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, $this->createStub(Security::class), ); } 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); } /** * SEAM PINNED (#50 Story 4): unlike Stories 1-3, the preview path does NOT go * through a Doctrine query extension — it goes through this processor's own * `process()`. Ownership therefore has to be enforced HERE, not via a query * filter. This test calls `process()` directly (not `buildOutput()`) because * the owner check needs the full scenario-resolution flow, including the * current authenticated user — pinning the behavioural outcome regardless of * exactly where inside process() the comparison lives. * * Convention followed: `Symfony\Bundle\SecurityBundle\Security` is the * project's established way to obtain the current user in a processor (see * `App\State\ScenarioOwnerProcessor`) — assumed here as the collaborator the * processor will be constructed with, consistent with that existing pattern. */ public function testProcessThrowsNotFoundWhenTheScenarioIsOwnedBySomeoneElse(): void { $owner = (new User())->setEmail('owner@example.com')->setPassword('irrelevant-hash'); $intruder = (new User())->setEmail('intruder@example.com')->setPassword('irrelevant-hash'); $scenario = $this->makeScenario()->setOwner($owner); $scenarioRepository = $this->createStub(ScenarioRepository::class); $scenarioRepository->method('find')->willReturn($scenario); $security = $this->createStub(Security::class); $security->method('getUser')->willReturn($intruder); $processor = new PreviewProcessor( $scenarioRepository, $this->createStub(BucketRepository::class), $security, ); $input = new ProjectionPreviewInput(); $input->amount = 1000; $this->expectException(NotFoundHttpException::class); $processor->process( $input, new Post(), ['scenario' => (string) $scenario->getId()], ); } 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'); } }