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

api-platform / core / 15255731762

26 May 2025 01:55PM UTC coverage: 0.0% (-26.5%) from 26.526%
15255731762

Pull #7176

github

web-flow
Merge 66f6cf4d2 into 79edced67
Pull Request #7176: Merge 4.1

0 of 387 new or added lines in 28 files covered. (0.0%)

11394 existing lines in 372 files now uncovered.

0 of 51334 relevant lines covered (0.0%)

0.0 hits per line

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

0.0
/src/Laravel/Tests/EloquentTest.php
1
<?php
2

3
/*
4
 * This file is part of the API Platform project.
5
 *
6
 * (c) Kévin Dunglas <dunglas@gmail.com>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11

12
declare(strict_types=1);
13

14
namespace ApiPlatform\Laravel\Tests;
15

16
use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait;
17
use ApiPlatform\Laravel\workbench\app\Enums\BookStatus;
18
use Illuminate\Foundation\Testing\RefreshDatabase;
19
use Illuminate\Support\Str;
20
use Orchestra\Testbench\Concerns\WithWorkbench;
21
use Orchestra\Testbench\TestCase;
22
use Workbench\Database\Factories\AuthorFactory;
23
use Workbench\Database\Factories\BookFactory;
24
use Workbench\Database\Factories\GrandSonFactory;
25
use Workbench\Database\Factories\WithAccessorFactory;
26

27
class EloquentTest extends TestCase
28
{
29
    use ApiTestAssertionsTrait;
30
    use RefreshDatabase;
31
    use WithWorkbench;
32

33
    public function testBackedEnumsNormalization(): void
34
    {
35
        BookFactory::new([
×
36
            'status' => BookStatus::DRAFT,
×
37
        ])->has(AuthorFactory::new())->count(10)->create();
×
38

39
        $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]);
×
40
        $book = $response->json()['member'][0];
×
41

42
        $this->assertArrayHasKey('status', $book);
×
43
        $this->assertSame('DRAFT', $book['status']);
×
44
    }
45

46
    public function testSearchFilter(): void
47
    {
48
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
49

50
        $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]);
×
51
        $book = $response->json()['member'][0];
×
52

53
        $response = $this->get('/api/books?isbn='.$book['isbn'], ['Accept' => ['application/ld+json']]);
×
54
        $this->assertSame($response->json()['member'][0], $book);
×
55
    }
56

57
    public function testValidateSearchFilter(): void
58
    {
59
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
60

61
        $response = $this->get('/api/books?isbn=a', ['Accept' => ['application/ld+json']]);
×
62
        $this->assertSame($response->json()['detail'], 'The isbn field must be at least 2 characters.');
×
63
    }
64

65
    public function testSearchFilterRelation(): void
66
    {
67
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
68

69
        $response = $this->get('/api/books?author=1', ['Accept' => ['application/ld+json']]);
×
70
        $this->assertSame($response->json()['member'][0]['author'], '/api/authors/1');
×
71
    }
72

73
    public function testPropertyFilter(): void
74
    {
75
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
76

77
        $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]);
×
78
        $book = $response->json()['member'][0];
×
79

80
        $response = $this->get(\sprintf('%s.jsonld?properties[]=author', $book['@id']));
×
81
        $book = $response->json();
×
82

83
        $this->assertArrayHasKey('@id', $book);
×
84
        $this->assertArrayHasKey('author', $book);
×
85
        $this->assertArrayNotHasKey('name', $book);
×
86
    }
87

88
    public function testPartialSearchFilter(): void
89
    {
90
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
91

92
        $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]);
×
93
        $book = $response->json()['member'][0];
×
94

95
        if (!isset($book['name'])) {
×
96
            throw new \UnexpectedValueException();
×
97
        }
98

99
        $end = strpos($book['name'], ' ') ?: 3;
×
100
        $name = substr($book['name'], 0, $end);
×
101

102
        $response = $this->get('/api/books?name='.$name, ['Accept' => ['application/ld+json']]);
×
103
        $this->assertSame($response->json()['member'][0], $book);
×
104
    }
105

106
    public function testDateFilterEqual(): void
107
    {
108
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
109

110
        $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]);
×
111
        $book = $response->json()['member'][0];
×
112
        $updated = $this->patchJson(
×
113
            $book['@id'],
×
114
            ['publicationDate' => '2024-02-18 00:00:00'],
×
115
            [
×
116
                'Accept' => ['application/ld+json'],
×
117
                'Content-Type' => ['application/merge-patch+json'],
×
118
            ]
×
119
        );
×
120

121
        $response = $this->get('/api/books?publicationDate[eq]='.$updated['publicationDate'], ['Accept' => ['application/ld+json']]);
×
122
        $this->assertSame($response->json()['member'][0]['@id'], $book['@id']);
×
123
    }
124

125
    public function testDateFilterIncludeNull(): void
126
    {
127
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
128

129
        $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]);
×
130
        $book = $response->json()['member'][0];
×
131
        $updated = $this->patchJson(
×
132
            $book['@id'],
×
133
            ['publicationDate' => null],
×
134
            [
×
135
                'Accept' => ['application/ld+json'],
×
136
                'Content-Type' => ['application/merge-patch+json'],
×
137
            ]
×
138
        );
×
139

140
        $response = $this->get('/api/books?publicationWithNulls[gt]=9999-12-31', ['Accept' => ['application/ld+json']]);
×
141
        $this->assertGreaterThan(0, $response->json()['totalItems']);
×
142
    }
143

144
    public function testDateFilterExcludeNull(): void
145
    {
146
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
147

148
        $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]);
×
149
        $book = $response->json()['member'][0];
×
150
        $updated = $this->patchJson(
×
151
            $book['@id'],
×
152
            ['publicationDate' => null],
×
153
            [
×
154
                'Accept' => ['application/ld+json'],
×
155
                'Content-Type' => ['application/merge-patch+json'],
×
156
            ]
×
157
        );
×
158

159
        $response = $this->get('/api/books?publicationDate[gt]=9999-12-31', ['Accept' => ['application/ld+json']]);
×
160
        $this->assertSame(0, $response->json()['totalItems']);
×
161
    }
162

163
    public function testDateFilterGreaterThan(): void
164
    {
165
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
166

167
        $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]);
×
168
        $bookBefore = $response->json()['member'][0];
×
169
        $updated = $this->patchJson(
×
170
            $bookBefore['@id'],
×
171
            ['publicationDate' => '9998-02-18 00:00:00'],
×
172
            [
×
173
                'Accept' => ['application/ld+json'],
×
174
                'Content-Type' => ['application/merge-patch+json'],
×
175
            ]
×
176
        );
×
177

178
        $bookAfter = $response->json()['member'][1];
×
179
        $this->patchJson(
×
180
            $bookAfter['@id'],
×
181
            ['publicationDate' => '9999-02-18 00:00:00'],
×
182
            [
×
183
                'Accept' => ['application/ld+json'],
×
184
                'Content-Type' => ['application/merge-patch+json'],
×
185
            ]
×
186
        );
×
187

188
        $response = $this->get('/api/books?publicationDate[gt]='.$updated['publicationDate'], ['Accept' => ['application/ld+json']]);
×
189
        $this->assertSame($response->json()['member'][0]['@id'], $bookAfter['@id']);
×
190
        $this->assertSame($response->json()['totalItems'], 1);
×
191
    }
192

193
    public function testDateFilterLowerThanEqual(): void
194
    {
195
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
196
        $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]);
×
197
        $bookBefore = $response->json()['member'][0];
×
198
        $this->patchJson(
×
199
            $bookBefore['@id'],
×
200
            ['publicationDate' => '0001-02-18 00:00:00'],
×
201
            [
×
202
                'Accept' => ['application/ld+json'],
×
203
                'Content-Type' => ['application/merge-patch+json'],
×
204
            ]
×
205
        );
×
206

207
        $bookAfter = $response->json()['member'][1];
×
208
        $this->patchJson(
×
209
            $bookAfter['@id'],
×
210
            ['publicationDate' => '0002-02-18 00:00:00'],
×
211
            [
×
212
                'Accept' => ['application/ld+json'],
×
213
                'Content-Type' => ['application/merge-patch+json'],
×
214
            ]
×
215
        );
×
216

217
        $response = $this->get('/api/books?publicationDate[lte]=0002-02-18', ['Accept' => ['application/ld+json']]);
×
218
        $this->assertSame($response->json()['member'][0]['@id'], $bookBefore['@id']);
×
219
        $this->assertSame($response->json()['member'][1]['@id'], $bookAfter['@id']);
×
220
        $this->assertSame($response->json()['totalItems'], 2);
×
221
    }
222

223
    public function testDateFilterBetween(): void
224
    {
225
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
226
        $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]);
×
227
        $book = $response->json()['member'][0];
×
228
        $updated = $this->patchJson(
×
229
            $book['@id'],
×
230
            ['publicationDate' => '0001-02-18 00:00:00'],
×
231
            [
×
232
                'Accept' => ['application/ld+json'],
×
233
                'Content-Type' => ['application/merge-patch+json'],
×
234
            ]
×
235
        );
×
236

237
        $book2 = $response->json()['member'][1];
×
238
        $this->patchJson(
×
239
            $book2['@id'],
×
240
            ['publicationDate' => '0002-02-18 00:00:00'],
×
241
            [
×
242
                'Accept' => ['application/ld+json'],
×
243
                'Content-Type' => ['application/merge-patch+json'],
×
244
            ]
×
245
        );
×
246

247
        $book3 = $response->json()['member'][2];
×
248
        $updated3 = $this->patchJson(
×
249
            $book3['@id'],
×
250
            ['publicationDate' => '0003-02-18 00:00:00'],
×
251
            [
×
252
                'Accept' => ['application/ld+json'],
×
253
                'Content-Type' => ['application/merge-patch+json'],
×
254
            ]
×
255
        );
×
256

257
        $response = $this->get('/api/books?publicationDate[gte]='.substr($updated['publicationDate'], 0, 10).'&publicationDate[lt]='.substr($updated3['publicationDate'], 0, 10), ['Accept' => ['application/ld+json']]);
×
258
        $this->assertSame($response->json()['member'][0]['@id'], $book['@id']);
×
259
        $this->assertSame($response->json()['member'][1]['@id'], $book2['@id']);
×
260
        $this->assertSame($response->json()['totalItems'], 2);
×
261
    }
262

263
    public function testSearchFilterWithPropertyPlaceholder(): void
264
    {
265
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
266
        $response = $this->get('/api/authors', ['Accept' => ['application/ld+json']])->json();
×
267
        $author = $response['member'][0];
×
268

269
        $test = $this->get('/api/authors?name='.explode(' ', $author['name'])[0], ['Accept' => ['application/ld+json']])->json();
×
270
        $this->assertSame($test['member'][0]['id'], $author['id']);
×
271

272
        $test = $this->get('/api/authors?id='.$author['id'], ['Accept' => ['application/ld+json']])->json();
×
273
        $this->assertSame($test['member'][0]['id'], $author['id']);
×
274
    }
275

276
    public function testOrderFilterWithPropertyPlaceholder(): void
277
    {
278
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
279
        $res = $this->get('/api/authors?order[id]=desc', ['Accept' => ['application/ld+json']])->json();
×
280
        $this->assertSame($res['member'][0]['id'], 10);
×
281
    }
282

283
    public function testOrFilter(): void
284
    {
285
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
286
        $response = $this->get('/api/books', ['Accept' => ['application/ld+json']])->json()['member'];
×
287
        $book = $response[0];
×
288
        $book2 = $response[1];
×
289

290
        $res = $this->get(\sprintf('/api/books?name2[]=%s&name2[]=%s', $book['name'], $book2['name']), ['Accept' => ['application/ld+json']])->json();
×
291
        $this->assertSame($res['totalItems'], 2);
×
292
    }
293

294
    public function testRangeLowerThanFilter(): void
295
    {
296
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
297
        $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]);
×
298
        $bookBefore = $response->json()['member'][0];
×
299
        $this->patchJson(
×
300
            $bookBefore['@id'],
×
301
            ['isbn' => '12'],
×
302
            [
×
303
                'Accept' => ['application/ld+json'],
×
304
                'Content-Type' => ['application/merge-patch+json'],
×
305
            ]
×
306
        );
×
307

308
        $bookAfter = $response->json()['member'][1];
×
309
        $updated = $this->patchJson(
×
310
            $bookAfter['@id'],
×
311
            ['isbn' => '15'],
×
312
            [
×
313
                'Accept' => ['application/ld+json'],
×
314
                'Content-Type' => ['application/merge-patch+json'],
×
315
            ]
×
316
        );
×
317

318
        $response = $this->get('api/books?isbn_range[lt]='.$updated['isbn'], ['Accept' => ['application/ld+json']]);
×
319
        $this->assertSame($response->json()['member'][0]['@id'], $bookBefore['@id']);
×
320
        $this->assertSame($response->json()['totalItems'], 1);
×
321
    }
322

323
    public function testRangeLowerThanEqualFilter(): void
324
    {
325
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
326
        $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]);
×
327
        $bookBefore = $response->json()['member'][0];
×
328
        $this->patchJson(
×
329
            $bookBefore['@id'],
×
330
            ['isbn' => '12'],
×
331
            [
×
332
                'Accept' => ['application/ld+json'],
×
333
                'Content-Type' => ['application/merge-patch+json'],
×
334
            ]
×
335
        );
×
336

337
        $bookAfter = $response->json()['member'][1];
×
338
        $updated = $this->patchJson(
×
339
            $bookAfter['@id'],
×
340
            ['isbn' => '15'],
×
341
            [
×
342
                'Accept' => ['application/ld+json'],
×
343
                'Content-Type' => ['application/merge-patch+json'],
×
344
            ]
×
345
        );
×
346

347
        $response = $this->get('api/books?isbn_range[lte]='.$updated['isbn'], ['Accept' => ['application/ld+json']]);
×
348
        $this->assertSame($response->json()['member'][0]['@id'], $bookBefore['@id']);
×
349
        $this->assertSame($response->json()['member'][1]['@id'], $bookAfter['@id']);
×
350
        $this->assertSame($response->json()['totalItems'], 2);
×
351
    }
352

353
    public function testRangeGreaterThanFilter(): void
354
    {
355
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
356
        $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]);
×
357
        $bookBefore = $response->json()['member'][0];
×
358
        $updated = $this->patchJson(
×
359
            $bookBefore['@id'],
×
360
            ['isbn' => '999999999999998'],
×
361
            [
×
362
                'Accept' => ['application/ld+json'],
×
363
                'Content-Type' => ['application/merge-patch+json'],
×
364
            ]
×
365
        );
×
366

367
        $bookAfter = $response->json()['member'][1];
×
368
        $this->patchJson(
×
369
            $bookAfter['@id'],
×
370
            ['isbn' => '999999999999999'],
×
371
            [
×
372
                'Accept' => ['application/ld+json'],
×
373
                'Content-Type' => ['application/merge-patch+json'],
×
374
            ]
×
375
        );
×
376

377
        $response = $this->get('api/books?isbn_range[gt]='.$updated['isbn'], ['Accept' => ['application/ld+json']]);
×
378
        $this->assertSame($response->json()['member'][0]['@id'], $bookAfter['@id']);
×
379
        $this->assertSame($response->json()['totalItems'], 1);
×
380
    }
381

382
    public function testRangeGreaterThanEqualFilter(): void
383
    {
384
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
385
        $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]);
×
386
        $bookBefore = $response->json()['member'][0];
×
387
        $updated = $this->patchJson(
×
388
            $bookBefore['@id'],
×
389
            ['isbn' => '999999999999998'],
×
390
            [
×
391
                'Accept' => ['application/ld+json'],
×
392
                'Content-Type' => ['application/merge-patch+json'],
×
393
            ]
×
394
        );
×
395

396
        $bookAfter = $response->json()['member'][1];
×
397
        $this->patchJson(
×
398
            $bookAfter['@id'],
×
399
            ['isbn' => '999999999999999'],
×
400
            [
×
401
                'Accept' => ['application/ld+json'],
×
402
                'Content-Type' => ['application/merge-patch+json'],
×
403
            ]
×
404
        );
×
405
        $response = $this->get('api/books?isbn_range[gte]='.$updated['isbn'], ['Accept' => ['application/ld+json']]);
×
406
        $json = $response->json();
×
407
        $this->assertSame($json['member'][0]['@id'], $bookBefore['@id']);
×
408
        $this->assertSame($json['member'][1]['@id'], $bookAfter['@id']);
×
409
        $this->assertSame($json['totalItems'], 2);
×
410
    }
411

412
    public function testWrongOrderFilter(): void
413
    {
414
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
415
        $res = $this->get('/api/authors?order[name]=something', ['Accept' => ['application/ld+json']]);
×
416
        $this->assertEquals($res->getStatusCode(), 422);
×
417
    }
418

419
    public function testWithAccessor(): void
420
    {
421
        WithAccessorFactory::new()->create();
×
422
        $res = $this->get('/api/with_accessors/1', ['Accept' => ['application/ld+json']]);
×
423
        $this->assertArraySubset(['name' => 'test'], $res->json());
×
424
    }
425

426
    public function testBooleanFilter(): void
427
    {
428
        BookFactory::new()->has(AuthorFactory::new())->count(10)->create();
×
429
        $res = $this->get('/api/books?published=notabool', ['Accept' => ['application/ld+json']]);
×
430
        $this->assertEquals($res->getStatusCode(), 422);
×
431

432
        $res = $this->get('/api/books?published=0', ['Accept' => ['application/ld+json']]);
×
433
        $this->assertEquals($res->getStatusCode(), 200);
×
434
        $this->assertEquals($res->json()['totalItems'], 0);
×
435
    }
436

437
    public function testBelongsTo(): void
438
    {
439
        GrandSonFactory::new()->count(1)->create();
×
440

441
        $res = $this->get('/api/grand_sons/1/grand_father', ['Accept' => ['application/ld+json']]);
×
442
        $json = $res->json();
×
443
        $this->assertEquals($json['@id'], '/api/grand_sons/1/grand_father');
×
444
        $this->assertEquals($json['sons'][0], '/api/grand_sons/1');
×
445
    }
446

447
    public function testRelationIsHandledOnCreateWithNestedData(): void
448
    {
NEW
449
        $cartData = [
×
NEW
450
            'productSku' => 'SKU_TEST_001',
×
NEW
451
            'quantity' => 2,
×
NEW
452
            'priceAtAddition' => '19.99',
×
NEW
453
            'shoppingCart' => [
×
NEW
454
                'userIdentifier' => 'user-'.Str::uuid()->toString(),
×
NEW
455
                'status' => 'active',
×
NEW
456
            ],
×
NEW
457
        ];
×
458

NEW
459
        $response = $this->postJson('/api/cart_items', $cartData, ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']);
×
NEW
460
        $response->assertStatus(201);
×
461

NEW
462
        $response
×
NEW
463
            ->assertJson([
×
NEW
464
                '@context' => '/api/contexts/CartItem',
×
NEW
465
                '@id' => '/api/cart_items/1',
×
NEW
466
                '@type' => 'CartItem',
×
NEW
467
                'id' => 1,
×
NEW
468
                'productSku' => 'SKU_TEST_001',
×
NEW
469
                'quantity' => 2,
×
NEW
470
                'priceAtAddition' => 19.99,
×
NEW
471
                'shoppingCart' => [
×
NEW
472
                    '@id' => '/api/shopping_carts/1',
×
NEW
473
                    '@type' => 'ShoppingCart',
×
NEW
474
                    'userIdentifier' => $cartData['shoppingCart']['userIdentifier'],
×
NEW
475
                    'status' => 'active',
×
NEW
476
                ],
×
NEW
477
            ]);
×
478
    }
479

480
    public function testRelationIsHandledOnCreateWithNestedDataToMany(): void
481
    {
NEW
482
        $cartData = [
×
NEW
483
            'userIdentifier' => 'user-'.Str::uuid()->toString(),
×
NEW
484
            'status' => 'active',
×
NEW
485
            'cartItems' => [
×
NEW
486
                [
×
NEW
487
                    'productSku' => 'SKU_TEST_001',
×
NEW
488
                    'quantity' => 2,
×
NEW
489
                    'priceAtAddition' => '19.99',
×
NEW
490
                ],
×
NEW
491
                [
×
NEW
492
                    'productSku' => 'SKU_TEST_002',
×
NEW
493
                    'quantity' => 1,
×
NEW
494
                    'priceAtAddition' => '25.50',
×
NEW
495
                ],
×
NEW
496
            ],
×
NEW
497
        ];
×
498

NEW
499
        $response = $this->postJson('/api/shopping_carts', $cartData, ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']);
×
NEW
500
        $response->assertStatus(201);
×
NEW
501
        $response->assertJson([
×
NEW
502
            '@context' => '/api/contexts/ShoppingCart',
×
NEW
503
            '@id' => '/api/shopping_carts/1',
×
NEW
504
            '@type' => 'ShoppingCart',
×
NEW
505
            'id' => 1,
×
NEW
506
            'userIdentifier' => $cartData['userIdentifier'],
×
NEW
507
            'status' => 'active',
×
NEW
508
            'cartItems' => [
×
NEW
509
                [
×
NEW
510
                    '@id' => '/api/cart_items/1',
×
NEW
511
                    '@type' => 'CartItem',
×
NEW
512
                    'productSku' => 'SKU_TEST_001',
×
NEW
513
                    'quantity' => 2,
×
NEW
514
                    'priceAtAddition' => '19.99',
×
NEW
515
                ],
×
NEW
516
                [
×
NEW
517
                    '@id' => '/api/cart_items/2',
×
NEW
518
                    '@type' => 'CartItem',
×
NEW
519
                    'productSku' => 'SKU_TEST_002',
×
NEW
520
                    'quantity' => 1,
×
NEW
521
                    'priceAtAddition' => '25.50',
×
NEW
522
                ],
×
NEW
523
            ],
×
NEW
524
        ]);
×
525
    }
526
}
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

© 2025 Coveralls, Inc