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

SyTW2526 / Proyecto-E09 / 19927988907

04 Dec 2025 11:50AM UTC coverage: 15.275% (+15.3%) from 0.0%
19927988907

push

github

alu0101539669
Fix: arreglo el package.json añadiendo los test unitarios para que muestre la tabla

153 of 1688 branches covered (9.06%)

Branch coverage included in aggregate %.

403 of 1952 relevant lines covered (20.65%)

0.74 hits per line

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

0.0
/src/client/services/apiService.ts
1
/**
2
 * @file apiService.ts
3
 * @description Servicio centralizado para todas las llamadas API REST y tcgDex
4
 * 
5
 * Proporciona métodos para:
6
 * - Búsqueda y obtención de cartas
7
 * - Operaciones CRUD de usuarios
8
 * - Gestión de trading y solicitudes
9
 * - Operaciones de colección de cartas
10
 * - Notificaciones y preferencias
11
 * 
12
 * URLs de API:
13
 * - API local: http://localhost:3000
14
 * - API externa tcgDex: https://api.tcgdex.net/v2/en
15
 * 
16
 * @requires authService - Servicio de autenticación
17
 * @module services/apiService
18
 */
19

20
import { types } from 'util';
21
import { PokemonCard, ApiResponse, PaginatedResponse, User, TradeStatus, UserOwnedCard } from '../types';
22
import { authService } from './authService';
23

24
/**
25
 * URL base de la API local del servidor
26
 * @constant
27
 * @type {string}
28
 */
29
const API_BASE_URL = 'http://localhost:3000'; // URL base de la API del servidor
×
30

31
/**
32
 * URL base de la API pública tcgDex
33
 * @constant
34
 * @type {string}
35
 */
36
const TCGDEX_URL = 'https://api.tcgdex.net/v2/en'; // API pública de tcgDex
×
37

38
/**
39
 * Obtiene el prefijo alfabético de un código de set
40
 * Ejemplo: "swsh1" -> "swsh", "base1" -> "ba"
41
 * 
42
 * @param {string} [setCode] - Código del set
43
 * @returns {string} Prefijo alfabético
44
 */
45
function alphaPrefix(setCode: string | undefined) {
46
  if (!setCode) return '';
×
47
  const m = String(setCode).match(/^[a-zA-Z]+/);
×
48
  if (m) return m[0];
×
49
  return String(setCode).slice(0, 2);
×
50
}
51

52
/**
53
 * Clase que agrupa todas las operaciones de API
54
 * @class ApiService
55
 */
56
class ApiService {
57
  /**
58
   * Obtiene las cartas destacadas de la aplicación
59
   * @async
60
   * @returns {Promise<PokemonCard[]>} Array de cartas destacadas
61
   * @example
62
   * const featured = await apiService.fetchFeaturedCards();
63
   */
64
  async fetchFeaturedCards(): Promise<PokemonCard[]> {
65
    try {
×
66
      const res = await fetch(`${API_BASE_URL}/cards/featured`);
×
67
      if (!res.ok) throw new Error("Error al obtener cartas destacadas");
×
68
      const data: ApiResponse<PokemonCard[]> = await res.json();
×
69
      return data.data;
×
70
    } catch (err) {
71
      console.error("Error:", err);
×
72
      return [];
×
73
    }
74
  }
75

76
  /**
77
   * Busca cartas en la base de datos local
78
   * @async
79
   * @param {string} query - Término de búsqueda
80
   * @param {number} [page=1] - Número de página
81
   * @param {number} [limit=20] - Límite de resultados
82
   * @returns {Promise<PaginatedResponse<PokemonCard>>} Resultados paginados
83
   */
84
  async searchCards(
85
    query: string,
86
    page = 1,
×
87
    limit = 20
×
88
  ): Promise<PaginatedResponse<PokemonCard>> {
89
    try {
×
90
      const res = await fetch(
×
91
        `${API_BASE_URL}/cards/search?q=${encodeURIComponent(query)}&page=${page}&limit=${limit}`
92
      );
93
      if (!res.ok) throw new Error("Error buscando cartas");
×
94
      return await res.json();
×
95
    } catch (err) {
96
      console.error("Error:", err);
×
97
      return { data: [], total: 0, page, limit };
×
98
    }
99
  }
100

101
  /**
102
   * Busca cartas en la API tcgDex (datos actualizados en tiempo real)
103
   * @async
104
   * @param {string} query - Término de búsqueda
105
   * @param {number} [page=1] - Número de página
106
   * @param {number} [limit=20] - Límite de resultados
107
   * @param {string} [set] - Filtro opcional por set
108
   * @param {string} [rarity] - Filtro opcional por rareza
109
   * @returns {Promise<PaginatedResponse<any>>} Resultados de la búsqueda
110
   */
111
  async searchTcgCards(query: string, page = 1, limit = 20, set?: string, rarity?: string): Promise<{ data: any[]; total: number; page: number; limit: number }> {
×
112
    try {
×
113
      const params = new URLSearchParams();
×
114
      params.append('q', query);
×
115
      params.append('page', String(page));
×
116
      params.append('limit', String(limit));
×
117
      if (set) params.append('set', set);
×
118

119
      if (rarity) params.append('rarity', rarity);
×
120
      const res = await fetch(`${API_BASE_URL}/cards/search/tcg?${params.toString()}`);
×
121
      if (!res.ok) throw new Error('Error searching TCGdex');
×
122
      return await res.json();
×
123
    } catch (err) {
124
      console.error('Error searchTcgCards:', err);
×
125
      return { data: [], total: 0, page, limit };
×
126
    }
127
  }
128

129
  async searchTcgQuick(query: string, limit = 10): Promise<any[]> {
×
130
    try {
×
131
      const params = new URLSearchParams();
×
132
      params.append('q', query);
×
133
      params.append('page', '1');
×
134
      params.append('limit', String(limit));
×
135
      const res = await fetch(`${API_BASE_URL}/cards/search/tcg?${params.toString()}`);
×
136
      if (!res.ok) throw new Error('Error quick searching TCGdex');
×
137
      const payload = await res.json();
×
138
      return payload.data || [];
×
139
    } catch (err) {
140
      console.error('Error searchTcgQuick:', err);
×
141
      return [];
×
142
    }
143
  }
144

145
  async quickSearchCards(query: string): Promise<PokemonCard[]> {
146
    try {
×
147
      const res = await fetch(
×
148
        `${API_BASE_URL}/cards/search/quick?q=${encodeURIComponent(query)}`
149
      );
150
      if (!res.ok) throw new Error("Error buscando cartas rápidamente");
×
151
      const data = await res.json();
×
152
      return data.data || [];
×
153
    } catch (err) {
154
      console.error("Error:", err);
×
155
      return [];
×
156
    }
157
  }
158

159
  async getCardById(id: string): Promise<PokemonCard | null> {
160
    try {
×
161
      const res = await fetch(`${API_BASE_URL}/cards/${id}`);
×
162
      if (!res.ok) throw new Error("Error al obtener carta");
×
163
      const data: ApiResponse<PokemonCard> = await res.json();
×
164
      return data.data;
×
165
    } catch (err) {
166
      console.error("Error:", err);
×
167
      return null;
×
168
    }
169
  }
170

171
  async fetchFromTcgDex(endpoint: string): Promise<any> {
172
    try {
×
173
      const res = await fetch(`${TCGDEX_URL}/${endpoint}`);
×
174
      if (!res.ok) throw new Error("Error al conectar con TCGdex");
×
175
      return await res.json();
×
176
    } catch (err) {
177
      console.error("Error:", err);
×
178
      return null;
×
179
    }
180
  }
181

182
  async getTcgDexSets(): Promise<any[]> {
183
    return this.fetchFromTcgDex("sets");
×
184
  }
185

186
  async getCardsFromTcgDexSet(setId: string): Promise<any | null> {
187
    try {
×
188
      const res = await this.fetchFromTcgDex(`sets/${setId}`);
×
189
      if (!res) return null;
×
190
      // normalize response shape
191
      let payload = res.data ?? res;
×
192
      // some responses nest under `set`
193
      if (payload.set) payload = payload.set;
×
194

195
      const cards = payload.cards ?? payload.data?.cards ?? payload.set?.cards ?? [];
×
196
      const images = payload.images ?? payload.image ?? payload.logo ?? {};
×
197
      const name = payload.name ?? payload.title ?? payload.setName ?? '';
×
198
      const id = payload.id ?? setId;
×
199

200
      return { id, name, images, cards, raw: res };
×
201
    } catch (err) {
202
      console.error('Error fetching set from TCGdex:', err);
×
203
      return null;
×
204
    }
205
  }
206

207
  async getTcgDexCard(setId: string, cardId: string): Promise<any> {
208
    return this.fetchFromTcgDex(`sets/${setId}/${cardId}`);
×
209
  }
210

211
  async addToCollection(userId: string, cardId: string): Promise<boolean> {
212
    try {
×
213
      const res = await fetch(`${API_BASE_URL}/users/${userId}/collection`, {
×
214
        method: "POST",
215
        headers: { "Content-Type": "application/json" },
216
        body: JSON.stringify({ cardId }),
217
      });
218
      return res.ok;
×
219
    } catch (err) {
220
      console.error("Error:", err);
×
221
      return false;
×
222
    }
223
  }
224

225
  async removeFromCollection(userId: string, cardId: string): Promise<boolean> {
226
    try {
×
227
      const res = await fetch(
×
228
        `${API_BASE_URL}/users/${userId}/collection/${cardId}`,
229
        { method: "DELETE" }
230
      );
231
      return res.ok;
×
232
    } catch (err) {
233
      console.error("Error:", err);
×
234
      return false;
×
235
    }
236
  }
237

238
  async addFriend(userId: string, friendId: string): Promise<User | null> {
239
    try {
×
240
      const res = await fetch(`${API_BASE_URL}/users/${userId}/friends/${friendId}`, {
×
241
        method: "POST",
242
      });
243
      if (!res.ok) throw new Error("Error al añadir amigo");
×
244
      const data: ApiResponse<User> = await res.json();
×
245
      return data.data;
×
246
    } catch (err) {
247
      console.error("Error al añadir amigo:", err);
×
248
      return null;
×
249
    }
250
  }
251

252
  async removeFriend(userId: string, friendId: string): Promise<boolean> {
253
    try {
×
254
      const res = await fetch(`${API_BASE_URL}/users/${userId}/friends/${friendId}`, {
×
255
        method: "DELETE",
256
      });
257
      return res.ok;
×
258
    } catch (err) {
259
      console.error("Error al eliminar amigo:", err);
×
260
      return false;
×
261
    }
262
  }
263

264
  async createTrade(data: {
265
    initiatorUserId: string;
266
    receiverUserId: string;
267
    tradeType?: "private" | "public";
268
    initiatorCards?: any[];
269
    receiverCards?: any[];
270
  }): Promise<any> {
271
    try {
×
272
      const res = await fetch(`${API_BASE_URL}/trades`, {
×
273
        method: "POST",
274
        headers: { "Content-Type": "application/json" },
275
        body: JSON.stringify(data),
276
      });
277
      if (!res.ok) throw new Error("Error creando el intercambio");
×
278
      return await res.json();
×
279
    } catch (err) {
280
      console.error("Error creando trade:", err);
×
281
      throw err;
×
282
    }
283
  }
284

285
  async getUserTrades(userId: string): Promise<any[]> {
286
    try {
×
287
      const res = await fetch(`${API_BASE_URL}/users/${userId}/trades`);
×
288
      if (!res.ok) throw new Error("Error obteniendo intercambios del usuario");
×
289
      const data: ApiResponse<any[]> = await res.json();
×
290
      return data.data;
×
291
    } catch (err) {
292
      console.error("Error:", err);
×
293
      return [];
×
294
    }
295
  }
296

297
  async updateTradeStatus(
298
    tradeId: string,
299
    status: TradeStatus
300
  ): Promise<any> {
301
    try {
×
302
      const res = await fetch(`${API_BASE_URL}/trades/${tradeId}`, {
×
303
        method: "PATCH",
304
        headers: { "Content-Type": "application/json" },
305
        body: JSON.stringify({ status }),
306
      });
307
      if (!res.ok) throw new Error("Error actualizando estado del intercambio");
×
308
      return await res.json();
×
309
    } catch (err) {
310
      console.error("Error:", err);
×
311
      throw err;
×
312
    }
313
  }
314

315
  async getUserById(userId: string): Promise<User | null> {
316
    try {
×
317
      const res = await fetch(`${API_BASE_URL}/users/${userId}`);
×
318
      if (!res.ok) throw new Error("Error obteniendo usuario");
×
319
      const data: ApiResponse<User> = await res.json();
×
320
      return data.data;
×
321
    } catch (err) {
322
      console.error("Error:", err);
×
323
      return null;
×
324
    }
325
  }
326

327
  async getUserFriends(userId: string): Promise<User[]> {
328
    try {
×
329
      const res = await fetch(`${API_BASE_URL}/users/${userId}/friends`);
×
330
      if (!res.ok) throw new Error("Error obteniendo amigos");
×
331
      const data: ApiResponse<User[]> = await res.json();
×
332
      return data.data;
×
333
    } catch (err) {
334
      console.error("Error:", err);
×
335
      return [];
×
336
    }
337
  }
338

339
  async getWishlist(userId: string): Promise<PokemonCard[]> {
340
    try {
×
341
      const res = await fetch(`${API_BASE_URL}/users/${userId}/wishlist`);
×
342
      if (!res.ok) throw new Error("Error obteniendo wishlist");
×
343
      const data: ApiResponse<PokemonCard[]> = await res.json();
×
344
      return data.data;
×
345
    } catch (err) {
346
      console.error("Error:", err);
×
347
      return [];
×
348
    }
349
  }
350

351
  async addToWishlist(userId: string, cardId: string): Promise<boolean> {
352
    try {
×
353
      // Add card to user's cards with collectionType=wishlist.
354
      const res = await fetch(`${API_BASE_URL}/users/${userId}/cards`, {
×
355
        method: "POST",
356
        headers: { "Content-Type": "application/json", ...authService.getAuthHeaders() },
357
        body: JSON.stringify({ pokemonTcgId: cardId, collectionType: 'wishlist', autoFetch: true }),
358
      });
359
      return res.ok;
×
360
    } catch (err) {
361
      console.error("Error:", err);
×
362
      return false;
×
363
    }
364
  }
365

366
  async addCardToUserCollectionByTcgId(userId: string, pokemonTcgId: string): Promise<boolean> {
367
    try {
×
368
      const res = await fetch(`${API_BASE_URL}/users/${userId}/cards`, {
×
369
        method: 'POST',
370
        headers: { 'Content-Type': 'application/json', ...authService.getAuthHeaders() },
371
        body: JSON.stringify({ pokemonTcgId, collectionType: 'collection', autoFetch: true })
372
      });
373
      return res.ok;
×
374
    } catch (err) {
375
      console.error('Error adding card to collection:', err);
×
376
      return false;
×
377
    }
378
  }
379

380
  async removeFromWishlist(userId: string, cardId: string): Promise<boolean> {
381
    try {
×
382
      // The server exposes deletion by userCard id under /usercards/:username/cards/:userCardId
383
      // We need to lookup the user's wishlist entries, find the matching userCard (by pokemonTcgId or cardId.pokemonTcgId)
384
      // and then call the existing delete route.
385
      const listRes = await fetch(`${API_BASE_URL}/usercards/${userId}/wishlist`);
×
386
      if (!listRes.ok) {
×
387
        // fallback: try the user-scoped endpoint
388
        const fallback = await fetch(`${API_BASE_URL}/users/${userId}/cards?collection=wishlist`);
×
389
        if (!fallback.ok) return false;
×
390
        const fallbackPayload = await fallback.json();
×
391
        const items = fallbackPayload.cards || fallbackPayload.results || [];
×
392
        const found = items.find((it: any) => (it.pokemonTcgId === cardId) || (it.cardId && it.cardId.pokemonTcgId === cardId));
×
393
        if (!found) return false;
×
394
        const delRes = await fetch(`${API_BASE_URL}/users/${userId}/cards/${found._id}`, {
×
395
          method: 'DELETE',
396
          headers: { ...authService.getAuthHeaders() }
397
        });
398
        return delRes.ok;
×
399
      }
400

401
      const payload = await listRes.json();
×
402
      const items = payload.cards || payload.results || [];
×
403
      const found = items.find((it: any) => (it.pokemonTcgId === cardId) || (it.cardId && it.cardId.pokemonTcgId === cardId));
×
404
      if (!found) return false;
×
405

406
      const delRes = await fetch(`${API_BASE_URL}/usercards/${userId}/cards/${found._id}`, {
×
407
        method: 'DELETE',
408
        headers: { ...authService.getAuthHeaders() },
409
      });
410
      return delRes.ok;
×
411
    } catch (err) {
412
      console.error('Error:', err);
×
413
      return false;
×
414
    }
415
  }
416

417
  async getUserWishlist(username: string): Promise<UserOwnedCard[]> {
418
    try {
×
419
      const res = await fetch(`${API_BASE_URL}/usercards/${username}/wishlist`);
×
420
      if (!res.ok) throw new Error("Error obteniendo wishlist del usuario");
×
421

422
      const data = await res.json();
×
423

424
      const results = [] as any[];
×
425
      // Collect items and batch-fetch missing cached cards with limited concurrency
426
      const items = data.cards || [];
×
427
      // build list of tcgIds we need to fetch
428
      const missingIds: string[] = [];
×
429
      const itemCardMap = new Map<number, any>();
×
430
      items.forEach((item: any, idx: number) => {
×
431
        const card = item.cardId || {};
×
432
        if ((!card || Object.keys(card).length === 0) && item.pokemonTcgId) {
×
433
          missingIds.push(item.pokemonTcgId);
×
434
        }
435
        itemCardMap.set(idx, card);
×
436
      });
437

438
      // helper: fetch cached cards in batches to avoid opening too many simultaneous connections
439
      const fetchCached = async (ids: string[]) => {
×
440
        const map: Record<string, any> = {};
×
441
        const concurrency = 8;
×
442
        for (let i = 0; i < ids.length; i += concurrency) {
×
443
          const batch = ids.slice(i, i + concurrency);
×
444
          const promises = batch.map((id) =>
×
445
            fetch(`${API_BASE_URL}/cards/tcg/${id}`)
×
446
              .then((r) => (r.ok ? r.json().catch(() => null) : null))
×
447
              .catch(() => null)
×
448
          );
449
          const resolved = await Promise.all(promises);
×
450
          resolved.forEach((payload, j) => {
×
451
            const id = batch[j];
×
452
            if (payload) map[id] = payload.card ?? payload;
×
453
          });
454
        }
455
        return map;
×
456
      };
457

458
      const cachedById = missingIds.length ? await fetchCached(Array.from(new Set(missingIds))) : {};
×
459

460
      for (let idx = 0; idx < items.length; idx++) {
×
461
        const item = items[idx];
×
462
        let card = itemCardMap.get(idx) || {};
×
463
        if ((!card || Object.keys(card).length === 0) && item.pokemonTcgId) {
×
464
          card = cachedById[item.pokemonTcgId] || {};
×
465
        }
466

467
        // derive image from multiple possible shapes
468
        let image = card.imageUrl || card.imageUrlHiRes || card.image || '';
×
469
        if (!image && card.images) {
×
470
          image = card.images.large || card.images.small || '';
×
471
        }
472

473
        const tcgId = item.pokemonTcgId || card.pokemonTcgId || '';
×
474
        if (!image && tcgId) {
×
475
          const [setCode, number] = tcgId.split('-');
×
476
          const series = alphaPrefix(setCode);
×
477
          if (setCode && number) {
×
478
            image = `https://assets.tcgdex.net/en/${series}/${setCode}/${number}/high.png`;
×
479
          }
480
        }
481

482
        results.push({
×
483
          id: item._id,
484
          name: card.name,
485
          image,
486
          rarity: card.rarity,
487
          forTrade: item.forTrade,
488
          pokemonTcgId: tcgId,
489
        });
490
      }
491

492
      return results;
×
493
    } catch (err) {
494
      console.error("Error wishlist:", err);
×
495
      return [];
×
496
    }
497
  }
498
  
499
  async getUserCollection(username: string): Promise<UserOwnedCard[]> {
500
    try {
×
501
      const res = await fetch(`${API_BASE_URL}/usercards/${username}/collection`);
×
502
      if (!res.ok) throw new Error("Error obteniendo colección del usuario");
×
503

504
      const data = await res.json();
×
505

506
      const results = [] as any[];
×
507
      // Batch-fetch missing cached cards to avoid sequential fetches per item
508
      const items = data.cards || [];
×
509
      const missingIds: string[] = [];
×
510
      const itemCardMap = new Map<number, any>();
×
511
      items.forEach((item: any, idx: number) => {
×
512
        const card = item.cardId || {};
×
513
        if ((!card || Object.keys(card).length === 0) && item.pokemonTcgId) missingIds.push(item.pokemonTcgId);
×
514
        itemCardMap.set(idx, card);
×
515
      });
516

517
      const fetchCached = async (ids: string[]) => {
×
518
        const map: Record<string, any> = {};
×
519
        const concurrency = 8;
×
520
        for (let i = 0; i < ids.length; i += concurrency) {
×
521
          const batch = ids.slice(i, i + concurrency);
×
522
          const promises = batch.map((id) =>
×
523
            fetch(`${API_BASE_URL}/cards/tcg/${id}`)
×
524
              .then((r) => (r.ok ? r.json().catch(() => null) : null))
×
525
              .catch(() => null)
×
526
          );
527
          const resolved = await Promise.all(promises);
×
528
          resolved.forEach((payload, j) => {
×
529
            const id = batch[j];
×
530
            if (payload) map[id] = payload.card ?? payload;
×
531
          });
532
        }
533
        return map;
×
534
      };
535

536
      const cachedById = missingIds.length ? await fetchCached(Array.from(new Set(missingIds))) : {};
×
537

538
      for (let idx = 0; idx < items.length; idx++) {
×
539
        const item = items[idx];
×
540
        let card = itemCardMap.get(idx) || {};
×
541
        if ((!card || Object.keys(card).length === 0) && item.pokemonTcgId) {
×
542
          card = cachedById[item.pokemonTcgId] || {};
×
543
        }
544

545
        let image = card.imageUrl || card.imageUrlHiRes || card.image || '';
×
546
        if (!image && card.images) {
×
547
          image = card.images.large || card.images.small || '';
×
548
        }
549

550
        const tcgId = item.pokemonTcgId || card.pokemonTcgId || '';
×
551
        if (!image && tcgId) {
×
552
          const [setCode, number] = tcgId.split('-');
×
553
          const series = alphaPrefix(setCode);
×
554
          if (setCode && number) {
×
555
            image = `https://assets.tcgdex.net/en/${series}/${setCode}/${number}/high.png`;
×
556
          }
557
        }
558

559
        results.push({
×
560
          id: item._id,
561
          name: card.name,
562
          image,
563
          set: card.set,
564
          types: card.types,
565
          category: card.category,
566
          rarity: card.rarity,
567
          forTrade: item.forTrade,
568
          pokemonTcgId: tcgId,
569
        });
570
      }
571

572
      return results;
×
573
    } catch (err) {
574
      console.error("Error colección:", err);
×
575
      return [];
×
576
    }
577
  }
578

579
  /**
580
   * Update a userCard fields (PATCH /users/:username/cards/:userCardId)
581
   * Returns true on success
582
   */
583
  async updateUserCard(username: string, userCardId: string, updates: Record<string, any>): Promise<boolean> {
584
    try {
×
585
      const res = await fetch(`${API_BASE_URL}/users/${username}/cards/${userCardId}`, {
×
586
        method: 'PATCH',
587
        headers: { 'Content-Type': 'application/json', ...authService.getAuthHeaders() },
588
        body: JSON.stringify(updates)
589
      });
590
      return res.ok;
×
591
    } catch (err) {
592
      console.error('Error updating userCard:', err);
×
593
      return false;
×
594
    }
595
  }
596

597
  async getCachedCardByTcgId(pokemonTcgId: string): Promise<any | null> {
598
    try {
×
599
      const res = await fetch(`${API_BASE_URL}/cards/tcg/${encodeURIComponent(pokemonTcgId)}`);
×
600
      if (!res.ok) return null;
×
601
      const payload = await res.json().catch(() => null);
×
602
      // the server may return { card } or the card directly
603
      return payload?.card ?? payload ?? null;
×
604
    } catch (err) {
605
      console.error('Error fetching cached card:', err);
×
606
      return null;
×
607
    }
608
  }
609
}
610

611

612
export default new ApiService();
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