diff --git a/src/Entity/Bucket.php b/src/Entity/Bucket.php index ce6c171..b9cb53c 100644 --- a/src/Entity/Bucket.php +++ b/src/Entity/Bucket.php @@ -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() diff --git a/src/Entity/Scenario.php b/src/Entity/Scenario.php index cb7d2d0..f61f904 100644 --- a/src/Entity/Scenario.php +++ b/src/Entity/Scenario.php @@ -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 + */ + #[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 + */ + public function getBuckets(): Collection + { + return $this->buckets; + } + public function getName(): string { return $this->name; diff --git a/tests/Entity/ScenarioPersistenceTest.php b/tests/Entity/ScenarioPersistenceTest.php index abcd465..604831a 100644 --- a/tests/Entity/ScenarioPersistenceTest.php +++ b/tests/Entity/ScenarioPersistenceTest.php @@ -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.', + ); + } } diff --git a/tests/Functional/BucketApiTest.php b/tests/Functional/BucketApiTest.php index 6c00cba..942f4cf 100644 --- a/tests/Functional/BucketApiTest.php +++ b/tests/Functional/BucketApiTest.php @@ -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'); diff --git a/tests/Functional/ScenarioApiTest.php b/tests/Functional/ScenarioApiTest.php index 8c61371..14d0321 100644 --- a/tests/Functional/ScenarioApiTest.php +++ b/tests/Functional/ScenarioApiTest.php @@ -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(); diff --git a/tests/Functional/ScenarioOwnerApiTest.php b/tests/Functional/ScenarioOwnerApiTest.php index 5aee74c..1debc13 100644 --- a/tests/Functional/ScenarioOwnerApiTest.php +++ b/tests/Functional/ScenarioOwnerApiTest.php @@ -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();