client = static::createClient(); $this->entityManager = static::getContainer()->get(EntityManagerInterface::class); $this->resetDatabase(); } public function testReviewCreateWithoutJwtReturns401Envelope(): void { $this->client->request( 'POST', '/api/v1/reviews', server: ['CONTENT_TYPE' => 'application/json'], content: json_encode([ 'album_id' => 'missing-album', 'title' => 'Sample', 'content' => str_repeat('A', 30), 'rating' => 8, ], JSON_THROW_ON_ERROR) ); $this->assertErrorEnvelope(Response::HTTP_UNAUTHORIZED, 'authentication_required'); } public function testReviewCreateWithInvalidJwtReturns401Envelope(): void { $this->client->request( 'POST', '/api/v1/reviews', server: [ 'CONTENT_TYPE' => 'application/json', 'HTTP_AUTHORIZATION' => 'Bearer definitely-not-a-valid-jwt', ], content: json_encode([ 'album_id' => 'missing-album', 'title' => 'Sample', 'content' => str_repeat('A', 30), 'rating' => 8, ], JSON_THROW_ON_ERROR) ); $this->assertErrorEnvelope(Response::HTTP_UNAUTHORIZED, 'invalid_token'); } public function testReviewCreateWithInvalidJsonReturns400Envelope(): void { $user = $this->createUser('api-invalid-json@example.test'); $album = $this->createAlbum('album_invalid_json'); $token = $this->issueToken($user); $this->client->request( 'POST', '/api/v1/reviews', server: [ 'CONTENT_TYPE' => 'application/json', 'HTTP_AUTHORIZATION' => 'Bearer ' . $token, ], content: sprintf('{"album_id":"%s","title":"oops"', $album->getExternalId()) ); $this->assertErrorEnvelope(Response::HTTP_BAD_REQUEST, ['invalid_json', 'invalid_request']); } public function testReviewCreateValidationReturns422EnvelopeWithFieldDetails(): void { $user = $this->createUser('api-validation@example.test'); $album = $this->createAlbum('album_validation'); $token = $this->issueToken($user); $this->client->request( 'POST', '/api/v1/reviews', server: [ 'CONTENT_TYPE' => 'application/json', 'HTTP_AUTHORIZATION' => 'Bearer ' . $token, ], content: json_encode([ 'album_id' => $album->getExternalId(), 'title' => '', 'content' => 'too short', 'rating' => 42, ], JSON_THROW_ON_ERROR) ); $payload = $this->assertErrorEnvelope(Response::HTTP_UNPROCESSABLE_ENTITY, 'validation_error'); self::assertIsArray($payload['error']['details'] ?? null); self::assertIsArray($payload['error']['details']['fields'] ?? null); self::assertArrayHasKey('title', $payload['error']['details']['fields']); self::assertArrayHasKey('content', $payload['error']['details']['fields']); self::assertArrayHasKey('rating', $payload['error']['details']['fields']); } public function testReviewUpdateByDifferentUserReturns403Envelope(): void { $owner = $this->createUser('review-owner@example.test'); $intruder = $this->createUser('review-intruder@example.test'); $album = $this->createAlbum('album_forbidden'); $review = $this->createReview($album, $owner); $token = $this->issueToken($intruder); $this->client->request( 'PATCH', '/api/v1/reviews/' . (string) $review->getId(), server: [ 'CONTENT_TYPE' => 'application/json', 'HTTP_AUTHORIZATION' => 'Bearer ' . $token, ], content: json_encode([ 'title' => 'Unauthorized update attempt', ], JSON_THROW_ON_ERROR) ); $this->assertErrorEnvelope(Response::HTTP_FORBIDDEN, 'forbidden'); } public function testAlbumReviewsUnknownAlbumReturns404Envelope(): void { $this->client->request('GET', '/api/v1/albums/not-a-real-album-id/reviews'); $this->assertErrorEnvelope(Response::HTTP_NOT_FOUND, 'album_not_found'); } public function testUnsupportedMethodOnApiIndexReturns405Envelope(): void { $this->client->request('POST', '/api/v1'); $this->assertErrorEnvelope(Response::HTTP_METHOD_NOT_ALLOWED, 'method_not_allowed'); } public function testReviewIndexDoesNotExposeAuthorEmail(): void { $author = $this->createUser('pii-check@example.test'); $album = $this->createAlbum('album_pii'); $this->createReview($album, $author); $this->client->request('GET', '/api/v1/reviews?limit=1'); $response = $this->client->getResponse(); self::assertSame(Response::HTTP_OK, $response->getStatusCode(), $response->getContent()); $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); self::assertIsArray($payload); self::assertIsArray($payload['data'] ?? null); self::assertNotEmpty($payload['data']); $first = $payload['data'][0] ?? null; self::assertIsArray($first); $authorPayload = $first['author'] ?? null; self::assertIsArray($authorPayload); self::assertArrayHasKey('id', $authorPayload); self::assertArrayHasKey('display_name', $authorPayload); self::assertArrayNotHasKey('email', $authorPayload); } public function testApiIndexExposesHypermediaLinksAndActions(): void { $this->client->request('GET', '/api/v1'); $response = $this->client->getResponse(); self::assertSame(Response::HTTP_OK, $response->getStatusCode(), $response->getContent()); $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); self::assertIsArray($payload); self::assertIsArray($payload['links'] ?? null); self::assertSame('/api/v1', $payload['links']['self'] ?? null); self::assertSame('/api/v1/albums', $payload['links']['albums'] ?? null); self::assertSame('/api/v1/reviews', $payload['links']['reviews'] ?? null); self::assertSame('/api/docs', $payload['links']['docs'] ?? null); self::assertIsArray($payload['actions'] ?? null); self::assertSame('/api/v1/auth/login', $payload['actions']['login']['href'] ?? null); self::assertSame('POST', $payload['actions']['login']['method'] ?? null); } public function testAlbumShowIncludesHypermediaLinksAndActions(): void { $album = $this->createAlbum('album_hypermedia'); $this->client->request('GET', '/api/v1/albums/' . $album->getExternalId()); $response = $this->client->getResponse(); self::assertSame(Response::HTTP_OK, $response->getStatusCode(), $response->getContent()); $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); self::assertIsArray($payload); self::assertIsArray($payload['data'] ?? null); self::assertSame('/api/v1/albums/album_hypermedia', $payload['data']['links']['self'] ?? null); self::assertSame('/api/v1/albums/album_hypermedia/reviews', $payload['data']['links']['reviews'] ?? null); self::assertSame('https://example.test/albums/album_hypermedia', $payload['data']['links']['open_source'] ?? null); self::assertSame('/api/v1/reviews', $payload['data']['actions']['create_review']['href'] ?? null); self::assertSame('/api/v1/albums/ingest', $payload['data']['actions']['ingest']['href'] ?? null); } public function testAlbumReviewsEndpointIncludesHypermedia(): void { $author = $this->createUser('album-reviews-hypermedia@example.test'); $album = $this->createAlbum('album_reviews_hypermedia'); $review = $this->createReview($album, $author); $this->client->request('GET', '/api/v1/albums/' . $album->getExternalId() . '/reviews'); $response = $this->client->getResponse(); self::assertSame(Response::HTTP_OK, $response->getStatusCode(), $response->getContent()); $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); self::assertIsArray($payload); self::assertSame('/api/v1/albums/album_reviews_hypermedia/reviews', $payload['links']['self'] ?? null); self::assertSame('/api/v1/reviews', $payload['links']['reviews'] ?? null); self::assertSame('/api/v1/reviews', $payload['actions']['create_review']['href'] ?? null); self::assertSame('POST', $payload['actions']['create_review']['method'] ?? null); self::assertIsArray($payload['data'] ?? null); self::assertNotEmpty($payload['data']); $first = $payload['data'][0] ?? null; self::assertIsArray($first); self::assertSame($review->getId(), $first['id'] ?? null); self::assertSame('/api/v1/reviews/' . $review->getId(), $first['links']['self'] ?? null); self::assertSame('/api/v1/albums/album_reviews_hypermedia', $first['links']['album'] ?? null); } private function resetDatabase(): void { $allMetadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); if ($allMetadata === []) { return; } $schemaTool = new SchemaTool($this->entityManager); $schemaTool->dropDatabase(); $schemaTool->createSchema($allMetadata); $this->entityManager->clear(); } private function createUser(string $email, string $plainPassword = 'password123'): User { /** @var UserPasswordHasherInterface $passwordHasher */ $passwordHasher = static::getContainer()->get(UserPasswordHasherInterface::class); $user = new User(); $user->setEmail($email); $user->setDisplayName('Test User'); $user->setPassword($passwordHasher->hashPassword($user, $plainPassword)); $this->entityManager->persist($user); $this->entityManager->flush(); return $user; } private function createAlbum(string $externalId): Album { $album = new Album(); $album->setSource('external'); $album->setExternalId($externalId); $album->setName('Functional Test Album'); $album->setArtists(['Functional Test Artist']); $album->setGenres(['Rock']); $album->setReleaseDate('2020-01-01'); $album->setTotalTracks(1); $album->setExternalUrl('https://example.test/albums/' . $externalId); $track = new AlbumTrack(); $track->setDiscNumber(1); $track->setTrackNumber(1); $track->setName('Functional Test Track'); $track->setDurationMs(210000); $album->addTrack($track); $this->entityManager->persist($album); $this->entityManager->flush(); return $album; } private function createReview(Album $album, User $author): Review { $review = new Review(); $review->setAlbum($album); $review->setAuthor($author); $review->setTitle('Excellent reference review'); $review->setContent('This review content is comfortably longer than twenty characters.'); $review->setRating(8); $this->entityManager->persist($review); $this->entityManager->flush(); return $review; } private function issueToken(User $user): string { /** @var JWTTokenManagerInterface $jwtTokenManager */ $jwtTokenManager = static::getContainer()->get(JWTTokenManagerInterface::class); return $jwtTokenManager->create($user); } /** * @return array */ private function assertErrorEnvelope(int $expectedStatus, string|array $expectedCode): array { $response = $this->client->getResponse(); self::assertSame($expectedStatus, $response->getStatusCode(), $response->getContent()); $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); self::assertIsArray($payload); self::assertIsArray($payload['error'] ?? null); if (is_array($expectedCode)) { self::assertContains($payload['error']['code'] ?? null, $expectedCode); } else { self::assertSame($expectedCode, $payload['error']['code'] ?? null); } self::assertSame($expectedStatus, $payload['error']['status'] ?? null); self::assertIsString($payload['error']['message'] ?? null); return $payload; } }