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

GrottoCenter / grottocenter-api / 25452316144

06 May 2026 06:01PM UTC coverage: 86.493% (-0.004%) from 86.497%
25452316144

push

github

ClemRz
feat(notification): notify document author on validation and rejection

3014 of 3623 branches covered (83.19%)

Branch coverage included in aggregate %.

23 of 31 new or added lines in 3 files covered. (74.19%)

3 existing lines in 2 files now uncovered.

6233 of 7068 relevant lines covered (88.19%)

54.1 hits per line

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

91.02
/api/services/NotificationService.js
1
const NameService = require('./NameService');
8✔
2
const CommonService = require('./CommonService');
8✔
3

4
const NOTIFICATION_ENTITIES = {
8✔
5
  CAVE: 'cave',
6
  COMMENT: 'comment',
7
  DESCRIPTION: 'description',
8
  DOCUMENT: 'document',
9
  ENTRANCE: 'entrance',
10
  HISTORY: 'history',
11
  LOCATION: 'location',
12
  MASSIF: 'massif',
13
  ORGANIZATION: 'organization',
14
  RIGGING: 'rigging',
15
};
16

17
const NOTIFICATION_TYPES = {
8✔
18
  CREATE: 'CREATE',
19
  DELETE: 'DELETE',
20
  PERMANENT_DELETE: 'PERMANENT_DELETE',
21
  UPDATE: 'UPDATE',
22
  VALIDATE: 'VALIDATE',
23
  RESTORE: 'RESTORE',
24
  REJECT: 'REJECT',
25
};
26

27
async function removeOlderNotifications() {
28
  const query = `DELETE
×
29
                 FROM t_notification
30
                 WHERE date_inscription < current_timestamp - interval '2 month';`;
31
  await CommonService.query(query);
×
32
}
33

34
const safeGetPropId = (prop, data) => {
8✔
35
  if (data && data[prop]) {
1,881✔
36
    if (data[prop] instanceof Object) {
556✔
37
      return data[prop].id;
187✔
38
    }
39
    return data[prop];
369✔
40
  }
41
  return undefined;
1,325✔
42
};
43

44
const sendNotificationEmail = async (
8✔
45
  entity,
46
  notificationType,
47
  notificationEntity,
48
  req,
49
  user
50
) => {
51
  // Get entity name (handle all cases)
52
  const getEntityName = (entityData) => {
291✔
53
    if (entityData.name) return entityData.name;
291✔
54
    if (entityData.names) return entityData.names[0]?.name;
31✔
55
    if (entityData.title) return entityData.title;
30✔
56
    if (entityData.titles) return entityData.titles[0]?.text;
16✔
57
    if (entityData.body) return `${entityData.body.slice(0, 50)}...`;
15✔
58
    if (entityData.descriptions) return entityData.descriptions[0].title;
14✔
59
    return '';
13✔
60
  };
61

62
  const entityName = getEntityName(entity);
291✔
63

64
  // Format action verb
65
  const actionVerbMap = {
291✔
66
    [NOTIFICATION_TYPES.CREATE]: 'created',
67
    [NOTIFICATION_TYPES.DELETE]: 'deleted',
68
    [NOTIFICATION_TYPES.PERMANENT_DELETE]: 'permanently deleted',
69
    [NOTIFICATION_TYPES.UPDATE]: 'updated',
70
    [NOTIFICATION_TYPES.VALIDATE]: 'validated',
71
    [NOTIFICATION_TYPES.RESTORE]: 'restored',
72
    [NOTIFICATION_TYPES.REJECT]: 'rejected',
73
  };
74

75
  const actionVerb = actionVerbMap[notificationType];
291✔
76
  if (!actionVerb) {
291✔
77
    throw Error(`Unknown notification type: ${notificationType}`);
1✔
78
  }
79

80
  // Format entity Link
81
  const baseUrl = `${sails.config.custom.baseUrl}/ui/`;
290✔
82
  const relatedCaveId = safeGetPropId('cave', entity);
290✔
83
  const relatedEntranceId = safeGetPropId('entrance', entity);
290✔
84
  const relatedMassifId = safeGetPropId('massif', entity);
290✔
85

86
  const getRelatedEntityLink = () => {
290✔
87
    if (relatedCaveId) return `caves/${relatedCaveId}`;
22✔
88
    if (relatedEntranceId) return `entrances/${relatedEntranceId}`;
20✔
89
    if (relatedMassifId) return `massifs/${relatedMassifId}`;
5✔
90
    return null;
4✔
91
  };
92

93
  const directLinkEntities = {
290✔
94
    [NOTIFICATION_ENTITIES.CAVE]: `caves/${entity.id}`,
95
    [NOTIFICATION_ENTITIES.DOCUMENT]: `documents/${entity.id}`,
96
    [NOTIFICATION_ENTITIES.ENTRANCE]: `entrances/${entity.id}`,
97
    [NOTIFICATION_ENTITIES.MASSIF]: `massifs/${entity.id}`,
98
    [NOTIFICATION_ENTITIES.ORGANIZATION]: `organizations/${entity.id}`,
99
  };
100

101
  let entityLink;
102
  if (directLinkEntities[notificationEntity]) {
290✔
103
    entityLink = baseUrl + directLinkEntities[notificationEntity];
268✔
104
  } else {
105
    const relatedLink = getRelatedEntityLink();
22✔
106
    if (!relatedLink) {
22✔
107
      const entityTypeName = notificationEntity.toLowerCase();
4✔
108
      let requiredEntities;
109
      if (notificationEntity === NOTIFICATION_ENTITIES.DESCRIPTION) {
4✔
110
        requiredEntities = 'cave, entrance or massif';
1✔
111
      } else if (notificationEntity === NOTIFICATION_ENTITIES.LOCATION) {
3✔
112
        requiredEntities = 'entrance';
1✔
113
      } else {
114
        requiredEntities = 'cave or entrance';
2✔
115
      }
116
      throw Error(
4✔
117
        `Can't find related entity (${requiredEntities}) of the ${entityTypeName} with id ${entity.id}`
118
      );
119
    }
120
    entityLink = baseUrl + relatedLink;
18✔
121
  }
122

123
  await sails.helpers.sendEmail
286✔
124
    .with({
125
      allowResponse: false,
126
      emailSubject: 'Notification',
127
      i18n: req.i18n,
128
      recipientEmail: user.mail,
129
      viewName: 'notification',
130
      viewValues: {
131
        actionVerb,
132
        entityLink,
133
        entityName,
134
        entityType: notificationEntity,
135
        recipientName: user.nickname,
136
        subscriptionName: user.subscriptionName,
137
        subscriptionType: user.subscriptionType,
138
        isAuthorNotification: user.isAuthorNotification || false,
376✔
139
        validationComment: user.validationComment || null,
401✔
140
      },
141
    })
142
    .intercept('sendSESEmailError', () => {
143
      sails.log.error(
×
144
        `The email service has encountered an error while trying to notify user ${user.nickname} (id=${user.id}).`
145
      );
146
      return false;
×
147
    });
148
};
149

150
const getCountryMassifAndRegionSubscribers = async (
8✔
151
  entityCountryId,
152
  entityMassifIds,
153
  entityRegionId
154
) => {
155
  const countrySubscribers = [];
144✔
156
  const massifsSubscribers = [];
144✔
157
  const regionSubscribers = [];
144✔
158
  if (entityCountryId) {
144✔
159
    const country =
160
      await TCountry.findOne(entityCountryId).populate('subscribedCavers');
10✔
161
    if (country) {
10!
162
      countrySubscribers.push(
10✔
163
        ...country.subscribedCavers.map((caver) => ({
10✔
164
          ...caver,
165
          subscriptionName: country.nativeName,
166
          subscriptionType: 'country',
167
        }))
168
      );
169
    }
170
  }
171
  if (entityMassifIds) {
144!
172
    await Promise.all(
144✔
173
      entityMassifIds.map(async (massifId) => {
174
        const massif =
175
          await TMassif.findOne(massifId).populate('subscribedCavers');
77✔
176
        if (massif) {
77✔
177
          await NameService.setNames([massif], 'massif');
73✔
178
          massifsSubscribers.push(
73✔
179
            ...massif.subscribedCavers.map((caver) => ({
63✔
180
              ...caver,
181
              subscriptionType: 'massif',
182
              subscriptionName: massif.names[0]?.name,
183
            }))
184
          );
185
        }
186
      })
187
    );
188
  }
189
  if (entityRegionId) {
144✔
190
    const region =
191
      await TISO31662.findOne(entityRegionId).populate('subscribedCavers');
17✔
192
    if (region) {
17✔
193
      regionSubscribers.push(
3✔
194
        ...region.subscribedCavers.map((caver) => ({
×
195
          ...caver,
196
          subscriptionName: region.name,
197
          subscriptionType: 'region',
198
        }))
199
      );
200
    }
201
  }
202
  return { countrySubscribers, massifsSubscribers, regionSubscribers };
144✔
203
};
204

205
module.exports = {
8✔
206
  NOTIFICATION_ENTITIES,
207
  NOTIFICATION_TYPES,
208
  ...(process.env.NODE_ENV === 'test' ? { sendNotificationEmail } : undefined),
8!
209

210
  /**
211
   *
212
   * @param {*} req
213
   * @param {*} entity
214
   * @param {Number} notifierId
215
   * @param {NOTIFICATION_TYPES} notificationType
216
   * @param {NOTIFICATION_ENTITIES} notificationEntity
217
   * @return {Boolean} true if everything went well, else false
218
   */
219
  notifySubscribers: async (
220
    req,
221
    entity,
222
    notifierId,
223
    notificationType,
224
    notificationEntity
225
  ) => {
226
    // Had to require in the function to avoid a circular dependency with notifySubscribers() in CaveService.createCave()
227
    // eslint-disable-next-line global-require
228
    const CaveService = require('./CaveService');
155✔
229

230
    // Check params and silently fail to avoid sending an error to the client
231
    if (!Object.values(NOTIFICATION_ENTITIES).includes(notificationEntity)) {
155✔
232
      throw new Error(`Invalid notification entity: ${notificationEntity}`);
1✔
233
    }
234
    if (!Object.values(NOTIFICATION_TYPES).includes(notificationType)) {
154✔
235
      throw new Error(`Invalid notification type: ${notificationType}`);
1✔
236
    }
237
    if (!notifierId) {
153✔
238
      throw new Error(`Missing notifier id`);
1✔
239
    }
240

241
    try {
152✔
242
      // For the populateEntities() method, must use "grotto" instead of "organization"
243
      const entityKey =
244
        notificationEntity === NOTIFICATION_ENTITIES.ORGANIZATION
152✔
245
          ? 'grotto'
246
          : notificationEntity;
247

248
      // Format notification and populate entity
249
      const notification = await module.exports.populateEntities({
152✔
250
        dateInscription: new Date(),
251
        notificationType: (
252
          await TNotificationType.findOne({
253
            name: notificationType,
254
          })
255
        ).id,
256
        notifier: notifierId,
257
        [entityKey]: entity,
258
      });
259

260
      const populatedEntity = notification[entityKey];
152✔
261

262
      const caveId = safeGetPropId('cave', populatedEntity);
152✔
263
      const entranceId = safeGetPropId('entrance', populatedEntity);
152✔
264
      const massifId = safeGetPropId('massif', populatedEntity);
152✔
265

266
      const getMassifIdsFromCave = async (id) =>
152✔
267
        (await CaveService.getMassifs(id)).map((m) => m.id);
75✔
268
      const getCountryId = (id) => safeGetPropId('country', id);
152✔
269
      const getRegionId = (entityData) => entityData?.iso_3166_2;
152✔
270

271
      const getCountryFromCaveEntrances = (cave) => {
152✔
272
        if (cave?.entrances?.length > 0) {
16✔
273
          return getCountryId(cave.entrances[0]);
5✔
274
        }
275
        return null;
11✔
276
      };
277

278
      const resolveLocationFromCaveOrEntrance = async (
152✔
279
        relatedCaveId,
280
        relatedEntranceId,
281
        entityData
282
      ) => {
283
        if (relatedCaveId) {
62✔
284
          return {
3✔
285
            countryId: getCountryFromCaveEntrances(entityData.cave),
286
            massifIds: await getMassifIdsFromCave(relatedCaveId),
287
            regionId: entityData.cave?.entrances?.[0]
3!
288
              ? getRegionId(entityData.cave.entrances[0])
289
              : null,
290
          };
291
        }
292
        if (relatedEntranceId) {
59✔
293
          return {
19✔
294
            countryId: getCountryId(entityData.entrance),
295
            massifIds: await getMassifIdsFromCave(
296
              safeGetPropId('cave', entityData.entrance)
297
            ),
298
            regionId: getRegionId(entityData.entrance),
299
          };
300
        }
301
        return { countryId: null, massifIds: [], regionId: null };
40✔
302
      };
303

304
      // Entity-specific location resolution
305
      const entityResolvers = {
152✔
306
        [NOTIFICATION_ENTITIES.CAVE]: async () => ({
13✔
307
          countryId: getCountryFromCaveEntrances(populatedEntity),
308
          massifIds: await getMassifIdsFromCave(populatedEntity.id),
309
          regionId: populatedEntity?.entrances?.[0]
13✔
310
            ? getRegionId(populatedEntity.entrances[0])
311
            : null,
312
        }),
313

314
        [NOTIFICATION_ENTITIES.ENTRANCE]: async () => ({
39✔
315
          countryId: getCountryId(populatedEntity),
316
          massifIds: populatedEntity?.cave
39✔
317
            ? await getMassifIdsFromCave(safeGetPropId('cave', populatedEntity))
318
            : [],
319
          regionId: getRegionId(populatedEntity),
320
        }),
321

322
        [NOTIFICATION_ENTITIES.MASSIF]: async () => ({
11✔
323
          countryId: null,
324
          massifIds: [populatedEntity.id],
325
          regionId: null,
326
        }),
327

328
        [NOTIFICATION_ENTITIES.ORGANIZATION]: async () => ({
16✔
329
          countryId: getCountryId(populatedEntity),
330
          massifIds: [],
331
          regionId: getRegionId(populatedEntity),
332
        }),
333

334
        [NOTIFICATION_ENTITIES.LOCATION]: async () => {
335
          if (!entranceId)
11✔
336
            throw new Error(`Can't retrieve related entrance id.`);
2✔
337
          return {
9✔
338
            countryId: getCountryId(populatedEntity.entrance),
339
            massifIds: await getMassifIdsFromCave(
340
              safeGetPropId('cave', populatedEntity.entrance)
341
            ),
342
            regionId: getRegionId(populatedEntity.entrance),
343
          };
344
        },
345
      };
346

347
      // Entities that can relate to cave, entrance, or massif
348
      const multiRelationEntities = [
152✔
349
        NOTIFICATION_ENTITIES.COMMENT,
350
        NOTIFICATION_ENTITIES.DESCRIPTION,
351
        NOTIFICATION_ENTITIES.HISTORY,
352
        NOTIFICATION_ENTITIES.RIGGING,
353
      ];
354

355
      // Find massifs and country concerned about the notification
356
      let result;
357
      if (entityResolvers[notificationEntity]) {
152✔
358
        result = await entityResolvers[notificationEntity]();
90✔
359
      } else if (multiRelationEntities.includes(notificationEntity)) {
62✔
360
        result = await resolveLocationFromCaveOrEntrance(
30✔
361
          caveId,
362
          entranceId,
363
          populatedEntity
364
        );
365

366
        // Handle massif-only case for description and document
367
        if (!result.countryId && !result.massifIds.length && massifId) {
30!
368
          result.massifIds = [safeGetPropId('massif', populatedEntity)];
×
369
        }
370

371
        // Require cave or entrance for most entities
372
        if (
30✔
373
          !caveId &&
65✔
374
          !entranceId &&
375
          ![
376
            NOTIFICATION_ENTITIES.DESCRIPTION,
377
            NOTIFICATION_ENTITIES.DOCUMENT,
378
          ].includes(notificationEntity)
379
        ) {
380
          throw new Error(`Can't retrieve related cave or entrance id.`);
6✔
381
        }
382
      } else if (notificationEntity === NOTIFICATION_ENTITIES.DOCUMENT) {
32!
383
        result = await resolveLocationFromCaveOrEntrance(
32✔
384
          caveId,
385
          entranceId,
386
          populatedEntity
387
        );
388
        if (!result.countryId && !result.massifIds.length && massifId) {
32!
389
          result.massifIds = [safeGetPropId('massif', populatedEntity)];
×
390
        }
391
      } else {
392
        throw new Error(
×
393
          `Can't find what to do with the following notification entity value: ${notificationEntity}`
394
        );
395
      }
396

397
      const entityCountryId = result.countryId;
144✔
398
      const entityMassifIds = result.massifIds;
144✔
399
      const entityRegionId = result.regionId;
144✔
400

401
      // Find subscribers to the entity.
402
      const { countrySubscribers, massifsSubscribers, regionSubscribers } =
403
        await getCountryMassifAndRegionSubscribers(
144✔
404
          entityCountryId,
405
          entityMassifIds,
406
          entityRegionId
407
        );
408
      // Consolidate subscribers by user ID and combine subscription types
409
      const subscriberMap = new Map();
144✔
410

411
      const addSubscribers = (subscribers) => {
144✔
412
        subscribers
432✔
413
          .filter((u) => u.id !== notifierId)
73✔
414
          .forEach((user) => {
415
            if (subscriberMap.has(user.id)) {
73✔
416
              const existing = subscriberMap.get(user.id);
9✔
417
              existing.subscriptionNames.push(user.subscriptionName);
9✔
418
              existing.subscriptionTypes.push(user.subscriptionType);
9✔
419
            } else {
420
              subscriberMap.set(user.id, {
64✔
421
                ...user,
422
                subscriptionNames: [user.subscriptionName],
423
                subscriptionTypes: [user.subscriptionType],
424
              });
425
            }
426
          });
427
      };
428

429
      addSubscribers(countrySubscribers);
144✔
430
      addSubscribers(massifsSubscribers);
144✔
431
      addSubscribers(regionSubscribers);
144✔
432

433
      const uniqueUsers = Array.from(subscriberMap.values()).map((user) => ({
144✔
434
        ...user,
435
        subscriptionName: user.subscriptionNames.join(' and '),
436
        subscriptionType: user.subscriptionTypes.join(', '),
437
      }));
438

439
      // Create notifications & optionally send email
440
      const res = await Promise.all(
144✔
441
        uniqueUsers.map(async (user) => {
442
          try {
64✔
443
            await TNotification.create({
64✔
444
              ...notification,
445
              notified: user.id,
446
              [entityKey]: notification[entityKey].id, // id only for the DB storage
447
            });
448
          } catch (e) {
449
            sails.log.error(
×
450
              `An error occured when trying to create a notification: ${e.message}`
451
            );
452
            return false;
×
453
          }
454

455
          if (user.sendNotificationByEmail) {
64!
456
            await sendNotificationEmail(
×
457
              populatedEntity,
458
              notificationType,
459
              notificationEntity,
460
              req,
461
              user
462
            );
463
          }
464
          return true;
64✔
465
        })
466
      );
467

468
      // 5% chance to also remove older notifications
469
      if (process.env.NODE_ENV !== 'test' && Math.random() < 0.05) {
144!
470
        try {
×
471
          await removeOlderNotifications();
×
472
        } catch (cleanupError) {
473
          sails.log.error(
×
474
            `Error during notification cleanup: ${cleanupError.message}`
475
          );
476
        }
477
      }
478

479
      return res;
144✔
480
    } catch (error) {
481
      // Fail silently to avoid sending an error to the user
482
      sails.log.error(
8✔
483
        `An error occurred when trying to notify subscribers: ${error.message} ${error.stack}`
484
      );
485
      return false;
8✔
486
    }
487
  },
488

489
  /**
490
   * Create an in-app notification for the document author and optionally send an email.
491
   *
492
   * @param {Object}  req              - Express request (carries i18n)
493
   * @param {Object}  document         - Populated TDocument (must have .author)
494
   * @param {Number}  moderatorId      - ID of the moderator who made the decision
495
   * @param {String}  notificationType - NOTIFICATION_TYPES.VALIDATE or NOTIFICATION_TYPES.REJECT
496
   * @param {String|null} validationComment - Moderator's comment (required for REJECT)
497
   * @returns {Boolean} true on success, false on silent failure
498
   */
499
  notifyAuthor: async (
500
    req,
501
    document,
502
    moderatorId,
503
    notificationType,
504
    validationComment
505
  ) => {
506
    const authorId = safeGetPropId('author', document);
323✔
507
    if (!authorId) {
323✔
508
      sails.log.debug(
1✔
509
        `notifyAuthor: document ${document.id} has no author, skipping notification`
510
      );
511
      return true;
1✔
512
    }
513
    if (authorId === moderatorId) {
322✔
514
      return true;
103✔
515
    }
516

517
    if (!Object.values(NOTIFICATION_TYPES).includes(notificationType)) {
219!
NEW
518
      throw new Error(`Invalid notification type: ${notificationType}`);
×
519
    }
520

521
    try {
219✔
522
      const notificationTypeRecord = await TNotificationType.findOne({
219✔
523
        name: notificationType,
524
      });
525

526
      if (!notificationTypeRecord) {
219!
NEW
527
        throw new Error(
×
528
          `Notification type '${notificationType}' not found in DB — migration may not have run`
529
        );
530
      }
531

532
      await TNotification.create({
219✔
533
        dateInscription: new Date(),
534
        notificationType: notificationTypeRecord.id,
535
        notifier: moderatorId,
536
        notified: authorId,
537
        document: document.id,
538
      });
539

540
      const author = await TCaver.findOne(authorId);
219✔
541

542
      if (author && author.sendNotificationByEmail) {
219✔
543
        await sendNotificationEmail(
47✔
544
          document,
545
          notificationType,
546
          NOTIFICATION_ENTITIES.DOCUMENT,
547
          req,
548
          {
549
            ...author,
550
            isAuthorNotification: true,
551
            validationComment,
552
          }
553
        );
554
      }
555

556
      return true;
219✔
557
    } catch (error) {
NEW
558
      sails.log.error(
×
559
        `An error occurred when trying to notify the document author: ${error.message} ${error.stack}`
560
      );
NEW
561
      return false;
×
562
    }
563
  },
564

565
  populateEntities: async (notification) => {
566
    const populatedNotification = notification;
202✔
567
    if (populatedNotification.cave) {
202✔
568
      await NameService.setNames([populatedNotification.cave], 'cave');
15✔
569
    }
570
    if (populatedNotification.comment) {
202✔
571
      populatedNotification.comment = await TComment.findOne(
11✔
572
        safeGetPropId('comment', notification)
573
      )
574
        .populate('cave')
575
        .populate('entrance');
576
    }
577
    if (populatedNotification.description) {
202✔
578
      populatedNotification.description = await TDescription.findOne(
10✔
579
        safeGetPropId('description', notification)
580
      )
581
        .populate('cave')
582
        .populate('document')
583
        .populate('entrance')
584
        .populate('massif');
585
    }
586
    if (populatedNotification.document) {
202✔
587
      // Had to require in the function to avoid a circular dependency with notifySubscribers() in DocumentService.createDocument()
588
      // eslint-disable-next-line global-require
589
      const DocumentService = require('./DocumentService');
39✔
590
      const populatedDocuments = await DocumentService.getDocuments([
39✔
591
        safeGetPropId('document', notification),
592
      ]);
593
      populatedNotification.document = populatedDocuments[0];
39✔
594
    }
595
    if (populatedNotification.entrance) {
202✔
596
      await NameService.setNames([populatedNotification.entrance], 'entrance');
55✔
597
    }
598
    if (populatedNotification.grotto) {
202✔
599
      await NameService.setNames([populatedNotification.grotto], 'grotto');
16✔
600
    }
601
    if (populatedNotification.history) {
202✔
602
      populatedNotification.history = await THistory.findOne(
7✔
603
        safeGetPropId('history', notification)
604
      )
605
        .populate('cave')
606
        .populate('entrance');
607
    }
608
    if (populatedNotification.location) {
202✔
609
      populatedNotification.location = await TLocation.findOne(
12✔
610
        safeGetPropId('location', notification)
611
      ).populate('entrance');
612
    }
613
    if (populatedNotification.massif) {
202✔
614
      await NameService.setNames([populatedNotification.massif], 'massif');
11✔
615
    }
616
    if (populatedNotification.rigging) {
202✔
617
      populatedNotification.rigging = await TRigging.findOne(
6✔
618
        safeGetPropId('rigging', notification)
619
      )
620
        .populate('entrance')
621
        .populate('cave');
622
    }
623
    return populatedNotification;
202✔
624
  },
625
};
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