154 lines
5.3 KiB
PHP
154 lines
5.3 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Tests\State;
|
||
|
|
|
||
|
|
use App\ApiResource\ProjectionPreviewOutput;
|
||
|
|
use App\Entity\Bucket;
|
||
|
|
use App\Entity\Scenario;
|
||
|
|
use App\Enum\BucketAllocationType;
|
||
|
|
use App\Enum\BucketType;
|
||
|
|
use App\Repository\BucketRepository;
|
||
|
|
use App\Repository\ScenarioRepository;
|
||
|
|
use App\State\PreviewProcessor;
|
||
|
|
use PHPUnit\Framework\MockObject\Stub;
|
||
|
|
use PHPUnit\Framework\TestCase;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Pure-unit complement to tests/Functional/ProjectionPreviewApiTest.php.
|
||
|
|
*
|
||
|
|
* SEAM PINNED: this test targets `PreviewProcessor::buildOutput(Scenario $scenario,
|
||
|
|
* int $amount): ProjectionPreviewOutput` directly, NOT `process()`. `process()` is
|
||
|
|
* thin API Platform glue (extracting scenario + amount from $uriVariables/$context)
|
||
|
|
* and stays covered by the functional HTTP test. `buildOutput()` is the actual
|
||
|
|
* mapping logic — repository lookup -> 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');
|
||
|
|
}
|
||
|
|
}
|