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

j-schumann / symfony-addons / 15613934204

12 Jun 2025 02:53PM UTC coverage: 74.215%. First build
15613934204

push

github

j-schumann
remove: outdated/deprecated code
upd: create UPGRADE.md
upd: CHANGELOG.md, README.md

16 of 23 new or added lines in 6 files covered. (69.57%)

449 of 605 relevant lines covered (74.21%)

4.75 hits per line

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

49.31
/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
     * uri:                the endpoint to call, e.g. '/tenants'
175
     * 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
     * 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
     * email:              if given, tries to find a User with that email and sends
185
     *                     the request authenticated as this user with lexikJWT
186
     * postFormAuth:       if given together with 'email', sends the JWT as
187
     *                     'application/x-www-form-urlencoded' request in the
188
     *                     given field name
189
     * method:             HTTP method for the request, defaults to GET
190
     * requestOptions:     options for the HTTP client, e.g. query parameters or
191
     *                     basic auth
192
     * files:              array of files to upload
193
     * responseCode:       asserts that the received status code matches
194
     * contentType:        asserts that the received content type header matches
195
     * json:               asserts that the returned content is JSON and
196
     *                     contains the given array as subset
197
     * 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
     * forbiddenKeys:      like requiredKeys, but the dataset may not contain those
202
     * schemaClass:        asserts that the received response matches the JSON
203
     *                     schema for the given class
204
     * createdLogs:        array of ["log message", LogLevel] entries, asserts the
205
     *                     messages to be present in the monolog handlers after the
206
     *                     operation ran
207
     * emailCount:         asserts this number of emails to be sent via the
208
     *                     mailer after the operation was executed
209
     * messageCount:       asserts this number of messages to be dispatched
210
     *                     to the message bus
211
     * 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
     * dispatchedEvents:   array of event names, asserts that at least one
218
     *                     instance of each given event has been dispatched
219
     */
220
    protected function testOperation(array $params): ResponseInterface
221
    {
222
        $invalidKeys = array_diff(array_keys($params), self::SUPPORTED_OPERATION_PARAMS);
12✔
223
        if ([] !== $invalidKeys) {
12✔
224
            $keys = implode('", "', $invalidKeys);
1✔
225
            throw new \LogicException("Got unsupported parameter(s): \"$keys\" - maybe a typo?");
1✔
226
        }
227

228
        $client = static::createClient();
11✔
229

230
        if (isset($params['email'])) {
11✔
NEW
231
            $token = $this->getJWT(['email' => $params['email']]);
×
232

233
            if ($params['postFormAuth'] ?? false) {
×
234
                $client->setDefaultOptions([
×
235
                    'headers' => [
×
236
                        'content-type' => 'application/x-www-form-urlencoded',
×
237
                    ],
×
238
                    'extra'   => [
×
239
                        'parameters' => [
×
240
                            $params['postFormAuth'] => $token,
×
241
                        ],
×
242
                    ],
×
243
                ]);
×
244
            } else {
245
                $client->setDefaultOptions([
×
246
                    'headers' => [
×
247
                        'Authorization' => sprintf('Bearer %s', $token),
×
248
                    ],
×
249
                ]);
×
250
            }
251
        }
252

253
        // called after createClient as this forces the kernel boot which in
254
        // turn refreshes the database
255
        if (isset($params['prepare'])) {
11✔
256
            $params['prepare'](static::getContainer(), $params);
×
257
        }
258

259
        if (isset($params['iri'])) {
11✔
260
            $params['uri'] = $this->findIriBy($params['iri'][0], $params['iri'][1]);
×
261
        }
262

263
        if (isset($params['createdLogs'])) {
11✔
264
            self::prepareLogger();
1✔
265
        }
266

267
        $params['method'] ??= 'GET';
11✔
268
        $params['requestOptions'] ??= [];
11✔
269

270
        if (isset($params['files'])) {
11✔
271
            $params['requestOptions']['extra']['files'] ??= [];
×
272
            $params['requestOptions']['headers']['content-type'] ??= 'multipart/form-data';
×
273

274
            foreach ($params['files'] as $key => $fileData) {
×
275
                $params['requestOptions']['extra']['files'][$key] =
×
276
                    static::prepareUploadedFile(
×
277
                        $fileData['path'],
×
278
                        $fileData['originalName'],
×
279
                        $fileData['mimeType'],
×
280
                    );
×
281
            }
282
        }
283

284
        if ('PATCH' === $params['method']) {
11✔
285
            $params['requestOptions']['headers']['content-type'] ??= 'application/merge-patch+json';
×
286
        }
287

288
        $response = $client->request(
11✔
289
            $params['method'],
11✔
290
            $params['uri'],
11✔
291
            $params['requestOptions'],
11✔
292
        );
11✔
293

294
        if (isset($params['responseCode'])) {
11✔
295
            self::assertResponseStatusCodeSame($params['responseCode']);
2✔
296
        }
297

298
        if (isset($params['contentType'])) {
10✔
299
            self::assertResponseHeaderSame('content-type', $params['contentType']);
2✔
300
        }
301

302
        if (isset($params['json'])) {
9✔
303
            self::assertJsonContains($params['json']);
2✔
304
        }
305

306
        if (isset($params['requiredKeys'])
8✔
307
            || isset($params['forbiddenKeys'])
8✔
308
        ) {
309
            $dataset = $response->toArray(false);
3✔
310

311
            self::assertDatasetHasKeys(
3✔
312
                $params['requiredKeys'] ?? [], $dataset);
3✔
313
            self::assertDatasetNotHasKeys(
2✔
314
                $params['forbiddenKeys'] ?? [], $dataset);
2✔
315
        }
316

317
        if (isset($params['schemaClass'])) {
6✔
318
            if (isset($params['iri']) || 'GET' !== $params['method']) {
×
319
                self::assertMatchesResourceItemJsonSchema($params['schemaClass']);
×
320
            } else {
321
                self::assertMatchesResourceCollectionJsonSchema($params['schemaClass']);
×
322
            }
323
        }
324

325
        if (isset($params['createdLogs'])) {
6✔
326
            foreach ($params['createdLogs'] as $createdLog) {
1✔
327
                self::assertLoggerHasMessage($createdLog[0], $createdLog[1]);
1✔
328
            }
329
        }
330

331
        if (isset($params['emailCount'])) {
5✔
332
            self::assertEmailCount($params['emailCount']);
1✔
333
        }
334

335
        if (isset($params['dispatchedEvents'])) {
4✔
336
            /** @var TraceableEventDispatcher $dispatcher */
337
            $dispatcher = static::getContainer()
2✔
338
                ->get(EventDispatcherInterface::class);
2✔
339

340
            foreach ($params['dispatchedEvents'] as $eventName) {
2✔
341
                $found = false;
2✔
342
                foreach ($dispatcher->getCalledListeners() as $calledListener) {
2✔
343
                    if ($calledListener['event'] === $eventName) {
2✔
344
                        $found = true;
1✔
345
                        break;
1✔
346
                    }
347
                }
348

349
                self::assertTrue($found, "Expected event '$eventName' was not dispatched");
2✔
350
            }
351
        }
352

353
        if (isset($params['messageCount'])
3✔
354
            || isset($params['dispatchedMessages'])
3✔
355
        ) {
356
            $messenger = static::getContainer()->get('messenger.default_bus');
2✔
357
            $messages = $messenger->getDispatchedMessages();
2✔
358

359
            if (isset($params['messageCount'])) {
2✔
360
                $expected = $params['messageCount'];
2✔
361
                $found = count($messages);
2✔
362
                self::assertSame($expected, $found,
2✔
363
                    "Expected $expected messages to be dispatched, found $found");
2✔
364
            }
365

366
            if (isset($params['dispatchedMessages'])) {
1✔
367
                foreach ($params['dispatchedMessages'] as $message) {
×
368
                    $messageCallback = null;
×
369

370
                    if (is_array($message)
×
371
                        && 2 === count($message)
×
372
                        && is_string($message[0])
×
373
                        && is_callable($message[1])
×
374
                    ) {
375
                        $messageClass = $message[0];
×
376
                        $messageCallback = $message[1];
×
377
                    } elseif (is_string($message)) {
×
378
                        $messageClass = $message;
×
379
                    } else {
380
                        $error = 'Entries of "dispatchedMessages" must either be a string representing '
×
381
                            .'the FQN of the message class or an array with two elements: '
×
382
                            .'first the message class FQN and second a callable that will be called '
×
383
                            .'with the message object for inspection and the API response data';
×
384
                        throw new \InvalidArgumentException($error);
×
385
                    }
386

387
                    $filtered = array_filter(
×
388
                        $messages,
×
389
                        static fn ($ele) => is_a($ele['message'], $messageClass)
×
390
                    );
×
391
                    self::assertGreaterThan(0, count($filtered),
×
392
                        "The expected '$messageClass' was not dispatched");
×
393

394
                    if ($messageCallback) {
×
395
                        foreach ($filtered as $msg) {
×
396
                            $messageCallback($msg['message'], $response->toArray(false));
×
397
                        }
398
                    }
399
                }
400
            }
401
        }
402

403
        return $response;
2✔
404
    }
405

406
    /**
407
     * Creates a copy of the file given via $path and returns a UploadedFile
408
     * to be given to testOperation() / the kernelBrowser to unit test
409
     * file uploads.
410
     */
411
    public static function prepareUploadedFile(
412
        string $path,
413
        string $originalName,
414
        string $mimeType,
415
    ): UploadedFile {
416
        // don't directly use the given file as the upload handler will
417
        // most probably move or delete the received file -> copy to temp file
418
        $filename = tempnam(sys_get_temp_dir(), __METHOD__);
×
419
        copy($path, $filename);
×
420

421
        return new UploadedFile(
×
422
            $filename,
×
423
            $originalName,
×
424
            $mimeType,
×
425
            null,
×
426
            true
×
427
        );
×
428
    }
429

430
    public static function tearDownAfterClass(): void
431
    {
432
        self::fixtureCleanup();
×
433
    }
434

435
    /**
436
     * Asserts that the given dataset $array does contain the list of $expected
437
     * keys. The keys may be nested.
438
     *
439
     * @param array  $expected list of keys to check:
440
     *                         ['hydra:member'][0]['id', '@id', 'slug']
441
     * @param array  $array    the dataset to verify
442
     * @param string $parent   auto-set when called recursively
443
     */
444
    public static function assertDatasetHasKeys(array $expected, array $array, string $parent = ''): void
445
    {
446
        foreach ($expected as $index => $value) {
11✔
447
            if (is_array($value)) {
10✔
448
                self::assertArrayHasKey($index, $array, "Dataset does not have key {$parent}[$index]!");
4✔
449
                self::assertIsArray($array[$index], "Key {$parent}[$index] is expected to be an array!");
4✔
450
                self::assertDatasetHasKeys($value, $array[$index], "{$parent}[$index]");
3✔
451
            } else {
452
                self::assertArrayHasKey($value, $array, "Dataset does not have key {$parent}[$value]!");
9✔
453
            }
454
        }
455
    }
456

457
    /**
458
     * Asserts that the given dataset $array does *not* contain the list of
459
     * $expected keys. The keys may be nested.
460
     *
461
     * @param array  $expected list of keys to check:
462
     *                         ['hydra:member'][0]['internal', 'hidden', 'private']
463
     * @param array  $array    the dataset to verify
464
     * @param string $parent   auto-set when called recursively
465
     */
466
    public static function assertDatasetNotHasKeys(array $expected, array $array, string $parent = ''): void
467
    {
468
        foreach ($expected as $index => $value) {
12✔
469
            if (is_array($value)) {
12✔
470
                // the parent key does not exist / is null -> silently skip the child keys
471
                if (!isset($array[$index])) {
5✔
472
                    continue;
×
473
                }
474
                self::assertIsArray($array[$index], "Key {$parent}[$index] is expected to be an array or null!");
5✔
475
                self::assertDatasetNotHasKeys($value, $array[$index], "{$parent}[$index]");
4✔
476
            } else {
477
                self::assertArrayNotHasKey($value, $array, "Dataset should not have key {$parent}[$value]!");
11✔
478
            }
479
        }
480
    }
481

482
    /**
483
     * Generates a JWT for the user given by its identifying property, e.g. email.
484
     */
485
    protected function getJWT(array $findUserBy): string
486
    {
NEW
487
        $em = static::getContainer()->get('doctrine.orm.entity_manager');
×
NEW
488
        $user = $em->getRepository(static::$userClass)->findOneBy($findUserBy);
×
NEW
489
        if (!$user) {
×
NEW
490
            throw new \RuntimeException('User specified for JWT authentication was not found, please check your test database/fixtures!');
×
491
        }
492

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

NEW
495
        return $jwtManager->create($user);
×
496
    }
497
}
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