client = static::createClient(); /** @var EntityManagerInterface $em */ $em = static::getContainer()->get(EntityManagerInterface::class); $this->em = $em; $factory = new PasswordHasherFactory([ User::class => new NativePasswordHasher(cost: 4), ]); $hasher = new UserPasswordHasher($factory); $user = new User(); $user->setEmail(self::EMAIL); $user->setPassword($hasher->hashPassword($user, self::PASSWORD)); $this->em->persist($user); $this->em->flush(); } private function login(): void { $this->client->jsonRequest('POST', '/api/login', [ 'username' => self::EMAIL, 'password' => self::PASSWORD, ]); self::assertLessThan( 300, $this->client->getResponse()->getStatusCode(), 'Test setup precondition: login must succeed before exercising the Scenario API.', ); } private function persistScenario(string $name): Scenario { $scenario = new Scenario(); $scenario->setName($name); $this->em->persist($scenario); $this->em->flush(); return $scenario; } public function testGetCollectionIsRejectedWhenUnauthenticated(): void { $this->client->request('GET', '/api/scenarios', server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); self::assertSame( 401, $this->client->getResponse()->getStatusCode(), 'GET /api/scenarios must require authentication.', ); } public function testGetItemIsRejectedWhenUnauthenticated(): void { $scenario = $this->persistScenario('Emergency Fund'); $this->client->request('GET', '/api/scenarios/'.$scenario->getId(), server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); self::assertSame( 401, $this->client->getResponse()->getStatusCode(), 'GET /api/scenarios/{id} must require authentication.', ); } public function testPostIsRejectedWhenUnauthenticated(): void { $this->client->request( 'POST', '/api/scenarios', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode(['name' => 'Holiday Fund']), ); self::assertSame( 401, $this->client->getResponse()->getStatusCode(), 'POST /api/scenarios must require authentication.', ); } public function testGetCollectionReturnsPersistedScenariosWhenAuthenticated(): void { $this->persistScenario('Household Budget'); $this->persistScenario('Travel Fund'); $this->login(); $this->client->request('GET', '/api/scenarios', server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); $response = $this->client->getResponse(); self::assertSame( 200, $response->getStatusCode(), 'GET /api/scenarios must return 200 for an authenticated user.', ); $body = json_decode((string) $response->getContent(), true); self::assertIsArray($body, 'The collection response must be a JSON-LD/Hydra object.'); self::assertArrayHasKey( 'member', $body, 'A Hydra collection response must expose its items under the member key.', ); $names = array_column($body['member'], 'name'); self::assertContains('Household Budget', $names); self::assertContains('Travel Fund', $names); } public function testGetItemReturnsTheScenarioWhenAuthenticated(): void { $scenario = $this->persistScenario('Emergency Fund'); $this->login(); $this->client->request('GET', '/api/scenarios/'.$scenario->getId(), server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); $response = $this->client->getResponse(); self::assertSame( 200, $response->getStatusCode(), 'GET /api/scenarios/{id} must return 200 for an authenticated user.', ); $body = json_decode((string) $response->getContent(), true); self::assertIsArray($body, 'The item response must be a JSON-LD object.'); self::assertArrayHasKey('@id', $body, 'A JSON-LD item must expose its IRI under @id.'); self::assertSame('Scenario', $body['@type'] ?? null, 'The item @type must be Scenario.'); self::assertSame('Emergency Fund', $body['name'] ?? null); } public function testPostCreatesAScenarioWhenAuthenticated(): void { $this->login(); $this->client->request( 'POST', '/api/scenarios', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode(['name' => 'Holiday Fund']), ); $response = $this->client->getResponse(); self::assertSame( 201, $response->getStatusCode(), 'POST /api/scenarios with a valid body must return 201 Created.', ); $body = json_decode((string) $response->getContent(), true); self::assertIsArray($body, 'The created resource response must be a JSON-LD object.'); self::assertArrayHasKey('@id', $body, 'The created resource must expose its IRI under @id.'); self::assertSame('Holiday Fund', $body['name'] ?? null); $this->em->clear(); $persisted = $this->em->getRepository(Scenario::class)->findOneBy(['name' => 'Holiday Fund']); self::assertNotNull( $persisted, 'A successful POST /api/scenarios must persist the new Scenario to the database.', ); } public function testPostWithMissingNameIsRejectedAsUnprocessable(): void { $this->login(); $this->client->request( 'POST', '/api/scenarios', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode([]), ); self::assertSame( 422, $this->client->getResponse()->getStatusCode(), 'POST /api/scenarios without a name must return 422 Unprocessable Entity (validator-rejected, not a 500).', ); } public function testPostWithNonJsonContentTypeIsRejected(): void { $this->login(); $this->client->request( 'POST', '/api/scenarios', ['name' => 'Holiday Fund'], ); self::assertSame( 415, $this->client->getResponse()->getStatusCode(), 'POST /api/scenarios without a JSON Content-Type must be rejected with 415 Unsupported Media Type.', ); $this->em->clear(); $persisted = $this->em->getRepository(Scenario::class)->findOneBy(['name' => 'Holiday Fund']); self::assertNull( $persisted, 'A rejected non-JSON POST must not persist a Scenario.', ); } }