64 - Embed buckets in the Scenario item representation
This commit is contained in:
parent
f16e279f58
commit
6fd2243c83
6 changed files with 531 additions and 2 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue