diff --git a/src/ApiResource/ProjectionPreviewInput.php b/src/ApiResource/ProjectionPreviewInput.php new file mode 100644 index 0000000..58199b0 --- /dev/null +++ b/src/ApiResource/ProjectionPreviewInput.php @@ -0,0 +1,12 @@ + + */ public array $allocations; public int $totalAllocated; @@ -20,11 +43,20 @@ class ProjectionPreviewOutput $this->unallocated = $result->getUnallocated(); } - public static function fromResult(Result $result): static + public static function fromResult(Result $result): self { return new self($result); } + /** + * @return array + */ private function setAllocationsFromResult(Result $result): array { return new ArrayCollection($result->getAllocations()) diff --git a/src/Repository/BucketRepository.php b/src/Repository/BucketRepository.php index 16eff92..8acf35b 100644 --- a/src/Repository/BucketRepository.php +++ b/src/Repository/BucketRepository.php @@ -35,4 +35,17 @@ class BucketRepository extends ServiceEntityRepository return (int) $qb->getQuery()->getSingleScalarResult(); } + + /** + * @return array + */ + public function findByScenarioOrderedByPriority(Scenario $scenario): array + { + return $this->createQueryBuilder('b') + ->where('b.scenario = :scenario') + ->setParameter('scenario', $scenario) + ->orderBy('b.priority', 'ASC') + ->getQuery() + ->execute(); + } } diff --git a/src/Service/Allocation/AllocateIncome.php b/src/Service/Allocation/AllocateIncome.php index a7e7d2d..7dce19f 100644 --- a/src/Service/Allocation/AllocateIncome.php +++ b/src/Service/Allocation/AllocateIncome.php @@ -27,7 +27,7 @@ final class AllocateIncome $toAllocate = $this->income; if ($toAllocate <= 0 || $this->buckets->isEmpty()) { - return new Result([], 0, 0); + return new Result([], 0, max(0, $toAllocate)); } $needs = $this->bucketsOfType(BucketType::NEED); diff --git a/src/State/PreviewProcessor.php b/src/State/PreviewProcessor.php new file mode 100644 index 0000000..afe3108 --- /dev/null +++ b/src/State/PreviewProcessor.php @@ -0,0 +1,56 @@ + + */ +class PreviewProcessor implements ProcessorInterface +{ + public function __construct( + private readonly ScenarioRepository $scenarioRepository, + private readonly BucketRepository $bucketRepository, + ) { + } + + public function process( + mixed $data, + Operation $operation, + array $uriVariables = [], + array $context = [], + ): ProjectionPreviewOutput { + try { + $uuid = Uuid::fromString($uriVariables['scenario']); + } catch (\InvalidArgumentException) { + throw new NotFoundHttpException(); + } + + $scenario = $this->scenarioRepository->find($uuid); + + if (null === $scenario) { + throw new NotFoundHttpException(); + } + + return $this->buildOutput($scenario, $data->amount); + } + + public function buildOutput(Scenario $scenario, int $amount): ProjectionPreviewOutput + { + $buckets = $this->bucketRepository->findByScenarioOrderedByPriority($scenario); + $result = (new AllocateIncome(new ArrayCollection($buckets), $amount))->execute(); + + return ProjectionPreviewOutput::fromResult($result); + } +} diff --git a/tests/Functional/ProjectionPreviewApiTest.php b/tests/Functional/ProjectionPreviewApiTest.php new file mode 100644 index 0000000..5447b33 --- /dev/null +++ b/tests/Functional/ProjectionPreviewApiTest.php @@ -0,0 +1,344 @@ +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; + } +} diff --git a/tests/Repository/BucketRepositoryTest.php b/tests/Repository/BucketRepositoryTest.php index 6f9c2d5..ca9ba6f 100644 --- a/tests/Repository/BucketRepositoryTest.php +++ b/tests/Repository/BucketRepositoryTest.php @@ -92,6 +92,53 @@ final class BucketRepositoryTest extends KernelTestCase ); } + public function testFindByScenarioOrderedByPriorityReturnsScenarioBucketsInPriorityOrder(): void + { + $scenario = $this->persistScenario(); + + // Inserted out of priority order on purpose. + $third = $this->persistBucket($scenario, BucketType::NEED, 3); + $first = $this->persistBucket($scenario, BucketType::WANT, 1); + $second = $this->persistBucket($scenario, BucketType::OVERFLOW, 2); + + $buckets = $this->repository->findByScenarioOrderedByPriority($scenario); + + self::assertSame( + [$first->getId(), $second->getId(), $third->getId()], + array_map(static fn (Bucket $bucket) => $bucket->getId(), $buckets), + 'Buckets must come back ordered by priority ascending, regardless of insertion order.', + ); + } + + public function testFindByScenarioOrderedByPriorityReturnsOnlyTheGivenScenariosBuckets(): void + { + $scenarioA = $this->persistScenario('Scenario A'); + $scenarioB = $this->persistScenario('Scenario B'); + + $bucketA1 = $this->persistBucket($scenarioA, BucketType::NEED, 1); + $bucketA2 = $this->persistBucket($scenarioA, BucketType::OVERFLOW, 2); + $this->persistBucket($scenarioB, BucketType::NEED, 1); + + $buckets = $this->repository->findByScenarioOrderedByPriority($scenarioA); + + self::assertSame( + [$bucketA1->getId(), $bucketA2->getId()], + array_map(static fn (Bucket $bucket) => $bucket->getId(), $buckets), + 'Only the queried scenario\'s buckets must be returned — another scenario\'s buckets must not leak in.', + ); + } + + public function testFindByScenarioOrderedByPriorityReturnsEmptyForScenarioWithNoBuckets(): void + { + $scenario = $this->persistScenario(); + + self::assertSame( + [], + $this->repository->findByScenarioOrderedByPriority($scenario), + 'A scenario with no buckets must return an empty array.', + ); + } + private function persistScenario(string $name = 'Household Budget'): Scenario { $scenario = new Scenario(); diff --git a/tests/Service/Allocation/AllocateIncomeTest.php b/tests/Service/Allocation/AllocateIncomeTest.php index 70adaca..b6df273 100644 --- a/tests/Service/Allocation/AllocateIncomeTest.php +++ b/tests/Service/Allocation/AllocateIncomeTest.php @@ -77,7 +77,18 @@ final class AllocateIncomeTest extends TestCase self::assertInstanceOf(Result::class, $result); self::assertSame([], $result->getAllocations()); self::assertSame(0, $result->getTotalAllocated()); - self::assertSame(0, $result->getUnallocated()); + self::assertSame(10000, $result->getUnallocated()); + } + + public function testNoBucketsWithPositiveIncomeLeavesTheFullIncomeUnallocated(): void + { + $engine = new AllocateIncome(new ArrayCollection([]), 5000); + $result = $engine->execute(); + + self::assertInstanceOf(Result::class, $result); + self::assertSame([], $result->getAllocations()); + self::assertSame(0, $result->getTotalAllocated()); + self::assertSame(5000, $result->getUnallocated()); } // --- B. Needs-base fills before wants-base, regardless of priority -- diff --git a/tests/State/PreviewProcessorTest.php b/tests/State/PreviewProcessorTest.php new file mode 100644 index 0000000..1af3811 --- /dev/null +++ b/tests/State/PreviewProcessorTest.php @@ -0,0 +1,153 @@ + AllocateIncome -> ProjectionPreviewOutput — + * and is what's worth unit-testing in isolation, without mocking AP request internals. + * + * `AllocateIncome` is NOT mocked — it's `new`-ed inside buildOutput() and the real + * engine runs against the stubbed bucket list. The engine has its own full unit + * coverage (AllocateIncomeTest); trusting it here is correct. + * + * Expected ProjectionPreviewOutput shape (mirrors the functional test's JSON contract): + * - allocations: list of ['bucket_id' => string (uuid, RFC4122), 'bucket_name' => + * string, 'bucket_type' => string (enum backing value), 'allocated_amount' => int, + * 'remaining_capacity' => int|null] + * - totalAllocated: int + * - unallocated: int + */ +final class PreviewProcessorTest extends TestCase +{ + private BucketRepository&Stub $bucketRepository; + private PreviewProcessor $provider; + + protected function setUp(): void + { + $this->bucketRepository = $this->createStub(BucketRepository::class); + $this->provider = new PreviewProcessor( + $this->createStub(ScenarioRepository::class), + $this->bucketRepository, + ); + } + + public function testItMapsTheAllocationResultToTheOutputDto(): void + { + $scenario = $this->makeScenario(); + + $need = $this->makeBucket( + $scenario, + BucketType::NEED, + BucketAllocationType::FIXED_LIMIT, + priority: 1, + allocationValue: 5000, + name: 'Rent', + ); + $overflow = $this->makeBucket( + $scenario, + BucketType::OVERFLOW, + BucketAllocationType::UNLIMITED, + priority: 2, + allocationValue: null, + name: 'Overflow', + ); + + $this->bucketRepository + ->method('findByScenarioOrderedByPriority') + ->willReturn([$need, $overflow]); + + $output = $this->provider->buildOutput($scenario, 8000); + + self::assertInstanceOf(ProjectionPreviewOutput::class, $output); + + $byBucketId = []; + foreach ($output->allocations as $allocation) { + $byBucketId[$allocation['bucket_id']] = $allocation; + } + + self::assertArrayHasKey($need->getId()->toRfc4122(), $byBucketId); + self::assertArrayHasKey($overflow->getId()->toRfc4122(), $byBucketId); + + $needAllocation = $byBucketId[$need->getId()->toRfc4122()]; + self::assertSame('Rent', $needAllocation['bucket_name']); + self::assertSame(BucketType::NEED->value, $needAllocation['bucket_type']); + self::assertSame(5000, $needAllocation['allocated_amount']); + + $overflowAllocation = $byBucketId[$overflow->getId()->toRfc4122()]; + self::assertSame('Overflow', $overflowAllocation['bucket_name']); + self::assertSame(BucketType::OVERFLOW->value, $overflowAllocation['bucket_type']); + self::assertSame(3000, $overflowAllocation['allocated_amount']); + + self::assertSame(8000, $output->totalAllocated); + self::assertSame(0, $output->unallocated); + } + + public function testItReturnsZeroAllocationsForAScenarioWithNoBuckets(): void + { + $scenario = $this->makeScenario(); + + $this->bucketRepository + ->method('findByScenarioOrderedByPriority') + ->willReturn([]); + + $output = $this->provider->buildOutput($scenario, 5000); + + self::assertSame([], $output->allocations); + self::assertSame(0, $output->totalAllocated); + self::assertSame(5000, $output->unallocated); + } + + public function testTheOutputIsTheProjectionPreviewOutputDto(): void + { + $scenario = $this->makeScenario(); + + $this->bucketRepository + ->method('findByScenarioOrderedByPriority') + ->willReturn([]); + + $output = $this->provider->buildOutput($scenario, 1000); + + self::assertInstanceOf(ProjectionPreviewOutput::class, $output); + } + + private function makeScenario(): Scenario + { + return (new Scenario())->setName('Household Budget'); + } + + private function makeBucket( + Scenario $scenario, + BucketType $type, + BucketAllocationType $allocationType, + int $priority, + ?int $allocationValue, + string $name, + ): Bucket { + return (new Bucket()) + ->setScenario($scenario) + ->setType($type) + ->setName($name) + ->setPriority($priority) + ->setAllocationType($allocationType) + ->setAllocationValue($allocationValue) + ->setStartingAmount(0) + ->setBufferMultiplier('0.00'); + } +}