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 Stream API.', ); } private function persistScenario(string $name): Scenario { $scenario = new Scenario(); $scenario->setName($name); $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; } private function persistStream(Scenario $scenario, string $name): Stream { $stream = new Stream(); $stream->setScenario($scenario); $stream->setName($name); $stream->setAmount(300000); $stream->setType(StreamType::INCOME); $stream->setFrequency(StreamFrequency::MONTHLY); $stream->setStartDate(new \DateTimeImmutable('2026-01-01')); $this->em->persist($stream); $this->em->flush(); return $stream; } private function scenarioIri(Scenario $scenario): string { return '/api/scenarios/'.$scenario->getId(); } private function bucketIri(Bucket $bucket): string { return '/api/buckets/'.$bucket->getId(); } public function testGetCollectionIsRejectedWhenUnauthenticated(): void { $this->client->request('GET', '/api/streams', server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); self::assertSame( 401, $this->client->getResponse()->getStatusCode(), 'GET /api/streams must require authentication.', ); } public function testGetCollectionReturnsPersistedStreamsWhenAuthenticated(): void { $scenario = $this->persistScenario('Household Budget'); $this->persistStream($scenario, 'Salary'); $this->login(); $this->client->request('GET', '/api/streams', server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); $response = $this->client->getResponse(); self::assertSame( 200, $response->getStatusCode(), 'GET /api/streams 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('Salary', $names); } public function testPostCreatesAStreamWithoutABucketWhenAuthenticated(): void { $scenario = $this->persistScenario('Household Budget'); $this->login(); $this->client->request( 'POST', '/api/streams', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode([ 'scenario' => $this->scenarioIri($scenario), 'name' => 'Salary', 'amount' => 300000, 'type' => StreamType::INCOME->value, 'frequency' => StreamFrequency::MONTHLY->value, 'startDate' => '2026-01-01', ]), ); $response = $this->client->getResponse(); self::assertSame( 201, $response->getStatusCode(), 'POST /api/streams with a valid body and no bucket 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('Salary', $body['name'] ?? null); $this->em->clear(); $persisted = $this->em->getRepository(Stream::class)->findOneBy(['name' => 'Salary']); self::assertNotNull( $persisted, 'A successful POST /api/streams must persist the new Stream to the database.', ); self::assertNull( $persisted->getBucket(), 'A Stream posted without a bucket must persist with a null bucket relation.', ); } public function testPostCreatesAStreamWithABucketWhenAuthenticated(): void { $scenario = $this->persistScenario('Household Budget'); $bucket = $this->persistBucket($scenario, 'Groceries', 1); $this->login(); $this->client->request( 'POST', '/api/streams', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode([ 'scenario' => $this->scenarioIri($scenario), 'bucket' => $this->bucketIri($bucket), 'name' => 'Grocery Spend', 'amount' => 15000, 'type' => StreamType::EXPENSE->value, 'frequency' => StreamFrequency::WEEKLY->value, 'startDate' => '2026-01-01', ]), ); $response = $this->client->getResponse(); self::assertSame( 201, $response->getStatusCode(), 'POST /api/streams with a valid body including a bucket IRI must return 201 Created.', ); $this->em->clear(); $persisted = $this->em->getRepository(Stream::class)->findOneBy(['name' => 'Grocery Spend']); self::assertNotNull( $persisted, 'A successful POST /api/streams must persist the new Stream to the database.', ); self::assertNotNull( $persisted->getBucket(), 'The persisted Stream must resolve its nullable bucket relation from the posted IRI.', ); self::assertSame( $bucket->getId()->toRfc4122(), $persisted->getBucket()->getId()->toRfc4122(), ); } public function testPostWithMissingStartDateIsRejectedAsUnprocessable(): void { $scenario = $this->persistScenario('Household Budget'); $this->login(); $this->client->request( 'POST', '/api/streams', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode([ 'scenario' => $this->scenarioIri($scenario), 'name' => 'Salary', 'amount' => 300000, 'type' => StreamType::INCOME->value, 'frequency' => StreamFrequency::MONTHLY->value, ]), ); self::assertSame( 422, $this->client->getResponse()->getStatusCode(), 'POST /api/streams without a startDate must return 422 Unprocessable Entity (validator-rejected, not a 500).', ); } public function testPostWithMissingNameIsRejectedAsUnprocessable(): void { $scenario = $this->persistScenario('Household Budget'); $this->login(); $this->client->request( 'POST', '/api/streams', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode([ 'scenario' => $this->scenarioIri($scenario), 'amount' => 300000, 'type' => StreamType::INCOME->value, 'frequency' => StreamFrequency::MONTHLY->value, 'startDate' => '2026-01-01', ]), ); self::assertSame( 422, $this->client->getResponse()->getStatusCode(), 'POST /api/streams without a name must return 422 Unprocessable Entity (validator-rejected, not a 500).', ); } public function testGetItemIsRejectedWhenUnauthenticated(): void { $scenario = $this->persistScenario('Household Budget'); $stream = $this->persistStream($scenario, 'Salary'); $this->client->request('GET', '/api/streams/'.$stream->getId(), server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); self::assertSame( 401, $this->client->getResponse()->getStatusCode(), 'GET /api/streams/{id} must require authentication.', ); } public function testPostIsRejectedWhenUnauthenticated(): void { $this->client->request( 'POST', '/api/streams', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode([ 'name' => 'Salary', 'amount' => 300000, 'type' => StreamType::INCOME->value, 'frequency' => StreamFrequency::MONTHLY->value, 'startDate' => '2026-01-01', ]), ); self::assertSame( 401, $this->client->getResponse()->getStatusCode(), 'POST /api/streams must require authentication.', ); } public function testPostWithNonJsonContentTypeIsRejected(): void { $this->login(); $this->client->request( 'POST', '/api/streams', ['name' => 'Salary'], ); self::assertSame( 415, $this->client->getResponse()->getStatusCode(), 'POST /api/streams without a JSON Content-Type must be rejected with 415 Unsupported Media Type.', ); $this->em->clear(); $persisted = $this->em->getRepository(Stream::class)->findOneBy(['name' => 'Salary']); self::assertNull( $persisted, 'A rejected non-JSON POST must not persist a Stream.', ); } }