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); $this->user = new User(); $this->user->setEmail(self::EMAIL); $this->user->setPassword($hasher->hashPassword($this->user, self::PASSWORD)); $this->em->persist($this->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); $scenario->setOwner($this->user); $this->em->persist($scenario); $this->em->flush(); return $scenario; } private function persistBucket(Scenario $scenario, string $name, int $priority): Bucket { $bucket = new Bucket(); $bucket->setScenario($scenario); $bucket->setName($name); $bucket->setPriority($priority); $this->em->persist($bucket); $this->em->flush(); return $bucket; } 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 testGetItemEmbedsItsBuckets(): void { $scenario = $this->persistScenario('Household Budget'); $this->persistBucket($scenario, 'Rent', 1); $this->persistBucket($scenario, 'Groceries', 2); $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 owner.', ); $body = json_decode((string) $response->getContent(), true); self::assertIsArray($body, 'The item response must be a JSON-LD object.'); self::assertArrayHasKey( 'buckets', $body, 'The Scenario item representation must embed its buckets so the SPA can fetch one resource.', ); self::assertIsArray($body['buckets'], 'The embedded buckets field must be an array.'); $names = array_column($body['buckets'], 'name'); self::assertContains('Rent', $names); self::assertContains('Groceries', $names); } public function testEmbeddedBucketsExposeTheFieldsTheFrontendNeeds(): void { $scenario = $this->persistScenario('Household Budget'); $bucket = $this->persistBucket($scenario, 'Rent', 1); $bucket->setSortOrder(3); $bucket->setType(BucketType::NEED); $bucket->setAllocationType(BucketAllocationType::FIXED_LIMIT); $bucket->setAllocationValue(150000); $bucket->setStartingAmount(5000); $bucket->setBufferMultiplier('1.50'); $this->em->flush(); $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()); $body = json_decode((string) $response->getContent(), true); self::assertIsArray($body, 'The item response must be a JSON-LD object.'); self::assertIsArray($body['buckets'] ?? null); $embedded = $body['buckets'][0] ?? null; self::assertIsArray($embedded, 'The embedded bucket must be an object.'); self::assertArrayHasKey( '@id', $embedded, 'Each embedded bucket must expose its own IRI so the SPA can target it for inline PATCH/DELETE from the nested view.', ); self::assertSame('/api/buckets/'.$bucket->getId(), $embedded['@id'] ?? null); self::assertSame('Rent', $embedded['name'] ?? null); self::assertSame(BucketType::NEED->value, $embedded['type'] ?? null); self::assertSame(1, $embedded['priority'] ?? null); self::assertSame(3, $embedded['sortOrder'] ?? null); self::assertSame(BucketAllocationType::FIXED_LIMIT->value, $embedded['allocationType'] ?? null); self::assertSame( 150000, $embedded['allocationValue'] ?? null, 'allocationValue must serialize as an int literal, not a float.', ); self::assertSame( 5000, $embedded['startingAmount'] ?? null, 'startingAmount must serialize as an int literal, not a float.', ); self::assertSame('1.50', $embedded['bufferMultiplier'] ?? null); } public function testEmbeddedBucketsAreOrderedBySortOrder(): void { $scenario = $this->persistScenario('Household Budget'); $last = $this->persistBucket($scenario, 'Last', 1); $last->setSortOrder(2); $first = $this->persistBucket($scenario, 'First', 2); $first->setSortOrder(0); $middle = $this->persistBucket($scenario, 'Middle', 3); $middle->setSortOrder(1); $this->em->flush(); $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()); $body = json_decode((string) $response->getContent(), true); self::assertIsArray($body, 'The item response must be a JSON-LD object.'); $names = array_column($body['buckets'] ?? [], 'name'); self::assertSame( ['First', 'Middle', 'Last'], $names, 'Embedded buckets must be ordered by sortOrder ascending, regardless of persistence order.', ); } public function testScenarioWithNoBucketsEmbedsAnEmptyBucketsArray(): void { $scenario = $this->persistScenario('Empty Scenario'); $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(), 'A scenario with no buckets must still return 200, not error.', ); $body = json_decode((string) $response->getContent(), true); self::assertIsArray($body, 'The item response must be a JSON-LD object.'); self::assertArrayHasKey('buckets', $body); self::assertSame( [], $body['buckets'], 'A bucket-less scenario must embed an empty buckets array, not null or a missing key.', ); } public function testGetCollectionDoesNotEmbedBuckets(): void { $scenario = $this->persistScenario('Household Budget'); $this->persistBucket($scenario, 'Rent', 1); $this->login(); $this->client->request('GET', '/api/scenarios', server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); $response = $this->client->getResponse(); self::assertSame(200, $response->getStatusCode()); $body = json_decode((string) $response->getContent(), true); self::assertIsArray($body, 'The collection response must be a JSON-LD/Hydra object.'); self::assertArrayHasKey('member', $body); $members = array_column($body['member'], null, 'name'); self::assertArrayHasKey('Household Budget', $members); self::assertArrayNotHasKey( 'buckets', $members['Household Budget'], 'GET /api/scenarios collection members must NOT embed buckets — only the item op does.', ); } /** * CHARACTERIZATION TEST — pins the CURRENT item representation before * serialization groups land (#64). Confirms name/description/@id/@var * survive the group-flip. Owner-presence is intentionally NOT asserted * here — it is dropped as part of #64, so pinning it now would make this * test fail for the wrong reason once groups land. Born green. */ public function testGetItemExposesScenarioFieldsUnchanged(): void { $scenario = $this->persistScenario('Emergency Fund'); $scenario->setDescription('Six months of essential expenses.'); $this->em->flush(); $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 owner.', ); $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); self::assertSame( 'Six months of essential expenses.', $body['description'] ?? null, 'The item representation must still expose description.', ); } public function testGetItemDoesNotExposeOwner(): 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()); $body = json_decode((string) $response->getContent(), true); self::assertIsArray($body, 'The item response must be a JSON-LD object.'); self::assertArrayNotHasKey( 'owner', $body, 'The Scenario item representation must not leak the owner relation — it is implicit from the ' .'authenticated session, not part of the public shape (#64).', ); } 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 testPostThenGetOnReturnedIriRoundTripsForTheOwner(): void { $this->login(); $this->client->request( 'POST', '/api/scenarios', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode(['name' => 'New Scenario From UI']), ); $created = json_decode((string) $this->client->getResponse()->getContent(), true); self::assertIsArray($created, 'The created resource response must be a JSON-LD object.'); self::assertArrayHasKey( '@id', $created, 'POST must return the created scenario IRI so the SPA can redirect to its show page.', ); $iri = $created['@id']; $this->client->request('GET', $iri, server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); $response = $this->client->getResponse(); self::assertSame( 200, $response->getStatusCode(), 'A GET on the just-created IRI by the same owner must return 200 — the create->redirect->show flow must never land on an owner-scope 404.', ); $body = json_decode((string) $response->getContent(), true); self::assertIsArray($body, 'The item response must be a JSON-LD object.'); self::assertSame( $iri, $body['@id'] ?? null, 'The show page must resolve the same IRI the create response returned.', ); self::assertSame('New Scenario From UI', $body['name'] ?? null); } public function testPostWithDescriptionPersistsAndEchoesIt(): void { $this->login(); $this->client->request( 'POST', '/api/scenarios', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode([ 'name' => 'Documented Fund', 'description' => 'Money set aside for the kitchen renovation.', ]), ); $response = $this->client->getResponse(); self::assertSame( 201, $response->getStatusCode(), 'POST /api/scenarios with name + optional description 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::assertSame( 'Money set aside for the kitchen renovation.', $body['description'] ?? null, 'The create response must echo the supplied optional description back to the SPA.', ); $this->em->clear(); $persisted = $this->em->getRepository(Scenario::class)->findOneBy(['name' => 'Documented Fund']); self::assertNotNull( $persisted, 'A successful POST with a description must persist the new Scenario.', ); self::assertSame( 'Money set aside for the kitchen renovation.', $persisted->getDescription(), 'The optional description must survive persistence.', ); } 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.', ); } }