64 - Embed buckets in the Scenario item representation

This commit is contained in:
myrmidex 2026-07-05 09:38:52 +02:00
parent f16e279f58
commit 6fd2243c83
6 changed files with 531 additions and 2 deletions

View file

@ -16,6 +16,7 @@ use App\Validator\CompatibleAllocationType;
use App\Validator\SingleOverflowPerScenario;
use App\Validator\UniquePriorityPerScenario;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Uid\UuidV7;
use Symfony\Component\Validator\Constraints as Assert;
@ -36,35 +37,43 @@ class Bucket
#[ORM\Column(type: 'uuid')]
private UuidV7 $id;
#[ORM\ManyToOne(targetEntity: Scenario::class)]
#[ORM\ManyToOne(targetEntity: Scenario::class, inversedBy: 'buckets')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Scenario $scenario;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Groups(['bucket:read'])]
private string $name;
#[ORM\Column(enumType: BucketType::class)]
#[Groups(['bucket:read'])]
private BucketType $type = BucketType::NEED;
#[ORM\Column]
#[Assert\NotNull]
#[Groups(['bucket:read'])]
private int $priority;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['bucket:read'])]
private int $sortOrder = 0;
#[ORM\Column(enumType: BucketAllocationType::class, options: ['default' => BucketAllocationType::FIXED_LIMIT])]
#[Groups(['bucket:read'])]
private BucketAllocationType $allocationType = BucketAllocationType::FIXED_LIMIT;
#[ORM\Column(nullable: true)]
#[Groups(['bucket:read'])]
private ?int $allocationValue = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['bucket:read'])]
private int $startingAmount = 0;
#[ORM\Column(type: 'decimal', precision: 5, scale: 2, options: ['default' => '0.00'])]
#[Assert\GreaterThanOrEqual(0)]
#[Groups(['bucket:read'])]
private string $bufferMultiplier = '0.00';
public function __construct()

View file

@ -7,14 +7,21 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\Repository\ScenarioRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Uid\UuidV7;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ScenarioRepository::class)]
#[ApiResource(
operations: [new GetCollection(), new Get(), new Post()],
operations: [
new GetCollection(),
new Get(normalizationContext: ['groups' => ['scenario:read', 'bucket:read']]),
new Post(),
],
security: "is_granted('ROLE_USER')",
)]
class Scenario
@ -32,16 +39,30 @@ class Scenario
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private User $owner;
/**
* Embedded in the item representation only (scenario:read is on the Get op, not GetCollection):
* the collection stays lean and avoids an N+1 lazy-load of buckets per scenario.
*
* @var Collection<int, Bucket>
*/
#[ORM\OneToMany(mappedBy: 'scenario', targetEntity: Bucket::class)]
#[ORM\OrderBy(['sortOrder' => 'ASC'])]
#[Groups(['scenario:read'])]
private Collection $buckets;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Groups(['scenario:read'])]
private string $name;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['scenario:read'])]
private ?string $description = null;
public function __construct()
{
$this->id = new UuidV7();
$this->buckets = new ArrayCollection();
}
public function getId(): UuidV7
@ -54,6 +75,14 @@ class Scenario
return $this->owner;
}
/**
* @return Collection<int, Bucket>
*/
public function getBuckets(): Collection
{
return $this->buckets;
}
public function getName(): string
{
return $this->name;

View file

@ -2,6 +2,7 @@
namespace App\Tests\Entity;
use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Tests\Concerns\CreatesUsers;
use Doctrine\ORM\EntityManagerInterface;
@ -79,4 +80,49 @@ final class ScenarioPersistenceTest extends KernelTestCase
self::assertNotNull($reloaded);
self::assertNull($reloaded->getDescription());
}
public function testScenarioExposesItsBucketsInverseRelation(): void
{
$scenario = new Scenario();
$scenario->setName('Household Budget');
$scenario->setOwner($this->persistOwner());
$this->em->persist($scenario);
$this->em->flush();
$rent = new Bucket();
$rent->setScenario($scenario);
$rent->setName('Rent');
$rent->setPriority(1);
$groceries = new Bucket();
$groceries->setScenario($scenario);
$groceries->setName('Groceries');
$groceries->setPriority(2);
$this->em->persist($rent);
$this->em->persist($groceries);
$this->em->flush();
$id = $scenario->getId();
$this->em->clear();
$reloaded = $this->em->find(Scenario::class, $id);
self::assertNotNull($reloaded);
$names = array_map(
static fn (Bucket $bucket): string => $bucket->getName(),
$reloaded->getBuckets()->toArray(),
);
sort($names);
self::assertSame(
['Groceries', 'Rent'],
$names,
'A reloaded Scenario must expose its Buckets via an inverse relation.',
);
}
}

View file

@ -233,6 +233,79 @@ final class BucketApiTest extends WebTestCase
self::assertSame('Rent', $body['name'] ?? null);
}
/**
* CHARACTERIZATION TEST pins the CURRENT flat Bucket item AND collection
* representations before #64 introduces serialization groups elsewhere.
* #64 does not touch GET /api/buckets or GET /api/buckets/{id} — this
* test guards against a group definition accidentally narrowing the
* flat Bucket representation as a side effect. Born green.
*/
public function testGetBucketItemAndCollectionFieldsUnchanged(): void
{
$scenario = $this->persistScenario('Household Budget');
$bucket = $this->persistBucket($scenario, 'Rent', 1);
$bucket->setSortOrder(4);
$bucket->setType(BucketType::NEED);
$bucket->setAllocationType(BucketAllocationType::FIXED_LIMIT);
$bucket->setAllocationValue(150000);
$bucket->setStartingAmount(5000);
$bucket->setBufferMultiplier('1.50');
$this->em->flush();
$this->login();
$this->client->request('GET', '/api/buckets/'.$bucket->getId(), server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
$itemResponse = $this->client->getResponse();
self::assertSame(200, $itemResponse->getStatusCode());
$item = json_decode((string) $itemResponse->getContent(), true);
self::assertIsArray($item, 'The item response must be a JSON-LD object.');
self::assertArrayHasKey('@id', $item);
self::assertSame('Bucket', $item['@type'] ?? null);
self::assertSame('Rent', $item['name'] ?? null);
self::assertSame(BucketType::NEED->value, $item['type'] ?? null);
self::assertSame(1, $item['priority'] ?? null);
self::assertSame(4, $item['sortOrder'] ?? null);
self::assertSame(BucketAllocationType::FIXED_LIMIT->value, $item['allocationType'] ?? null);
self::assertSame(150000, $item['allocationValue'] ?? null);
self::assertSame(5000, $item['startingAmount'] ?? null);
self::assertSame('1.50', $item['bufferMultiplier'] ?? null);
$this->client->request('GET', '/api/buckets', server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
$collectionResponse = $this->client->getResponse();
self::assertSame(200, $collectionResponse->getStatusCode());
$collection = json_decode((string) $collectionResponse->getContent(), true);
self::assertIsArray($collection, 'The collection response must be a JSON-LD/Hydra object.');
self::assertArrayHasKey('member', $collection);
$members = array_column($collection['member'], null, 'name');
self::assertArrayHasKey('Rent', $members, 'GET /api/buckets must still list the persisted bucket.');
$member = $members['Rent'];
self::assertArrayHasKey('@id', $member);
self::assertSame('Bucket', $member['@type'] ?? null);
self::assertSame(BucketType::NEED->value, $member['type'] ?? null);
self::assertSame(1, $member['priority'] ?? null);
self::assertSame(4, $member['sortOrder'] ?? null);
self::assertSame(BucketAllocationType::FIXED_LIMIT->value, $member['allocationType'] ?? null);
self::assertSame(150000, $member['allocationValue'] ?? null);
self::assertSame(5000, $member['startingAmount'] ?? null);
self::assertSame('1.50', $member['bufferMultiplier'] ?? null);
}
public function testPostCreatesABucketWhenAuthenticatedAndResolvesScenarioIri(): void
{
$scenario = $this->persistScenario('Household Budget');

View file

@ -2,8 +2,11 @@
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;
@ -67,6 +70,19 @@ final class ScenarioApiTest extends WebTestCase
return $scenario;
}
private function persistBucket(Scenario $scenario, string $name, int $priority): Bucket
{
$bucket = new Bucket();
$bucket->setScenario($scenario);
$bucket->setName($name);
$bucket->setPriority($priority);
$this->em->persist($bucket);
$this->em->flush();
return $bucket;
}
public function testGetCollectionIsRejectedWhenUnauthenticated(): void
{
$this->client->request('GET', '/api/scenarios', server: [
@ -174,6 +190,258 @@ final class ScenarioApiTest extends WebTestCase
self::assertSame('Emergency Fund', $body['name'] ?? null);
}
public function testGetItemEmbedsItsBuckets(): void
{
$scenario = $this->persistScenario('Household Budget');
$this->persistBucket($scenario, 'Rent', 1);
$this->persistBucket($scenario, 'Groceries', 2);
$this->login();
$this->client->request('GET', '/api/scenarios/'.$scenario->getId(), server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
$response = $this->client->getResponse();
self::assertSame(
200,
$response->getStatusCode(),
'GET /api/scenarios/{id} must return 200 for an authenticated owner.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
self::assertArrayHasKey(
'buckets',
$body,
'The Scenario item representation must embed its buckets so the SPA can fetch one resource.',
);
self::assertIsArray($body['buckets'], 'The embedded buckets field must be an array.');
$names = array_column($body['buckets'], 'name');
self::assertContains('Rent', $names);
self::assertContains('Groceries', $names);
}
public function testEmbeddedBucketsExposeTheFieldsTheFrontendNeeds(): void
{
$scenario = $this->persistScenario('Household Budget');
$bucket = $this->persistBucket($scenario, 'Rent', 1);
$bucket->setSortOrder(3);
$bucket->setType(BucketType::NEED);
$bucket->setAllocationType(BucketAllocationType::FIXED_LIMIT);
$bucket->setAllocationValue(150000);
$bucket->setStartingAmount(5000);
$bucket->setBufferMultiplier('1.50');
$this->em->flush();
$this->login();
$this->client->request('GET', '/api/scenarios/'.$scenario->getId(), server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
$response = $this->client->getResponse();
self::assertSame(200, $response->getStatusCode());
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
self::assertIsArray($body['buckets'] ?? null);
$embedded = $body['buckets'][0] ?? null;
self::assertIsArray($embedded, 'The embedded bucket must be an object.');
self::assertArrayHasKey(
'@id',
$embedded,
'Each embedded bucket must expose its own IRI so the SPA can target it for inline PATCH/DELETE from the nested view.',
);
self::assertSame('/api/buckets/'.$bucket->getId(), $embedded['@id'] ?? null);
self::assertSame('Rent', $embedded['name'] ?? null);
self::assertSame(BucketType::NEED->value, $embedded['type'] ?? null);
self::assertSame(1, $embedded['priority'] ?? null);
self::assertSame(3, $embedded['sortOrder'] ?? null);
self::assertSame(BucketAllocationType::FIXED_LIMIT->value, $embedded['allocationType'] ?? null);
self::assertSame(
150000,
$embedded['allocationValue'] ?? null,
'allocationValue must serialize as an int literal, not a float.',
);
self::assertSame(
5000,
$embedded['startingAmount'] ?? null,
'startingAmount must serialize as an int literal, not a float.',
);
self::assertSame('1.50', $embedded['bufferMultiplier'] ?? null);
}
public function testEmbeddedBucketsAreOrderedBySortOrder(): void
{
$scenario = $this->persistScenario('Household Budget');
$last = $this->persistBucket($scenario, 'Last', 1);
$last->setSortOrder(2);
$first = $this->persistBucket($scenario, 'First', 2);
$first->setSortOrder(0);
$middle = $this->persistBucket($scenario, 'Middle', 3);
$middle->setSortOrder(1);
$this->em->flush();
$this->login();
$this->client->request('GET', '/api/scenarios/'.$scenario->getId(), server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
$response = $this->client->getResponse();
self::assertSame(200, $response->getStatusCode());
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
$names = array_column($body['buckets'] ?? [], 'name');
self::assertSame(
['First', 'Middle', 'Last'],
$names,
'Embedded buckets must be ordered by sortOrder ascending, regardless of persistence order.',
);
}
public function testScenarioWithNoBucketsEmbedsAnEmptyBucketsArray(): void
{
$scenario = $this->persistScenario('Empty Scenario');
$this->login();
$this->client->request('GET', '/api/scenarios/'.$scenario->getId(), server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
$response = $this->client->getResponse();
self::assertSame(
200,
$response->getStatusCode(),
'A scenario with no buckets must still return 200, not error.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
self::assertArrayHasKey('buckets', $body);
self::assertSame(
[],
$body['buckets'],
'A bucket-less scenario must embed an empty buckets array, not null or a missing key.',
);
}
public function testGetCollectionDoesNotEmbedBuckets(): void
{
$scenario = $this->persistScenario('Household Budget');
$this->persistBucket($scenario, 'Rent', 1);
$this->login();
$this->client->request('GET', '/api/scenarios', server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
$response = $this->client->getResponse();
self::assertSame(200, $response->getStatusCode());
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The collection response must be a JSON-LD/Hydra object.');
self::assertArrayHasKey('member', $body);
$members = array_column($body['member'], null, 'name');
self::assertArrayHasKey('Household Budget', $members);
self::assertArrayNotHasKey(
'buckets',
$members['Household Budget'],
'GET /api/scenarios collection members must NOT embed buckets — only the item op does.',
);
}
/**
* CHARACTERIZATION TEST pins the CURRENT item representation before
* serialization groups land (#64). Confirms name/description/@id/@var
* survive the group-flip. Owner-presence is intentionally NOT asserted
* here it is dropped as part of #64, so pinning it now would make this
* test fail for the wrong reason once groups land. Born green.
*/
public function testGetItemExposesScenarioFieldsUnchanged(): void
{
$scenario = $this->persistScenario('Emergency Fund');
$scenario->setDescription('Six months of essential expenses.');
$this->em->flush();
$this->login();
$this->client->request('GET', '/api/scenarios/'.$scenario->getId(), server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
$response = $this->client->getResponse();
self::assertSame(
200,
$response->getStatusCode(),
'GET /api/scenarios/{id} must return 200 for an authenticated owner.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
self::assertArrayHasKey('@id', $body, 'A JSON-LD item must expose its IRI under @id.');
self::assertSame('Scenario', $body['@type'] ?? null, 'The item @type must be Scenario.');
self::assertSame('Emergency Fund', $body['name'] ?? null);
self::assertSame(
'Six months of essential expenses.',
$body['description'] ?? null,
'The item representation must still expose description.',
);
}
public function testGetItemDoesNotExposeOwner(): void
{
$scenario = $this->persistScenario('Emergency Fund');
$this->login();
$this->client->request('GET', '/api/scenarios/'.$scenario->getId(), server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
$response = $this->client->getResponse();
self::assertSame(200, $response->getStatusCode());
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
self::assertArrayNotHasKey(
'owner',
$body,
'The Scenario item representation must not leak the owner relation — it is implicit from the '
.'authenticated session, not part of the public shape (#64).',
);
}
public function testPostCreatesAScenarioWhenAuthenticated(): void
{
$this->login();

View file

@ -2,6 +2,7 @@
namespace App\Tests\Functional;
use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
@ -63,6 +64,31 @@ final class ScenarioOwnerApiTest extends WebTestCase
);
}
private function persistScenario(User $owner, string $name): Scenario
{
$scenario = new Scenario();
$scenario->setName($name);
$scenario->setOwner($owner);
$this->em->persist($scenario);
$this->em->flush();
return $scenario;
}
private function persistBucket(Scenario $scenario, string $name, int $priority): Bucket
{
$bucket = new Bucket();
$bucket->setScenario($scenario);
$bucket->setName($name);
$bucket->setPriority($priority);
$this->em->persist($bucket);
$this->em->flush();
return $bucket;
}
public function testPostCreatesAScenarioOwnedByTheAuthenticatedUser(): void
{
$this->login();
@ -153,6 +179,84 @@ final class ScenarioOwnerApiTest extends WebTestCase
);
}
public function testGetItemOnAScenarioOwnedBySomeoneElseReturnsNotFoundWithNoBucketLeak(): void
{
$othersScenario = $this->persistScenario($this->otherUser, 'Someone Else\'s Fund');
$this->persistBucket($othersScenario, 'Their Rent', 1);
$this->persistBucket($othersScenario, 'Their Groceries', 2);
$this->login();
$this->client->request('GET', '/api/scenarios/'.$othersScenario->getId(), server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
$response = $this->client->getResponse();
self::assertSame(
404,
$response->getStatusCode(),
'GET /api/scenarios/{id} for a scenario owned by another user must return 404, even when it has buckets.',
);
$content = (string) $response->getContent();
self::assertStringNotContainsString(
'Their Rent',
$content,
'A 404 for a foreign scenario must not leak any of its embedded bucket data in the response body.',
);
self::assertStringNotContainsString(
'Their Groceries',
$content,
'A 404 for a foreign scenario must not leak any of its embedded bucket data in the response body.',
);
}
public function testEmbeddedBucketsContainOnlyThisScenariosBuckets(): void
{
$scenarioA = $this->persistScenario($this->caller, 'Scenario A');
$this->persistBucket($scenarioA, 'A Rent', 1);
$this->persistBucket($scenarioA, 'A Groceries', 2);
$scenarioB = $this->persistScenario($this->caller, 'Scenario B');
$this->persistBucket($scenarioB, 'B Rent', 1);
$this->persistBucket($scenarioB, 'B Groceries', 2);
$this->login();
$this->client->request('GET', '/api/scenarios/'.$scenarioA->getId(), server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
$response = $this->client->getResponse();
self::assertSame(200, $response->getStatusCode());
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
self::assertIsArray($body['buckets'] ?? null);
$names = array_column($body['buckets'], 'name');
self::assertContains('A Rent', $names, 'Scenario A\'s item must embed its own buckets.');
self::assertContains('A Groceries', $names, 'Scenario A\'s item must embed its own buckets.');
self::assertNotContains(
'B Rent',
$names,
'Scenario A\'s embedded buckets must never include another scenario\'s buckets, even when both '
.'scenarios are owned by the same caller.',
);
self::assertNotContains(
'B Groceries',
$names,
'Scenario A\'s embedded buckets must never include another scenario\'s buckets, even when both '
.'scenarios are owned by the same caller.',
);
}
public function testGetCollectionOnlyReturnsScenariosOwnedByTheCaller(): void
{
$mine = new Scenario();