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

j-schumann / symfony-addons / 15651293590

14 Jun 2025 10:52AM UTC coverage: 55.22% (-18.7%) from 73.957%
15651293590

push

github

j-schumann
fix: OperationTest

476 of 862 relevant lines covered (55.22%)

3.52 hits per line

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

54.91
/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 string   $uri                the endpoint to call, e.g. '/tenants'
175
     * @param string   $iri                [classname, [field => value]],
176
     *                                     e.g. [User::class, [email => 'test@test.de']]
177
     *                                     tries to find an entity by the given conditions and
178
     *                                     retrieves its IRI, it is then used as URI
179
     * @param callable $prepare            callback($containerInterface, &$params) that prepares the
180
     *                                     environment, e.g. creating / deleting entities.
181
     *                                     It is called after the kernel is booted & the database was
182
     *                                     refreshed. Can be used to update the parameters, e.g. with
183
     *                                     IDs/IRIs from the DB.
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
        string $uri = '',
222
        string $iri = '',
223
        ?callable $prepare = null,
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 && '0' !== $email) {
13✔
248
            $token = $this->getJWT(['email' => $email]);
×
249

250
            if ('' !== $postFormAuth) {
×
251
                $client->setDefaultOptions([
×
252
                    'headers' => [
×
253
                        'content-type' => 'application/x-www-form-urlencoded',
×
254
                    ],
×
255
                    'extra'   => [
×
256
                        'parameters' => [
×
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 && '0' !== $iri) {
12✔
278
            $resolved = $this->findIriBy($iri[0], $iri[1]);
×
279
            if (!$resolved) {
×
280
                throw new \RuntimeException('IRI could not be resolved with the given parameters!');
×
281
            }
282

283
            $uri = $resolved;
×
284
        }
285

286
        if ([] !== $createdLogs) {
12✔
287
            self::prepareLogger();
1✔
288
        }
289

290
        if ([] !== $files) {
12✔
291
            $requestOptions['extra']['files'] ??= [];
×
292
            $requestOptions['headers']['content-type'] ??= 'multipart/form-data';
×
293

294
            foreach ($files as $key => $fileData) {
×
295
                $requestOptions['extra']['files'][$key] =
×
296
                    static::prepareUploadedFile(
×
297
                        $fileData['path'],
×
298
                        $fileData['originalName'],
×
299
                        $fileData['mimeType'],
×
300
                    );
×
301
            }
302
        }
303

304
        if ('PATCH' === $method) {
12✔
305
            $requestOptions['headers']['content-type'] ??= 'application/merge-patch+json';
×
306
        }
307

308
        $response = $client->request(
12✔
309
            $method,
12✔
310
            $uri,
12✔
311
            $requestOptions,
12✔
312
        );
12✔
313

314
        if (null !== $responseCode) {
12✔
315
            self::assertResponseStatusCodeSame($responseCode);
3✔
316
        }
317

318
        if ('' !== $contentType) {
10✔
319
            self::assertResponseHeaderSame('content-type', $contentType);
2✔
320
        }
321

322
        if ([] !== $json) {
9✔
323
            self::assertJsonContains($json);
2✔
324
        }
325

326
        if ($requiredKeys || $forbiddenKeys) {
8✔
327
            $dataset = $response->toArray(false);
3✔
328

329
            self::assertDatasetHasKeys($requiredKeys, $dataset);
3✔
330
            self::assertDatasetNotHasKeys($forbiddenKeys, $dataset);
2✔
331
        }
332

333
        if ('' !== $schemaClass) {
6✔
334
            if ($iri || 'GET' !== $method) {
×
335
                self::assertMatchesResourceItemJsonSchema($schemaClass);
×
336
            } else {
337
                self::assertMatchesResourceCollectionJsonSchema($schemaClass);
×
338
            }
339
        }
340

341
        if ([] !== $createdLogs) {
6✔
342
            foreach ($createdLogs as $createdLog) {
1✔
343
                self::assertLoggerHasMessage($createdLog[0], $createdLog[1]);
1✔
344
            }
345
        }
346

347
        if (null !== $emailCount) {
5✔
348
            self::assertEmailCount($emailCount);
1✔
349
        }
350

351
        if ([] !== $dispatchedEvents) {
4✔
352
            /** @var TraceableEventDispatcher $dispatcher */
353
            $dispatcher = static::getContainer()
2✔
354
                ->get(EventDispatcherInterface::class);
2✔
355

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

365
                self::assertTrue(
2✔
366
                    $found,
2✔
367
                    "Expected event '$eventName' was not dispatched"
2✔
368
                );
2✔
369
            }
370
        }
371

372
        if (null !== $messageCount || $dispatchedMessages) {
3✔
373
            $messenger = static::getContainer()->get('messenger.default_bus');
2✔
374
            $messages = $messenger->getDispatchedMessages();
2✔
375

376
            if (null !== $messageCount) {
2✔
377
                $found = count($messages);
2✔
378
                self::assertSame(
2✔
379
                    $messageCount,
2✔
380
                    $found,
2✔
381
                    "Expected $messageCount messages to be dispatched, found $found"
2✔
382
                );
2✔
383
            }
384

385
            if ([] !== $dispatchedMessages) {
1✔
386
                foreach ($dispatchedMessages as $message) {
×
387
                    $messageCallback = null;
×
388

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

406
                    $filtered = array_filter(
×
407
                        $messages,
×
408
                        static fn ($ele) => is_a($ele['message'], $messageClass)
×
409
                    );
×
410
                    self::assertGreaterThan(
×
411
                        0,
×
412
                        count($filtered),
×
413
                        "The expected '$messageClass' was not dispatched"
×
414
                    );
×
415

416
                    if ($messageCallback) {
×
417
                        foreach ($filtered as $msg) {
×
418
                            $messageCallback($msg['message'], $response->toArray(false));
×
419
                        }
420
                    }
421
                }
422
            }
423
        }
424

425
        return $response;
2✔
426
    }
427

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

443
        return new UploadedFile(
×
444
            $filename,
×
445
            $originalName,
×
446
            $mimeType,
×
447
            null,
×
448
            true
×
449
        );
×
450
    }
451

452
    public static function tearDownAfterClass(): void
453
    {
454
        self::fixtureCleanup();
×
455
    }
456

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

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

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

547
        $jwtManager = static::getContainer()->get('lexik_jwt_authentication.jwt_manager');
×
548

549
        return $jwtManager->create($user);
×
550
    }
551
}
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