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'); } }