• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

GrottoCenter / grottocenter-api / 22207698595

20 Feb 2026 01:36AM UTC coverage: 85.22% (+0.04%) from 85.177%
22207698595

push

github

ClemRz
feat(massif): enforce polygon area limit and decouple entrances from GET response

2616 of 3232 branches covered (80.94%)

Branch coverage included in aggregate %.

48 of 49 new or added lines in 7 files covered. (97.96%)

2 existing lines in 1 file now uncovered.

5554 of 6355 relevant lines covered (87.4%)

29.02 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.5
/api/services/GeoLocService.js
1
const PUBLIC_ENTRANCES_IN_BOUNDS = `
2✔
2
  SELECT e.id as id, ne.name as name, e.city as city,
3
  e.region as region, e.longitude as longitude, e.latitude as latitude,
4
  c.size_coef as size_coef, e.id_cave as idCave, nc.name as nameCave, c.depth as depthCave,
5
  c.length as lengthCave
6
  FROM t_entrance as e
7
  LEFT JOIN t_name as ne ON ne.id_entrance = e.id
8
  LEFT JOIN t_name as nc ON nc.id_cave = e.id_cave
9
  LEFT JOIN t_cave as c ON c.Id = e.id_cave
10
  WHERE e.latitude > $1 AND e.latitude < $2 AND e.longitude > $3 AND e.longitude < $4
11
  AND e.is_sensitive = false
12
  AND e.is_deleted = false
13
  ORDER BY size_coef DESC
14
  LIMIT $5;
15
`;
16

17
const PUBLIC_ENTRANCES_IN_BOUNDS_AND_MASSIF = `
2✔
18
  SELECT e.id as id, ne.name as name, e.city as city,
19
  e.region as region, e.longitude as longitude, e.latitude as latitude,
20
  c.size_coef as size_coef, e.id_cave as idCave, nc.name as nameCave, c.depth as depthCave,
21
  c.length as lengthCave
22
  FROM t_entrance as e
23
  LEFT JOIN t_name as ne ON ne.id_entrance = e.id
24
  LEFT JOIN t_name as nc ON nc.id_cave = e.id_cave
25
  LEFT JOIN t_cave as c ON c.Id = e.id_cave
26
  JOIN t_massif AS m ON m.id = $6
27
  WHERE e.latitude > $1 AND e.latitude < $2 AND e.longitude > $3 AND e.longitude < $4
28
  AND ST_Contains(m.geog_polygon::geometry, e.point_geom)
29
  AND e.is_sensitive = false
30
  AND e.is_deleted = false
31
  ORDER BY size_coef DESC
32
  LIMIT $5;
33
`;
34
const PUBLIC_ENTRANCES_COORDINATES_IN_BOUNDS = `
2✔
35
  SELECT e.longitude as longitude, e.latitude as latitude
36
  FROM t_entrance as e
37
  WHERE e.latitude > $1 AND e.latitude < $2 AND e.longitude > $3 AND e.longitude < $4
38
  AND e.is_sensitive = false
39
  AND e.is_deleted = false
40
  LIMIT $5;
41
`;
42

43
const PUBLIC_ENTRANCES_COORDINATES_IN_BOUNDS_AND_MASSIF = `
2✔
44
  SELECT e.longitude AS longitude, e.latitude AS latitude
45
  FROM t_entrance AS e
46
  JOIN t_massif AS m ON m.id = $6
47
  WHERE e.latitude > $1 AND e.latitude < $2
48
  AND e.longitude > $3 AND e.longitude < $4
49
  AND ST_Contains(m.geog_polygon::geometry, e.point_geom)
50
  AND e.is_sensitive = false
51
  AND e.is_deleted = false
52
  LIMIT $5;
53
`;
54

55
const NETWORKS_IN_BOUNDS = `
2✔
56
  SELECT c.id as id, COALESCE(nc.name, ne.name) as name, avg(en.longitude) as longitude, avg(en.latitude) as latitude
57
  FROM t_entrance as en
58
  INNER JOIN t_cave c ON c.id = en.id_cave
59
  LEFT JOIN t_name AS nc ON nc.id_cave = c.id
60
  LEFT JOIN t_name as ne ON ne.id_entrance = en.id
61
  WHERE en.latitude > $1 AND en.latitude < $2 AND en.longitude > $3 AND en.longitude < $4
62
  AND en.is_sensitive = false
63
  AND en.is_deleted = false
64
  AND c.is_deleted = false
65
  GROUP BY c.id, COALESCE(nc.name, ne.name)
66
  HAVING count(en.id_cave) > 1
67
`;
68

69
const PUBLIC_NETWORKS_COORDINATES_IN_BOUNDS = `
2✔
70
  SELECT avg(en.longitude) as longitude, avg(en.latitude) as latitude
71
  FROM t_cave AS c
72
  LEFT JOIN t_entrance en ON c.id = en.id_cave
73
  WHERE en.latitude > $1 AND en.latitude < $2 AND en.longitude > $3 AND en.longitude < $4
74
  AND en.is_sensitive = false
75
  AND en.is_deleted = false
76
  AND c.is_deleted = false
77
  GROUP BY c.id
78
  HAVING count(en.id_cave) > 1
79
  LIMIT $5;
80
`;
81

82
const CommonService = require('./CommonService');
2✔
83
const NameService = require('./NameService');
2✔
84

85
/**
86
 * return a light version of the networks
87
 * @param networks
88
 */
89
const formatNetworks = (networks) =>
2✔
90
  networks.map((network) => ({
1✔
91
    id: network.id,
92
    name: network.name,
93
    longitude: Number(network.longitude),
94
    latitude: Number(network.latitude),
95
  }));
96

97
/**
98
 * Format the quality entrances in a lighter version
99
 * Quality entrance stand for an entrance that won't be clustered
100
 * @param entrances
101
 */
102
const formatEntrances = (entrances) =>
2✔
103
  entrances.map((entrance) => ({
3✔
104
    id: entrance.id,
105
    name: entrance.name,
106
    city: entrance.city,
107
    region: entrance.region,
108
    caveId: entrance.idcave,
109
    caveName: entrance.namecave,
110
    depth: entrance.depthcave,
111
    length: entrance.lengthcave,
112
    longitude: parseFloat(entrance.longitude),
113
    latitude: parseFloat(entrance.latitude),
114
    quality: entrance.size_coef,
115
  }));
116

117
/**
118
 * Return a lighter version of the grottos
119
 * @param grottos
120
 */
121
const formatGrottos = (grottos) =>
2✔
122
  grottos.map((grotto) => ({
2✔
123
    id: grotto.id,
124
    name: grotto.name,
125
    address: grotto.address,
126
    longitude: parseFloat(grotto.longitude),
127
    latitude: parseFloat(grotto.latitude),
128
  }));
129

130
/**
131
 * Parse and validate the optional `massif` query parameter.
132
 * Returns { massifId, errorResponse } where errorResponse is null if valid.
133
 * If errorResponse is not null, the caller should return it immediately.
134
 */
135
const checkAndGetMassifParam = async (req, res) => {
2✔
136
  const rawMassifId = req.param('massif', null);
106✔
137
  const massifId = rawMassifId ? parseInt(rawMassifId, 10) : null;
106✔
138

139
  if (rawMassifId && (!Number.isFinite(massifId) || massifId < 1)) {
106!
NEW
140
    return {
×
141
      massifId: null,
142
      errorResponse: res.badRequest(
143
        'massif parameter must be a positive integer.'
144
      ),
145
    };
146
  }
147

148
  if (massifId) {
106✔
149
    const massif = await TMassif.findOne(massifId);
104✔
150
    if (!massif) {
104✔
151
      return {
2✔
152
        massifId: null,
153
        errorResponse: res.notFound({
154
          message: `Massif of id ${massifId} not found.`,
155
        }),
156
      };
157
    }
158
  }
159

160
  return { massifId, errorResponse: null };
104✔
161
};
162

163
// ====================================
164

165
module.exports = {
2✔
166
  checkAndGetMassifParam,
167
  checkAndGetCoordinatesParams: (req) => {
168
    let errorMessage = '';
128✔
169
    const errors = [];
128✔
170
    const neededParams = [
128✔
171
      { key: 'sw_lat', name: 'South west latitude', value: null },
172
      { key: 'sw_lng', name: 'South west longitude', value: null },
173
      { key: 'ne_lat', name: 'North east latitude', value: null },
174
      { key: 'ne_lng', name: 'North east longitude', value: null },
175
    ];
176

177
    const result = neededParams.map((param) => ({
512✔
178
      ...param,
179
      value: req.param(param.key, null),
180
    }));
181

182
    // Check null values
183
    const missingParams = result.filter((p) => p.value === null);
512✔
184
    if (missingParams.length > 0) {
128✔
185
      errorMessage = 'You must provide the following parameter(s): ';
8✔
186
      for (const missingParam of missingParams) {
8✔
187
        errors.push(`${missingParam.name} value on key ${missingParam.key}`);
12✔
188
      }
189
    } else {
190
      // Check valid values
191
      for (const param of result) {
120✔
192
        if (
480✔
193
          param.key.endsWith('lat') &&
958✔
194
          (param.value < -90 || param.value > 90)
195
        ) {
196
          errors.push(
5✔
197
            `${param.name} value must be between -90 & 90 (value found: ${param.value})`
198
          );
199
        }
200
        if (
480✔
201
          param.key.endsWith('lng') &&
957✔
202
          (param.value < -180 || param.value > 180)
203
        ) {
204
          errors.push(
5✔
205
            `${param.name} value must be between -180 & 180 (value found: ${param.value})`
206
          );
207
        }
208
      }
209
    }
210

211
    if (errors.length > 0) errorMessage += `${errors.join(', ')}.`;
128✔
212

213
    return {
128✔
214
      errorMessage,
215
      southWestBound: {
216
        lat: result.find((p) => p.key === 'sw_lat').value,
128✔
217
        lng: result.find((p) => p.key === 'sw_lng').value,
256✔
218
      },
219
      northEastBound: {
220
        lat: result.find((p) => p.key === 'ne_lat').value,
384✔
221
        lng: result.find((p) => p.key === 'ne_lng').value,
512✔
222
      },
223
    };
224
  },
225

226
  countEntrances: async (southWestBound, northEastBound) => {
227
    const parameters = {
3✔
228
      isDeleted: false,
229
      latitude: {
230
        '>': southWestBound.lat,
231
        '<': northEastBound.lat,
232
      },
233
      longitude: {
234
        '>': southWestBound.lng,
235
        '<': northEastBound.lng,
236
      },
237
    };
238

239
    // TODO : to adapt when authentication will be implemented
240
    parameters.isSensitive = false;
3✔
241
    return TEntrance.count(parameters);
3✔
242
  },
243

244
  getEntrancesCoordinates: async (
245
    southWestBound,
246
    northEastBound,
247
    limitEntrances,
248
    massifId = null
2✔
249
  ) => {
250
    const query = massifId
104✔
251
      ? PUBLIC_ENTRANCES_COORDINATES_IN_BOUNDS_AND_MASSIF
252
      : PUBLIC_ENTRANCES_COORDINATES_IN_BOUNDS;
253
    const params = [
104✔
254
      southWestBound.lat,
255
      northEastBound.lat,
256
      southWestBound.lng,
257
      northEastBound.lng,
258
      limitEntrances,
259
    ];
260
    if (massifId) {
104✔
261
      params.push(massifId);
101✔
262
    }
263
    const results = await CommonService.query(query, params);
104✔
264
    if (!results || results.rows.length <= 0 || results.rows[0].count === 0) {
104✔
265
      return [];
103✔
266
    }
267
    const coordinates = results.rows;
1✔
268

269
    return coordinates.map((coord) => [
2✔
270
      Number(coord.longitude),
271
      Number(coord.latitude),
272
    ]);
273
  },
274

275
  getNetworksCoordinates: async (
276
    southWestBound,
277
    northEastBound,
278
    limitNetworks
279
  ) => {
280
    const results = await CommonService.query(
3✔
281
      PUBLIC_NETWORKS_COORDINATES_IN_BOUNDS,
282
      [
283
        southWestBound.lat,
284
        northEastBound.lat,
285
        southWestBound.lng,
286
        northEastBound.lng,
287
        limitNetworks,
288
      ]
289
    );
290
    if (!results || results.rows.length <= 0 || results.rows[0].count === 0) {
3✔
291
      return [];
2✔
292
    }
293
    const coordinates = results.rows;
1✔
294
    return coordinates.map((coord) => [
1✔
295
      Number(coord.longitude),
296
      Number(coord.latitude),
297
    ]);
298
  },
299

300
  /**
301
   * @param southWestBound
302
   * @param northEastBound
303
   * @param limitEntrances Max number of entrances that will be showed at a certain level of zoom
304
   * @returns {Promise<any>}
305
   */
306
  getEntrancesMap: async (
307
    southWestBound,
308
    northEastBound,
309
    limitEntrances,
310
    massifId = null
2✔
311
  ) => {
312
    const query = massifId
4✔
313
      ? PUBLIC_ENTRANCES_IN_BOUNDS_AND_MASSIF
314
      : PUBLIC_ENTRANCES_IN_BOUNDS;
315
    const params = [
4✔
316
      southWestBound.lat,
317
      northEastBound.lat,
318
      southWestBound.lng,
319
      northEastBound.lng,
320
      limitEntrances,
321
    ];
322
    if (massifId) {
4✔
323
      params.push(massifId);
1✔
324
    }
325
    const results = await CommonService.query(query, params);
4✔
326
    if (!results || results.rows.length <= 0 || results.rows[0].count === 0) {
4✔
327
      return [];
3✔
328
    }
329
    return formatEntrances(results.rows);
1✔
330
  },
331

332
  getGrottosMap: async (southWestBound, northEastBound) => {
333
    const parameters = {
2✔
334
      latitude: {
335
        '>': southWestBound.lat,
336
        '<': northEastBound.lat,
337
      },
338
      longitude: {
339
        '>': southWestBound.lng,
340
        '<': northEastBound.lng,
341
      },
342
    };
343
    const grottos = await TGrotto.find(parameters);
2✔
344
    await NameService.setNames(grottos, 'grotto');
2✔
345
    return formatGrottos(grottos);
2✔
346
  },
347

348
  getNetworksMap: async (southWestBound, northEastBound) => {
349
    const results = await CommonService.query(NETWORKS_IN_BOUNDS, [
3✔
350
      southWestBound.lat,
351
      northEastBound.lat,
352
      southWestBound.lng,
353
      northEastBound.lng,
354
    ]);
355
    if (!results || results.rows.length <= 0 || results.rows[0].count === 0) {
3✔
356
      return [];
2✔
357
    }
358
    return formatNetworks(results.rows);
1✔
359
  },
360
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc