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

j-schumann / symfony-addons / 15680683335

16 Jun 2025 12:22PM UTC coverage: 53.664%. First build
15680683335

push

github

j-schumann
Merge branch 'develop'

# Conflicts:
#	.php-cs-fixer.dist.php
#	composer.json
#	src/PHPUnit/ApiPlatformTestCase.php
#	src/PHPUnit/AuthenticatedClientTrait.php

103 of 382 new or added lines in 10 files covered. (26.96%)

476 of 887 relevant lines covered (53.66%)

3.42 hits per line

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

54.29
/src/PHPUnit/ApiPlatformTestCase.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Vrok\SymfonyAddons\PHPUnit;
6

7
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
8
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
9
use Symfony\Component\HttpFoundation\File\UploadedFile;
10
use Symfony\Component\HttpKernel\Debug\TraceableEventDispatcher;
11
use Symfony\Contracts\HttpClient\ResponseInterface;
12

13
/**
14
 * Helper class that contains often used functionality to simplify testing
15
 * API endpoints.
16
 */
17
abstract class ApiPlatformTestCase extends ApiTestCase
18
{
19
    use MonologAssertsTrait;
20
    use RefreshDatabaseTrait;
21

22
    protected static ?bool $alwaysBootKernel = false;
23

24
    /**
25
     * Used when getting a JWT for the authentication in testOperation().
26
     * Set in your test classes accordingly.
27
     */
28
    protected static string $userClass = '\App\Entity\User';
29

30
    // region JSON responses, for ApiPlatform >= 3.2
31
    protected const UNAUTHENTICATED_RESPONSE = [
32
        'code'    => 401,
33
        'message' => 'JWT Token not found',
34
    ];
35

36
    // this should be returned for RFC 7807 compliant errors
37
    public const PROBLEM_CONTENT_TYPE = 'application/problem+json; charset=utf-8';
38

39
    public const PROBLEM_400 = [
40
        // 'detail' => 'The key "username" must be provided.', // varies
41
        'status' => 400,
42
        'title'  => 'An error occurred',
43
        'type'   => '/errors/400',
44
    ];
45
    public const PROBLEM_403 = [
46
        // 'detail' => 'Filter "locked" is forbidden for non-admins!', // varies
47
        'status' => 403,
48
        'title'  => 'An error occurred',
49
        'type'   => '/errors/403',
50
    ];
51
    public const PROBLEM_ACCESS_DENIED = [
52
        'detail' => 'Access Denied.',
53
        'status' => 403,
54
        'title'  => 'An error occurred',
55
        'type'   => '/errors/403',
56
    ];
57
    public const PROBLEM_404 = [
58
        // 'detail' => 'No route found for "GET http://localhost/proposals"', // varies
59
        'status' => 404,
60
        'title'  => 'An error occurred',
61
        // 'type'   => 'https://tools.ietf.org/html/rfc2616#section-10', // varies
62
    ];
63
    public const PROBLEM_NOT_FOUND = [
64
        'detail' => 'Not Found',
65
        'status' => 404,
66
        'title'  => 'An error occurred',
67
        'type'   => '/errors/404',
68
    ];
69
    public const PROBLEM_405 = [
70
        // 'detail' => 'No route found for "PATCH [...]": Method Not Allowed (Allow: GET)', // varies
71
        'status' => 405,
72
        'title'  => 'An error occurred',
73
        // 'type'   => 'https://tools.ietf.org/html/rfc2616#section-10', // varies
74
    ];
75
    public const PROBLEM_422 = [
76
        // 'detail' => 'description: validate.general.tooShort', // varies
77
        'status'     => 422,
78
        'title'      => 'An error occurred',
79
        // 'type' => '/validation_errors/9ff3fdc4-b214-49db-8718-39c315e33d45', // varies
80
        'violations' => [
81
            // varying list of violations:
82
            // [
83
            //    'propertyPath' => 'description',
84
            //    'message' => 'validate.general.tooShort',
85
            //    'code' => '9ff3fdc4-b214-49db-8718-39c315e33d45',
86
            // ],
87
        ],
88
    ];
89
    public const PROBLEM_500 = [
90
        // 'detail' => 'platform.noDefaultRatePlan', // varies
91
        'status' => 500,
92
        'title'  => 'An error occurred',
93
        'type'   => '/errors/500',
94
    ];
95

96
    public const HYDRA_PROBLEM_400 = [
97
        '@id'         => '/errors/400',
98
        '@type'       => 'hydra:Error',
99
        // 'hydra:description' => '"name" is required', // varies
100
        'hydra:title' => 'An error occurred',
101
    ] + self::PROBLEM_400;
102
    public const HYDRA_PROBLEM_403 = [
103
        '@id'         => '/errors/403',
104
        '@type'       => 'hydra:Error',
105
        // 'hydra:description' => '', // varies
106
        'hydra:title' => 'An error occurred',
107
    ] + self::PROBLEM_403;
108
    public const HYDRA_PROBLEM_ACCESS_DENIED = [
109
        '@id'               => '/errors/403',
110
        '@type'             => 'hydra:Error',
111
        'hydra:description' => 'Access Denied.',
112
        'hydra:title'       => 'An error occurred',
113
    ] + self::PROBLEM_ACCESS_DENIED;
114
    public const HYDRA_PROBLEM_404 = [
115
        '@id'         => '/errors/404',
116
        '@type'       => 'hydra:Error',
117
        // 'hydra:description' => 'This route does not aim to be called.', // varies
118
        'hydra:title' => 'An error occurred',
119
        'type'        => '/errors/404',
120
    ] + self::PROBLEM_404;
121
    public const HYDRA_PROBLEM_NOT_FOUND = [
122
        '@id'               => '/errors/404',
123
        '@type'             => 'hydra:Error',
124
        'hydra:description' => 'Not Found',
125
        'hydra:title'       => 'An error occurred',
126
    ] + self::PROBLEM_NOT_FOUND;
127
    public const HYDRA_PROBLEM_405 = [
128
        '@id'         => '/errors/405',
129
        '@type'       => 'hydra:Error',
130
        // 'hydra:description' => 'No route found for "GET [...]": Method Not Allowed (Allow: POST)', // varies
131
        'hydra:title' => 'An error occurred',
132
        'type'        => '/errors/405',
133
    ] + self::PROBLEM_405;
134
    public const HYDRA_PROBLEM_422 = [
135
        // '@id' => '/validation_errors/9ff3fdc4-b214-49db-8718-39c315e33d45', // varies
136
        '@type'       => 'ConstraintViolationList',
137
        // 'hydra:description' => 'description: validate.general.tooShort', // varies
138
        'hydra:title' => 'An error occurred',
139
    ] + self::PROBLEM_422;
140
    public const HYDRA_PROBLEM_500 = [
141
        // '@id'               => '/errors/500', // varies
142
        '@type'       => 'hydra:Error',
143
        // 'hydra:description' => 'platform.noDefaultRatePlan', // varies
144
        'hydra:title' => 'An error occurred',
145
    ] + self::PROBLEM_500;
146
    // endregion
147

148
    public const SUPPORTED_OPERATION_PARAMS = [
149
        'contentType',
150
        'createdLogs',
151
        'dispatchedEvents',
152
        'dispatchedMessages',
153
        'email',
154
        'emailCount',
155
        'files',
156
        'forbiddenKeys',
157
        'iri',
158
        'json',
159
        'messageCount',
160
        'method',
161
        'postFormAuth',
162
        'prepare',
163
        'requestOptions',
164
        'requiredKeys',
165
        'responseCode',
166
        'schemaClass',
167
        'uri',
168
    ];
169

170
    /**
171
     * The params *must* contain either 'iri' or 'uri', all other settings are
172
     * optional.
173
     *
174
     * @param callable $prepare            callback($containerInterface, &$params) that prepares the
175
     *                                     environment, e.g. creating / deleting entities.
176
     *                                     It is called after the kernel is booted & the database was
177
     *                                     refreshed. Can be used to update the parameters, e.g. with
178
     *                                     IDs/IRIs from the DB.
179
     * @param string   $uri                the endpoint to call, e.g. '/tenants'
180
     * @param array    $iri                [classname, [field => value]],
181
     *                                     e.g. [User::class, [email => 'test@test.de']]
182
     *                                     tries to find an entity by the given conditions and
183
     *                                     retrieves its IRI, it is then used as URI
184
     * @param string   $email              if given, tries to find a User with that email and sends
185
     *                                     the request authenticated as this user with lexikJWT
186
     * @param string   $postFormAuth       if given together with 'email', sends the JWT as
187
     *                                     'application/x-www-form-urlencoded' request in the
188
     *                                     given field name
189
     * @param string   $method             HTTP method for the request, defaults to GET
190
     * @param array    $requestOptions     options for the HTTP client, e.g. query parameters or
191
     *                                     basic auth
192
     * @param array    $files              array of files to upload
193
     * @param ?int     $responseCode       asserts that the received status code matches
194
     * @param string   $contentType        asserts that the received content type header matches
195
     * @param array    $json               asserts that the returned content is JSON and
196
     *                                     contains the given array as subset
197
     * @param array    $requiredKeys       asserts the dataset contains the list of keys.
198
     *                                     Used for elements where the value is not known in advance,
199
     *                                     e.g. ID, slug, timestamps. Can be nested:
200
     *                                     ['hydra:member'][0]['id', '@id']
201
     * @param array    $forbiddenKeys      like requiredKeys, but the dataset may not contain those
202
     * @param string   $schemaClass        asserts that the received response matches the JSON
203
     *                                     schema for the given class
204
     * @param array    $createdLogs        array of ["log message", LogLevel] entries, asserts the
205
     *                                     messages to be present in the monolog handlers after the
206
     *                                     operation ran
207
     * @param ?int     $emailCount         asserts this number of emails to be sent via the
208
     *                                     mailer after the operation was executed
209
     * @param ?int     $messageCount       asserts this number of messages to be dispatched
210
     *                                     to the message bus
211
     * @param array    $dispatchedMessages array of message classes, asserts that at least one instance
212
     *                                     of each given class has been dispatched to the message bus.
213
     *                                     Instead of class names the elements can be an array of
214
     *                                     [classname, callable]. This callback will be called
215
     *                                     (for each matching message) with the message as first
216
     *                                     parameter and the returned JSON as second parameter.
217
     * @param array    $dispatchedEvents   array of event names, asserts that at least one
218
     *                                     instance of each given event has been dispatched
219
     */
220
    protected function testOperation(
221
        ?callable $prepare = null,
222
        string $uri = '',
223
        array $iri = [],
224
        string $email = '',
225
        string $postFormAuth = '',
226
        string $method = 'GET',
227
        array $requestOptions = [],
228
        array $files = [],
229
        ?int $responseCode = null,
230
        string $contentType = '',
231
        array $json = [],
232
        array $requiredKeys = [],
233
        array $forbiddenKeys = [],
234
        string $schemaClass = '',
235
        array $createdLogs = [],
236
        ?int $emailCount = null,
237
        ?int $messageCount = null,
238
        array $dispatchedMessages = [],
239
        array $dispatchedEvents = [],
240
    ): ResponseInterface {
241
        // Save all arguments as an associative array, not only the provided
242
        // values as an indexed array like func_get_args() would.
243
        $params = get_defined_vars();
13✔
244

245
        $client = static::createClient();
13✔
246

247
        if ('' !== $email) {
13✔
NEW
248
            $token = $this->getJWT(['email' => $email]);
×
249

NEW
250
            if ('' !== $postFormAuth) {
×
251
                $client->setDefaultOptions([
×
252
                    'headers' => [
×
253
                        'content-type' => 'application/x-www-form-urlencoded',
×
254
                    ],
×
255
                    'extra'   => [
×
256
                        'parameters' => [
×
NEW
257
                            $postFormAuth => $token,
×
258
                        ],
×
259
                    ],
×
260
                ]);
×
261
            } else {
262
                $client->setDefaultOptions([
×
263
                    'headers' => [
×
264
                        'Authorization' => \sprintf('Bearer %s', $token),
×
265
                    ],
×
266
                ]);
×
267
            }
268
        }
269

270
        // Called after createClient(), as this forces the kernel boot, which in
271
        // turn refreshes the database.
272
        if ($prepare) {
13✔
273
            $prepare(static::getContainer(), $params);
2✔
274
            extract($params);
1✔
275
        }
276

277
        if ([] !== $iri) {
12✔
NEW
278
            if ('' !== $uri) {
×
NEW
279
                throw new \LogicException('Setting both $iri and $uri is not allowed because it serves no purpose.');
×
280
            }
281

NEW
282
            $resolved = $this->findIriBy($iri[0], $iri[1]);
×
NEW
283
            if (!$resolved) {
×
NEW
284
                throw new \RuntimeException('IRI could not be resolved with the given parameters!');
×
285
            }
286

NEW
287
            $uri = $resolved;
×
288
        }
289

290
        if ([] !== $createdLogs) {
12✔
291
            self::prepareLogger();
1✔
292
        }
293

294
        if ([] !== $files) {
12✔
NEW
295
            $requestOptions['extra']['files'] ??= [];
×
NEW
296
            $requestOptions['headers']['content-type'] ??= 'multipart/form-data';
×
297

NEW
298
            foreach ($files as $key => $fileData) {
×
NEW
299
                $requestOptions['extra']['files'][$key] =
×
300
                    static::prepareUploadedFile(
×
301
                        $fileData['path'],
×
302
                        $fileData['originalName'],
×
303
                        $fileData['mimeType'],
×
304
                    );
×
305
            }
306
        }
307

308
        if ('PATCH' === $method) {
12✔
NEW
309
            $requestOptions['headers']['content-type'] ??= 'application/merge-patch+json';
×
310
        }
311

312
        $response = $client->request(
12✔
313
            $method,
12✔
314
            $uri,
12✔
315
            $requestOptions,
12✔
316
        );
12✔
317

318
        if (null !== $responseCode) {
12✔
319
            self::assertResponseStatusCodeSame($responseCode);
3✔
320
        }
321

322
        if ('' !== $contentType) {
10✔
323
            self::assertResponseHeaderSame('content-type', $contentType);
2✔
324
        }
325

326
        if ([] !== $json) {
9✔
327
            self::assertJsonContains($json);
2✔
328
        }
329

330
        if ($requiredKeys || $forbiddenKeys) {
8✔
331
            $dataset = $response->toArray(false);
3✔
332

333
            self::assertDatasetHasKeys($requiredKeys, $dataset);
3✔
334
            self::assertDatasetNotHasKeys($forbiddenKeys, $dataset);
2✔
335
        }
336

337
        if ('' !== $schemaClass) {
6✔
NEW
338
            if ($iri || 'GET' !== $method) {
×
NEW
339
                self::assertMatchesResourceItemJsonSchema($schemaClass);
×
340
            } else {
NEW
341
                self::assertMatchesResourceCollectionJsonSchema($schemaClass);
×
342
            }
343
        }
344

345
        if ([] !== $createdLogs) {
6✔
346
            foreach ($createdLogs as $createdLog) {
1✔
347
                self::assertLoggerHasMessage($createdLog[0], $createdLog[1]);
1✔
348
            }
349
        }
350

351
        if (null !== $emailCount) {
5✔
352
            self::assertEmailCount($emailCount);
1✔
353
        }
354

355
        if ([] !== $dispatchedEvents) {
4✔
356
            /** @var TraceableEventDispatcher $dispatcher */
357
            $dispatcher = static::getContainer()
2✔
358
                ->get(EventDispatcherInterface::class);
2✔
359

360
            foreach ($dispatchedEvents as $eventName) {
2✔
361
                $found = false;
2✔
362
                foreach ($dispatcher->getCalledListeners() as $calledListener) {
2✔
363
                    if ($calledListener['event'] === $eventName) {
2✔
364
                        $found = true;
1✔
365
                        break;
1✔
366
                    }
367
                }
368

369
                self::assertTrue(
2✔
370
                    $found,
2✔
371
                    "Expected event '$eventName' was not dispatched"
2✔
372
                );
2✔
373
            }
374
        }
375

376
        if (null !== $messageCount || $dispatchedMessages) {
3✔
377
            $messenger = static::getContainer()->get('messenger.default_bus');
2✔
378
            $messages = $messenger->getDispatchedMessages();
2✔
379

380
            if (null !== $messageCount) {
2✔
381
                $found = \count($messages);
2✔
382
                self::assertSame(
2✔
383
                    $messageCount,
2✔
384
                    $found,
2✔
385
                    "Expected $messageCount messages to be dispatched, found $found"
2✔
386
                );
2✔
387
            }
388

389
            if ([] !== $dispatchedMessages) {
1✔
NEW
390
                foreach ($dispatchedMessages as $message) {
×
391
                    $messageCallback = null;
×
392

393
                    if (\is_array($message)
×
394
                        && 2 === \count($message)
×
395
                        && \is_string($message[0])
×
396
                        && \is_callable($message[1])
×
397
                    ) {
398
                        $messageClass = $message[0];
×
399
                        $messageCallback = $message[1];
×
400
                    } elseif (\is_string($message)) {
×
401
                        $messageClass = $message;
×
402
                    } else {
403
                        $error = 'Entries of "dispatchedMessages" must either be a string representing '
×
404
                            .'the FQN of the message class or an array with two elements: '
×
405
                            .'first the message class FQN and second a callable that will be called '
×
406
                            .'with the message object for inspection and the API response data';
×
407
                        throw new \InvalidArgumentException($error);
×
408
                    }
409

410
                    $filtered = array_filter(
×
411
                        $messages,
×
412
                        static fn ($ele) => is_a($ele['message'], $messageClass)
×
413
                    );
×
NEW
414
                    self::assertGreaterThan(
×
NEW
415
                        0,
×
NEW
416
                        \count($filtered),
×
NEW
417
                        "The expected '$messageClass' was not dispatched"
×
NEW
418
                    );
×
419

420
                    if ($messageCallback) {
×
421
                        foreach ($filtered as $msg) {
×
422
                            $messageCallback($msg['message'], $response->toArray(false));
×
423
                        }
424
                    }
425
                }
426
            }
427
        }
428

429
        return $response;
2✔
430
    }
431

432
    /**
433
     * Creates a copy of the file given via $path and returns a UploadedFile
434
     * to be given to testOperation() / the kernelBrowser to unit test
435
     * file uploads.
436
     */
437
    public static function prepareUploadedFile(
438
        string $path,
439
        string $originalName,
440
        string $mimeType,
441
    ): UploadedFile {
442
        // don't directly use the given file as the upload handler will
443
        // most probably move or delete the received file -> copy to temp file
444
        $filename = tempnam(sys_get_temp_dir(), __METHOD__);
×
445
        copy($path, $filename);
×
446

447
        return new UploadedFile(
×
448
            $filename,
×
449
            $originalName,
×
450
            $mimeType,
×
451
            null,
×
452
            true
×
453
        );
×
454
    }
455

456
    public static function tearDownAfterClass(): void
457
    {
458
        self::fixtureCleanup();
×
459
    }
460

461
    /**
462
     * Asserts that the given dataset $array does contain the list of $expected
463
     * keys. The keys may be nested.
464
     *
465
     * @param array  $expected list of keys to check:
466
     *                         ['hydra:member'][0]['id', '@id', 'slug']
467
     * @param array  $array    the dataset to verify
468
     * @param string $parent   auto-set when called recursively
469
     */
470
    public static function assertDatasetHasKeys(
471
        array $expected,
472
        array $array,
473
        string $parent = '',
474
    ): void {
475
        foreach ($expected as $index => $value) {
11✔
476
            if (\is_array($value)) {
10✔
477
                self::assertArrayHasKey(
4✔
478
                    $index,
4✔
479
                    $array,
4✔
480
                    "Dataset does not have key {$parent}[$index]!"
4✔
481
                );
4✔
482
                self::assertIsArray(
4✔
483
                    $array[$index],
4✔
484
                    "Key {$parent}[$index] is expected to be an array!"
4✔
485
                );
4✔
486
                self::assertDatasetHasKeys(
3✔
487
                    $value,
3✔
488
                    $array[$index],
3✔
489
                    "{$parent}[$index]"
3✔
490
                );
3✔
491
            } else {
492
                self::assertArrayHasKey(
9✔
493
                    $value,
9✔
494
                    $array,
9✔
495
                    "Dataset does not have key {$parent}[$value]!"
9✔
496
                );
9✔
497
            }
498
        }
499
    }
500

501
    /**
502
     * Asserts that the given dataset $array does *not* contain the list of
503
     * $expected keys. The keys may be nested.
504
     *
505
     * @param array  $expected list of keys to check:
506
     *                         ['hydra:member'][0]['internal', 'hidden', 'private']
507
     * @param array  $array    the dataset to verify
508
     * @param string $parent   auto-set when called recursively
509
     */
510
    public static function assertDatasetNotHasKeys(
511
        array $expected,
512
        array $array,
513
        string $parent = '',
514
    ): void {
515
        foreach ($expected as $index => $value) {
12✔
516
            if (\is_array($value)) {
12✔
517
                // the parent key does not exist / is null -> silently skip the child keys
518
                if (!isset($array[$index])) {
5✔
519
                    continue;
×
520
                }
521
                self::assertIsArray(
5✔
522
                    $array[$index],
5✔
523
                    "Key {$parent}[$index] is expected to be an array or null!"
5✔
524
                );
5✔
525
                self::assertDatasetNotHasKeys(
4✔
526
                    $value,
4✔
527
                    $array[$index],
4✔
528
                    "{$parent}[$index]"
4✔
529
                );
4✔
530
            } else {
531
                self::assertArrayNotHasKey(
11✔
532
                    $value,
11✔
533
                    $array,
11✔
534
                    "Dataset should not have key {$parent}[$value]!"
11✔
535
                );
11✔
536
            }
537
        }
538
    }
539

540
    /**
541
     * Generates a JWT for the user given by its identifying property, e.g. email.
542
     */
543
    protected function getJWT(array $findUserBy): string
544
    {
NEW
545
        $em = static::getContainer()->get('doctrine.orm.entity_manager');
×
NEW
546
        $user = $em->getRepository(static::$userClass)->findOneBy($findUserBy);
×
NEW
547
        if (!$user) {
×
NEW
548
            throw new \RuntimeException('User specified for JWT authentication was not found, please check your test database/fixtures!');
×
549
        }
550

NEW
551
        $jwtManager = static::getContainer()->get('lexik_jwt_authentication.jwt_manager');
×
552

NEW
553
        return $jwtManager->create($user);
×
554
    }
555
}
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