32 - Expose stateless distribution preview endpoint

This commit is contained in:
myrmidex 2026-06-22 18:55:02 +02:00
parent 5c5369328a
commit d6f47dea66
9 changed files with 671 additions and 3 deletions

View file

@ -0,0 +1,12 @@
<?php
namespace App\ApiResource;
use Symfony\Component\Validator\Constraints as Assert;
class ProjectionPreviewInput
{
#[Assert\NotNull]
#[Assert\GreaterThanOrEqual(1)]
public ?int $amount = null;
}

View file

@ -2,11 +2,34 @@
namespace App\ApiResource; namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Service\Allocation\Result; use App\Service\Allocation\Result;
use App\State\PreviewProcessor;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/scenarios/{scenario}/projections/preview',
status: 200,
input: ProjectionPreviewInput::class,
output: ProjectionPreviewOutput::class,
processor: PreviewProcessor::class,
security: "is_granted('ROLE_USER')",
),
],
)]
class ProjectionPreviewOutput class ProjectionPreviewOutput
{ {
/** @var array<array{
* bucket_id: string,
* bucket_name: string,
* bucket_type: string,
* allocated_amount: int,
* remaining_capacity: int|null
* }>
*/
public array $allocations; public array $allocations;
public int $totalAllocated; public int $totalAllocated;
@ -20,11 +43,20 @@ class ProjectionPreviewOutput
$this->unallocated = $result->getUnallocated(); $this->unallocated = $result->getUnallocated();
} }
public static function fromResult(Result $result): static public static function fromResult(Result $result): self
{ {
return new self($result); return new self($result);
} }
/**
* @return array<array{
* bucket_id: string,
* bucket_name: string,
* bucket_type: string,
* allocated_amount: int,
* remaining_capacity: int|null
* }>
*/
private function setAllocationsFromResult(Result $result): array private function setAllocationsFromResult(Result $result): array
{ {
return new ArrayCollection($result->getAllocations()) return new ArrayCollection($result->getAllocations())

View file

@ -35,4 +35,17 @@ class BucketRepository extends ServiceEntityRepository
return (int) $qb->getQuery()->getSingleScalarResult(); return (int) $qb->getQuery()->getSingleScalarResult();
} }
/**
* @return array<int, Bucket>
*/
public function findByScenarioOrderedByPriority(Scenario $scenario): array
{
return $this->createQueryBuilder('b')
->where('b.scenario = :scenario')
->setParameter('scenario', $scenario)
->orderBy('b.priority', 'ASC')
->getQuery()
->execute();
}
} }

View file

@ -27,7 +27,7 @@ final class AllocateIncome
$toAllocate = $this->income; $toAllocate = $this->income;
if ($toAllocate <= 0 || $this->buckets->isEmpty()) { if ($toAllocate <= 0 || $this->buckets->isEmpty()) {
return new Result([], 0, 0); return new Result([], 0, max(0, $toAllocate));
} }
$needs = $this->bucketsOfType(BucketType::NEED); $needs = $this->bucketsOfType(BucketType::NEED);

View file

@ -0,0 +1,56 @@
<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\ProjectionPreviewInput;
use App\ApiResource\ProjectionPreviewOutput;
use App\Entity\Scenario;
use App\Repository\BucketRepository;
use App\Repository\ScenarioRepository;
use App\Service\Allocation\AllocateIncome;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Uid\Uuid;
/**
* @implements ProcessorInterface<ProjectionPreviewInput, ProjectionPreviewOutput>
*/
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);
}
}

View file

@ -0,0 +1,344 @@
<?php
namespace App\Tests\Functional;
use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Entity\User;
use App\Enum\BucketAllocationType;
use App\Enum\BucketType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
/**
* POST /api/scenarios/{scenario}/projections/preview stateless allocation
* preview. Wires AllocateIncome (the allocation engine, App\Service\Allocation)
* into the API via a custom State Provider. No entities are persisted by this
* endpoint it's a pure read+compute+return.
*/
final class ProjectionPreviewApiTest extends WebTestCase
{
private const EMAIL = 'projection-preview-api-test@example.com';
private const PASSWORD = 'correct-horse-battery-staple';
private KernelBrowser $client;
private EntityManagerInterface $em;
protected function setUp(): void
{
$this->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<string, mixed> $body
*/
private function findTotalAllocated(array $body): ?int
{
return $body['totalAllocated'] ?? $body['total_allocated'] ?? null;
}
/**
* @param array<string, mixed> $body
*/
private function findUnallocated(array $body): ?int
{
return $body['unallocated'] ?? null;
}
}

View file

@ -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 private function persistScenario(string $name = 'Household Budget'): Scenario
{ {
$scenario = new Scenario(); $scenario = new Scenario();

View file

@ -77,7 +77,18 @@ final class AllocateIncomeTest extends TestCase
self::assertInstanceOf(Result::class, $result); self::assertInstanceOf(Result::class, $result);
self::assertSame([], $result->getAllocations()); self::assertSame([], $result->getAllocations());
self::assertSame(0, $result->getTotalAllocated()); 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 -- // --- B. Needs-base fills before wants-base, regardless of priority --

View file

@ -0,0 +1,153 @@
<?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');
}
}