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 projection preview API.', ); } private function persistScenario(string $name = 'Household Budget'): Scenario { $scenario = new Scenario(); $scenario->setName($name); $this->em->persist($scenario); $this->em->flush(); return $scenario; } private function persistBucket( Scenario $scenario, BucketType $type, BucketAllocationType $allocationType, int $priority, ?int $allocationValue, string $name, ): Bucket { $bucket = new Bucket(); $bucket->setScenario($scenario); $bucket->setType($type); $bucket->setName($name); $bucket->setPriority($priority); $bucket->setAllocationType($allocationType); $bucket->setAllocationValue($allocationValue); $bucket->setStartingAmount(0); $bucket->setBufferMultiplier('0.00'); $this->em->persist($bucket); $this->em->flush(); return $bucket; } private function previewUri(Scenario $scenario): string { return '/api/scenarios/'.$scenario->getId().'/projections/preview'; } public function testPreviewIsRejectedWhenUnauthenticated(): void { $scenario = $this->persistScenario(); $this->client->request( 'POST', $this->previewUri($scenario), server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode(['amount' => 8000]), ); self::assertSame( 401, $this->client->getResponse()->getStatusCode(), 'POST .../projections/preview must require authentication.', ); } public function testPreviewReturnsAllocationBreakdownForAuthenticatedUser(): void { $scenario = $this->persistScenario(); $need = $this->persistBucket( $scenario, BucketType::NEED, BucketAllocationType::FIXED_LIMIT, priority: 1, allocationValue: 5000, name: 'Rent', ); $overflow = $this->persistBucket( $scenario, BucketType::OVERFLOW, BucketAllocationType::UNLIMITED, priority: 2, allocationValue: null, name: 'Overflow', ); $this->login(); $this->client->request( 'POST', $this->previewUri($scenario), server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode(['amount' => 8000]), ); $response = $this->client->getResponse(); self::assertSame( 200, $response->getStatusCode(), 'POST .../projections/preview with a valid amount must return 200 for an authenticated user.', ); $body = json_decode((string) $response->getContent(), true); self::assertIsArray($body, 'The preview response must be a JSON-LD object.'); self::assertArrayHasKey('allocations', $body, 'The preview response must expose an allocations array.'); self::assertIsArray($body['allocations']); $byBucketId = []; foreach ($body['allocations'] as $allocation) { self::assertArrayHasKey('bucket_id', $allocation); self::assertArrayHasKey('bucket_name', $allocation); self::assertArrayHasKey('bucket_type', $allocation); self::assertArrayHasKey('allocated_amount', $allocation); $byBucketId[$allocation['bucket_id']] = $allocation; } self::assertArrayHasKey( $need->getId()->toRfc4122(), $byBucketId, 'The NEED bucket must appear in the allocation breakdown.', ); self::assertArrayHasKey( $overflow->getId()->toRfc4122(), $byBucketId, 'The OVERFLOW bucket must appear in the allocation breakdown.', ); self::assertSame( 5000, $byBucketId[$need->getId()->toRfc4122()]['allocated_amount'], 'The fixed-limit NEED bucket (cap 5000) must be allocated its full base cap first.', ); self::assertSame( 3000, $byBucketId[$overflow->getId()->toRfc4122()]['allocated_amount'], 'The OVERFLOW bucket must sweep the remainder (8000 income - 5000 need = 3000).', ); $total = $this->findTotalAllocated($body); $unallocated = $this->findUnallocated($body); self::assertSame(8000, $total, 'totalAllocated must equal the full income — nothing left stranded.'); self::assertSame(0, $unallocated, 'unallocated must be zero — the overflow bucket absorbed the remainder.'); } public function testPreviewWithNonexistentScenarioIdIsRejectedAsNotFound(): void { $this->login(); $nonexistentScenarioUri = '/api/scenarios/'.\Symfony\Component\Uid\Uuid::v7().'/projections/preview'; $this->client->request( 'POST', $nonexistentScenarioUri, server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode(['amount' => 8000]), ); self::assertSame( 404, $this->client->getResponse()->getStatusCode(), 'POST .../projections/preview with a well-formed but unknown scenario UUID must return 404.', ); } public function testPreviewWithMalformedScenarioIdIsRejectedAsNotFound(): void { $this->login(); $this->client->request( 'POST', '/api/scenarios/not-a-uuid/projections/preview', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode(['amount' => 8000]), ); self::assertSame( 404, $this->client->getResponse()->getStatusCode(), 'POST .../projections/preview with a malformed scenario id must return 404 — ' .'indistinguishable from an unknown-but-valid UUID, so clients cannot probe id validity.', ); } public function testPreviewWithMissingAmountIsRejectedAsUnprocessable(): void { $scenario = $this->persistScenario(); $this->login(); $this->client->request( 'POST', $this->previewUri($scenario), server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode([]), ); self::assertSame( 422, $this->client->getResponse()->getStatusCode(), 'POST .../projections/preview without an amount must return 422 Unprocessable Entity.', ); } public function testPreviewWithZeroOrNegativeAmountIsRejectedAsUnprocessable(): void { $scenario = $this->persistScenario(); $this->login(); $this->client->request( 'POST', $this->previewUri($scenario), server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode(['amount' => 0]), ); self::assertSame( 422, $this->client->getResponse()->getStatusCode(), 'POST .../projections/preview with amount=0 must return 422 Unprocessable Entity ' .'(GreaterThanOrEqual(1) violated).', ); } public function testPreviewWithNonJsonContentTypeIsRejected(): void { $scenario = $this->persistScenario(); $this->login(); $this->client->request( 'POST', $this->previewUri($scenario), ['amount' => 8000], ); self::assertSame( 415, $this->client->getResponse()->getStatusCode(), 'POST .../projections/preview without a JSON Content-Type must be rejected with 415 Unsupported Media Type.', ); } /** * Tolerant lookup for the total-allocated figure: accepts either * camelCase (totalAllocated) or snake_case (total_allocated) serialized * property names, since the DTO's exact property/serialized-name choice * is the implementer's call. * * @param array $body */ private function findTotalAllocated(array $body): ?int { return $body['totalAllocated'] ?? $body['total_allocated'] ?? null; } /** * @param array $body */ private function findUnallocated(array $body): ?int { return $body['unallocated'] ?? null; } }