402 lines
13 KiB
PHP
402 lines
13 KiB
PHP
<?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 const OTHER_EMAIL = 'projection-preview-api-other@example.com';
|
|
|
|
private KernelBrowser $client;
|
|
private EntityManagerInterface $em;
|
|
private User $user;
|
|
private User $otherUser;
|
|
|
|
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);
|
|
|
|
$this->user = new User();
|
|
$this->user->setEmail(self::EMAIL);
|
|
$this->user->setPassword($hasher->hashPassword($this->user, self::PASSWORD));
|
|
$this->em->persist($this->user);
|
|
|
|
$this->otherUser = new User();
|
|
$this->otherUser->setEmail(self::OTHER_EMAIL);
|
|
$this->otherUser->setPassword($hasher->hashPassword($this->otherUser, 'some-other-password'));
|
|
$this->em->persist($this->otherUser);
|
|
|
|
$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', ?User $owner = null): Scenario
|
|
{
|
|
$scenario = new Scenario();
|
|
$scenario->setName($name);
|
|
$scenario->setOwner($owner ?? $this->user);
|
|
|
|
$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 testPreviewForAScenarioOwnedBySomeoneElseReturnsNotFound(): void
|
|
{
|
|
$othersScenario = $this->persistScenario('Someone Else\'s Fund', $this->otherUser);
|
|
|
|
$this->login();
|
|
|
|
$this->client->request(
|
|
'POST',
|
|
$this->previewUri($othersScenario),
|
|
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 for a scenario owned by another user must return 404, not 403 — '
|
|
.'no existence oracle for scenarios you do not own, matching the rest of #50.',
|
|
);
|
|
}
|
|
|
|
public function testPreviewForTheCallersOwnScenarioStillSucceeds(): void
|
|
{
|
|
$myScenario = $this->persistScenario('My Fund', $this->user);
|
|
|
|
$this->login();
|
|
|
|
$this->client->request(
|
|
'POST',
|
|
$this->previewUri($myScenario),
|
|
server: [
|
|
'CONTENT_TYPE' => 'application/ld+json',
|
|
'HTTP_ACCEPT' => 'application/ld+json',
|
|
],
|
|
content: json_encode(['amount' => 8000]),
|
|
);
|
|
|
|
self::assertSame(
|
|
200,
|
|
$this->client->getResponse()->getStatusCode(),
|
|
'POST .../projections/preview for the caller\'s own scenario must still succeed — ownership '
|
|
.'enforcement must not regress the happy path.',
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|