Compare commits
No commits in common. "2eff969a106d537d10409170e592734d26e889fb" and "5c5369328a469c884fbbcf89a6ddb4ae4a97c1e3" have entirely different histories.
2eff969a10
...
5c5369328a
22 changed files with 17 additions and 1086 deletions
|
|
@ -1,33 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-generated Migration: Please modify to your needs!
|
|
||||||
*/
|
|
||||||
final class Version20260623183021 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
// this up() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('ALTER TABLE scenario ADD owner_id UUID NOT NULL');
|
|
||||||
$this->addSql('ALTER TABLE scenario ADD CONSTRAINT FK_3E45C8D87E3C61F9 FOREIGN KEY (owner_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE');
|
|
||||||
$this->addSql('CREATE INDEX IDX_3E45C8D87E3C61F9 ON scenario (owner_id)');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('ALTER TABLE scenario DROP CONSTRAINT FK_3E45C8D87E3C61F9');
|
|
||||||
$this->addSql('DROP INDEX IDX_3E45C8D87E3C61F9');
|
|
||||||
$this->addSql('ALTER TABLE scenario DROP owner_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\ApiResource;
|
|
||||||
|
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
|
||||||
|
|
||||||
class ProjectionPreviewInput
|
|
||||||
{
|
|
||||||
#[Assert\NotNull]
|
|
||||||
#[Assert\GreaterThanOrEqual(1)]
|
|
||||||
public ?int $amount = null;
|
|
||||||
}
|
|
||||||
|
|
@ -2,39 +2,16 @@
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
public ?int $unallocated;
|
public int $unallocated;
|
||||||
|
|
||||||
public function __construct(Result $result)
|
public function __construct(Result $result)
|
||||||
{
|
{
|
||||||
|
|
@ -43,20 +20,11 @@ class ProjectionPreviewOutput
|
||||||
$this->unallocated = $result->getUnallocated();
|
$this->unallocated = $result->getUnallocated();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function fromResult(Result $result): self
|
public static function fromResult(Result $result): static
|
||||||
{
|
{
|
||||||
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())
|
||||||
|
|
|
||||||
|
|
@ -24,15 +24,6 @@ class Scenario
|
||||||
#[ORM\Column(type: 'uuid')]
|
#[ORM\Column(type: 'uuid')]
|
||||||
private UuidV7 $id;
|
private UuidV7 $id;
|
||||||
|
|
||||||
/**
|
|
||||||
* Set server-side by App\State\ScenarioOwnerProcessor on write; must be
|
|
||||||
* assigned via setOwner() before persist. The non-nullable type means
|
|
||||||
* getOwner() throws if read before that invariant is satisfied.
|
|
||||||
*/
|
|
||||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
|
||||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
|
||||||
private User $owner;
|
|
||||||
|
|
||||||
#[ORM\Column(enumType: DistributionMode::class)]
|
#[ORM\Column(enumType: DistributionMode::class)]
|
||||||
private DistributionMode $distributionMode = DistributionMode::EVEN;
|
private DistributionMode $distributionMode = DistributionMode::EVEN;
|
||||||
|
|
||||||
|
|
@ -53,11 +44,6 @@ class Scenario
|
||||||
return $this->id;
|
return $this->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getOwner(): User
|
|
||||||
{
|
|
||||||
return $this->owner;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDistributionMode(): DistributionMode
|
public function getDistributionMode(): DistributionMode
|
||||||
{
|
{
|
||||||
return $this->distributionMode;
|
return $this->distributionMode;
|
||||||
|
|
@ -73,13 +59,6 @@ class Scenario
|
||||||
return $this->description;
|
return $this->description;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setOwner(User $owner): static
|
|
||||||
{
|
|
||||||
$this->owner = $owner;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setDistributionMode(DistributionMode $distributionMode): static
|
public function setDistributionMode(DistributionMode $distributionMode): static
|
||||||
{
|
{
|
||||||
$this->distributionMode = $distributionMode;
|
$this->distributionMode = $distributionMode;
|
||||||
|
|
|
||||||
|
|
@ -35,17 +35,4 @@ 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, max(0, $toAllocate));
|
return new Result([], 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
$needs = $this->bucketsOfType(BucketType::NEED);
|
$needs = $this->bucketsOfType(BucketType::NEED);
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ class Result
|
||||||
return $this->totalAllocated;
|
return $this->totalAllocated;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getUnallocated(): ?int
|
public function getUnallocated(): int
|
||||||
{
|
{
|
||||||
return $this->unallocated;
|
return $this->unallocated;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\State;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
|
||||||
use App\Entity\Scenario;
|
|
||||||
use App\Entity\User;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stamps the authenticated user as the owner of a Scenario on write,
|
|
||||||
* then hands off to the core Doctrine persist processor.
|
|
||||||
*
|
|
||||||
* @implements ProcessorInterface<object, object|null>
|
|
||||||
*/
|
|
||||||
#[AsDecorator('api_platform.doctrine.orm.state.persist_processor')]
|
|
||||||
final class ScenarioOwnerProcessor implements ProcessorInterface
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param ProcessorInterface<object, object|null> $decorated
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
#[AutowireDecorated]
|
|
||||||
private readonly ProcessorInterface $decorated,
|
|
||||||
private readonly Security $security,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function process(
|
|
||||||
mixed $data,
|
|
||||||
Operation $operation,
|
|
||||||
array $uriVariables = [],
|
|
||||||
array $context = [],
|
|
||||||
): mixed {
|
|
||||||
if ($data instanceof Scenario) {
|
|
||||||
// The Scenario Post operation is gated by `is_granted('ROLE_USER')`
|
|
||||||
// (see #[ApiResource] on App\Entity\Scenario), so by the time this
|
|
||||||
// processor runs the security pipeline guarantees a logged-in User.
|
|
||||||
$user = $this->security->getUser();
|
|
||||||
\assert($user instanceof User);
|
|
||||||
|
|
||||||
$data->setOwner($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->decorated->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,6 @@ namespace App\Tests\Entity;
|
||||||
|
|
||||||
use App\Entity\Bucket;
|
use App\Entity\Bucket;
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
use App\Entity\User;
|
|
||||||
use App\Enum\BucketAllocationType;
|
use App\Enum\BucketAllocationType;
|
||||||
use App\Enum\BucketType;
|
use App\Enum\BucketType;
|
||||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||||
|
|
@ -22,25 +21,10 @@ final class BucketPersistenceTest extends KernelTestCase
|
||||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function persistOwner(): User
|
|
||||||
{
|
|
||||||
static $counter = 0;
|
|
||||||
++$counter;
|
|
||||||
|
|
||||||
$owner = new User();
|
|
||||||
$owner->setEmail(\sprintf('bucket-persistence-test-%d@example.com', $counter));
|
|
||||||
$owner->setPassword('irrelevant-hash');
|
|
||||||
|
|
||||||
$this->em->persist($owner);
|
|
||||||
|
|
||||||
return $owner;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testItPersistsABucketLinkedToItsScenario(): void
|
public function testItPersistsABucketLinkedToItsScenario(): void
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
@ -111,7 +95,6 @@ final class BucketPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
@ -151,7 +134,6 @@ final class BucketPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
@ -192,7 +174,6 @@ final class BucketPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Tests\Entity;
|
|
||||||
|
|
||||||
use App\Entity\Scenario;
|
|
||||||
use App\Entity\User;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
|
||||||
|
|
||||||
final class ScenarioOwnerTest extends KernelTestCase
|
|
||||||
{
|
|
||||||
private EntityManagerInterface $em;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
self::bootKernel();
|
|
||||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testOwnerRoundTripsThroughPersistence(): void
|
|
||||||
{
|
|
||||||
$owner = new User();
|
|
||||||
$owner->setEmail('scenario-owner-test@example.com');
|
|
||||||
$owner->setPassword('irrelevant-hash');
|
|
||||||
|
|
||||||
$this->em->persist($owner);
|
|
||||||
|
|
||||||
$scenario = new Scenario();
|
|
||||||
$scenario->setName('Household Budget');
|
|
||||||
$scenario->setOwner($owner);
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
|
||||||
$this->em->flush();
|
|
||||||
|
|
||||||
$scenarioId = $scenario->getId();
|
|
||||||
$ownerId = $owner->getId();
|
|
||||||
|
|
||||||
$this->em->clear();
|
|
||||||
|
|
||||||
$reloaded = $this->em->find(Scenario::class, $scenarioId);
|
|
||||||
|
|
||||||
self::assertNotNull($reloaded, 'Scenario must be retrievable from the database after an identity-map clear.');
|
|
||||||
self::assertInstanceOf(User::class, $reloaded->getOwner());
|
|
||||||
self::assertTrue(
|
|
||||||
$ownerId->equals($reloaded->getOwner()->getId()),
|
|
||||||
'The owner set before persistence must survive a flush + clear + reload round-trip.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
namespace App\Tests\Entity;
|
namespace App\Tests\Entity;
|
||||||
|
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
use App\Entity\User;
|
|
||||||
use App\Enum\DistributionMode;
|
use App\Enum\DistributionMode;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
|
@ -19,25 +18,10 @@ final class ScenarioPersistenceTest extends KernelTestCase
|
||||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function persistOwner(): User
|
|
||||||
{
|
|
||||||
static $counter = 0;
|
|
||||||
++$counter;
|
|
||||||
|
|
||||||
$owner = new User();
|
|
||||||
$owner->setEmail(\sprintf('scenario-persistence-test-%d@example.com', $counter));
|
|
||||||
$owner->setPassword('irrelevant-hash');
|
|
||||||
|
|
||||||
$this->em->persist($owner);
|
|
||||||
|
|
||||||
return $owner;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testItPersistsAScenarioWithAUuidId(): void
|
public function testItPersistsAScenarioWithAUuidId(): void
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
@ -58,7 +42,6 @@ final class ScenarioPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Default Mode Scenario');
|
$scenario->setName('Default Mode Scenario');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
@ -82,7 +65,6 @@ final class ScenarioPersistenceTest extends KernelTestCase
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Priority Mode Scenario');
|
$scenario->setName('Priority Mode Scenario');
|
||||||
$scenario->setDistributionMode(DistributionMode::PRIORITY);
|
$scenario->setDistributionMode(DistributionMode::PRIORITY);
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
@ -111,7 +93,6 @@ final class ScenarioPersistenceTest extends KernelTestCase
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Described Scenario');
|
$scenario->setName('Described Scenario');
|
||||||
$scenario->setDescription('Covers rent, groceries, and the emergency buffer.');
|
$scenario->setDescription('Covers rent, groceries, and the emergency buffer.');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
@ -130,7 +111,6 @@ final class ScenarioPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Undescribed Scenario');
|
$scenario->setName('Undescribed Scenario');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ namespace App\Tests\Entity;
|
||||||
use App\Entity\Bucket;
|
use App\Entity\Bucket;
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
use App\Entity\Stream;
|
use App\Entity\Stream;
|
||||||
use App\Entity\User;
|
|
||||||
use App\Enum\BucketAllocationType;
|
use App\Enum\BucketAllocationType;
|
||||||
use App\Enum\BucketType;
|
use App\Enum\BucketType;
|
||||||
use App\Enum\StreamFrequency;
|
use App\Enum\StreamFrequency;
|
||||||
|
|
@ -23,25 +22,10 @@ final class StreamPersistenceTest extends KernelTestCase
|
||||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function persistOwner(): User
|
|
||||||
{
|
|
||||||
static $counter = 0;
|
|
||||||
++$counter;
|
|
||||||
|
|
||||||
$owner = new User();
|
|
||||||
$owner->setEmail(\sprintf('stream-persistence-test-%d@example.com', $counter));
|
|
||||||
$owner->setPassword('irrelevant-hash');
|
|
||||||
|
|
||||||
$this->em->persist($owner);
|
|
||||||
|
|
||||||
return $owner;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testItPersistsAStreamLinkedToItsScenarioAndBucket(): void
|
public function testItPersistsAStreamLinkedToItsScenarioAndBucket(): void
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
|
|
||||||
$bucket = new Bucket();
|
$bucket = new Bucket();
|
||||||
|
|
@ -135,7 +119,6 @@ final class StreamPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ final class BucketApiTest extends WebTestCase
|
||||||
|
|
||||||
private KernelBrowser $client;
|
private KernelBrowser $client;
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
private User $user;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
|
@ -37,11 +36,11 @@ final class BucketApiTest extends WebTestCase
|
||||||
]);
|
]);
|
||||||
$hasher = new UserPasswordHasher($factory);
|
$hasher = new UserPasswordHasher($factory);
|
||||||
|
|
||||||
$this->user = new User();
|
$user = new User();
|
||||||
$this->user->setEmail(self::EMAIL);
|
$user->setEmail(self::EMAIL);
|
||||||
$this->user->setPassword($hasher->hashPassword($this->user, self::PASSWORD));
|
$user->setPassword($hasher->hashPassword($user, self::PASSWORD));
|
||||||
|
|
||||||
$this->em->persist($this->user);
|
$this->em->persist($user);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,7 +62,6 @@ final class BucketApiTest extends WebTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName($name);
|
$scenario->setName($name);
|
||||||
$scenario->setOwner($this->user);
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
|
||||||
|
|
@ -1,346 +0,0 @@
|
||||||
<?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;
|
|
||||||
private User $user;
|
|
||||||
|
|
||||||
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->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);
|
|
||||||
$scenario->setOwner($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 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,7 +18,6 @@ final class ScenarioApiTest extends WebTestCase
|
||||||
|
|
||||||
private KernelBrowser $client;
|
private KernelBrowser $client;
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
private User $user;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
|
@ -33,11 +32,11 @@ final class ScenarioApiTest extends WebTestCase
|
||||||
]);
|
]);
|
||||||
$hasher = new UserPasswordHasher($factory);
|
$hasher = new UserPasswordHasher($factory);
|
||||||
|
|
||||||
$this->user = new User();
|
$user = new User();
|
||||||
$this->user->setEmail(self::EMAIL);
|
$user->setEmail(self::EMAIL);
|
||||||
$this->user->setPassword($hasher->hashPassword($this->user, self::PASSWORD));
|
$user->setPassword($hasher->hashPassword($user, self::PASSWORD));
|
||||||
|
|
||||||
$this->em->persist($this->user);
|
$this->em->persist($user);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,7 +58,6 @@ final class ScenarioApiTest extends WebTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName($name);
|
$scenario->setName($name);
|
||||||
$scenario->setOwner($this->user);
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Tests\Functional;
|
|
||||||
|
|
||||||
use App\Entity\User;
|
|
||||||
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;
|
|
||||||
|
|
||||||
final class ScenarioOwnerApiTest extends WebTestCase
|
|
||||||
{
|
|
||||||
private const EMAIL = 'scenario-owner-api-test@example.com';
|
|
||||||
private const PASSWORD = 'correct-horse-battery-staple';
|
|
||||||
|
|
||||||
private const OTHER_EMAIL = 'scenario-owner-api-spoof-target@example.com';
|
|
||||||
|
|
||||||
private KernelBrowser $client;
|
|
||||||
private EntityManagerInterface $em;
|
|
||||||
private User $caller;
|
|
||||||
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->caller = new User();
|
|
||||||
$this->caller->setEmail(self::EMAIL);
|
|
||||||
$this->caller->setPassword($hasher->hashPassword($this->caller, self::PASSWORD));
|
|
||||||
$this->em->persist($this->caller);
|
|
||||||
|
|
||||||
$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 Scenario API.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPostCreatesAScenarioOwnedByTheAuthenticatedUser(): void
|
|
||||||
{
|
|
||||||
$this->login();
|
|
||||||
|
|
||||||
$this->client->request(
|
|
||||||
'POST',
|
|
||||||
'/api/scenarios',
|
|
||||||
server: [
|
|
||||||
'CONTENT_TYPE' => 'application/ld+json',
|
|
||||||
'HTTP_ACCEPT' => 'application/ld+json',
|
|
||||||
],
|
|
||||||
content: json_encode(['name' => 'Holiday Fund']),
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(
|
|
||||||
201,
|
|
||||||
$this->client->getResponse()->getStatusCode(),
|
|
||||||
'POST /api/scenarios with a valid body must return 201 Created.',
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->em->clear();
|
|
||||||
|
|
||||||
$persisted = $this->em->getRepository(\App\Entity\Scenario::class)->findOneBy(['name' => 'Holiday Fund']);
|
|
||||||
|
|
||||||
self::assertNotNull($persisted, 'A successful POST /api/scenarios must persist the new Scenario.');
|
|
||||||
self::assertNotNull($persisted->getOwner(), 'The persisted Scenario must have an owner set.');
|
|
||||||
self::assertTrue(
|
|
||||||
$this->caller->getId()->equals($persisted->getOwner()->getId()),
|
|
||||||
'The persisted Scenario must be owned by the authenticated caller, not left null or assigned elsewhere.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPostIgnoresAClientSuppliedOwner(): void
|
|
||||||
{
|
|
||||||
$this->login();
|
|
||||||
|
|
||||||
$this->client->request(
|
|
||||||
'POST',
|
|
||||||
'/api/scenarios',
|
|
||||||
server: [
|
|
||||||
'CONTENT_TYPE' => 'application/ld+json',
|
|
||||||
'HTTP_ACCEPT' => 'application/ld+json',
|
|
||||||
],
|
|
||||||
content: json_encode([
|
|
||||||
'name' => 'Spoofed Owner Fund',
|
|
||||||
'owner' => '/api/users/'.$this->otherUser->getId(),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(
|
|
||||||
201,
|
|
||||||
$this->client->getResponse()->getStatusCode(),
|
|
||||||
'POST /api/scenarios with a valid body must return 201 Created even when a client-supplied owner is present in the payload.',
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->em->clear();
|
|
||||||
|
|
||||||
$persisted = $this->em->getRepository(\App\Entity\Scenario::class)->findOneBy(['name' => 'Spoofed Owner Fund']);
|
|
||||||
|
|
||||||
self::assertNotNull($persisted, 'A successful POST /api/scenarios must persist the new Scenario.');
|
|
||||||
self::assertNotNull($persisted->getOwner(), 'The persisted Scenario must have an owner set.');
|
|
||||||
self::assertTrue(
|
|
||||||
$this->caller->getId()->equals($persisted->getOwner()->getId()),
|
|
||||||
'A client-supplied owner in the request body must be ignored — the owner must always be the authenticated caller.',
|
|
||||||
);
|
|
||||||
self::assertFalse(
|
|
||||||
$this->otherUser->getId()->equals($persisted->getOwner()->getId()),
|
|
||||||
'The spoofed owner IRI in the request body must never be honoured.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPostWithMissingNameIsStillRejectedAsUnprocessable(): void
|
|
||||||
{
|
|
||||||
$this->login();
|
|
||||||
|
|
||||||
$this->client->request(
|
|
||||||
'POST',
|
|
||||||
'/api/scenarios',
|
|
||||||
server: [
|
|
||||||
'CONTENT_TYPE' => 'application/ld+json',
|
|
||||||
'HTTP_ACCEPT' => 'application/ld+json',
|
|
||||||
],
|
|
||||||
content: json_encode([]),
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(
|
|
||||||
422,
|
|
||||||
$this->client->getResponse()->getStatusCode(),
|
|
||||||
'POST /api/scenarios without a name must still return 422 Unprocessable Entity — the owner-setting persist processor must not interfere with existing validation order.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -22,7 +22,6 @@ final class StreamApiTest extends WebTestCase
|
||||||
|
|
||||||
private KernelBrowser $client;
|
private KernelBrowser $client;
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
private User $user;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
|
@ -37,11 +36,11 @@ final class StreamApiTest extends WebTestCase
|
||||||
]);
|
]);
|
||||||
$hasher = new UserPasswordHasher($factory);
|
$hasher = new UserPasswordHasher($factory);
|
||||||
|
|
||||||
$this->user = new User();
|
$user = new User();
|
||||||
$this->user->setEmail(self::EMAIL);
|
$user->setEmail(self::EMAIL);
|
||||||
$this->user->setPassword($hasher->hashPassword($this->user, self::PASSWORD));
|
$user->setPassword($hasher->hashPassword($user, self::PASSWORD));
|
||||||
|
|
||||||
$this->em->persist($this->user);
|
$this->em->persist($user);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,7 +62,6 @@ final class StreamApiTest extends WebTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName($name);
|
$scenario->setName($name);
|
||||||
$scenario->setOwner($this->user);
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ namespace App\Tests\Repository;
|
||||||
|
|
||||||
use App\Entity\Bucket;
|
use App\Entity\Bucket;
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
use App\Entity\User;
|
|
||||||
use App\Enum\BucketAllocationType;
|
use App\Enum\BucketAllocationType;
|
||||||
use App\Enum\BucketType;
|
use App\Enum\BucketType;
|
||||||
use App\Repository\BucketRepository;
|
use App\Repository\BucketRepository;
|
||||||
|
|
@ -93,66 +92,10 @@ 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
|
||||||
{
|
{
|
||||||
static $counter = 0;
|
|
||||||
++$counter;
|
|
||||||
|
|
||||||
$owner = new User();
|
|
||||||
$owner->setEmail(\sprintf('bucket-repository-test-%d@example.com', $counter));
|
|
||||||
$owner->setPassword('irrelevant-hash');
|
|
||||||
$this->em->persist($owner);
|
|
||||||
|
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName($name);
|
$scenario->setName($name);
|
||||||
$scenario->setOwner($owner);
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,18 +77,7 @@ 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(10000, $result->getUnallocated());
|
self::assertSame(0, $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 --
|
||||||
|
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,6 @@ namespace App\Tests\Validator;
|
||||||
|
|
||||||
use App\Entity\Bucket;
|
use App\Entity\Bucket;
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
use App\Entity\User;
|
|
||||||
use App\Enum\BucketAllocationType;
|
use App\Enum\BucketAllocationType;
|
||||||
use App\Enum\BucketType;
|
use App\Enum\BucketType;
|
||||||
use App\Validator\SingleOverflowPerScenario;
|
use App\Validator\SingleOverflowPerScenario;
|
||||||
|
|
@ -25,25 +24,10 @@ final class SingleOverflowPerScenarioTest extends KernelTestCase
|
||||||
$this->validator = self::getContainer()->get(ValidatorInterface::class);
|
$this->validator = self::getContainer()->get(ValidatorInterface::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function persistOwner(): User
|
|
||||||
{
|
|
||||||
static $counter = 0;
|
|
||||||
++$counter;
|
|
||||||
|
|
||||||
$owner = new User();
|
|
||||||
$owner->setEmail(\sprintf('single-overflow-per-scenario-test-%d@example.com', $counter));
|
|
||||||
$owner->setPassword('irrelevant-hash');
|
|
||||||
|
|
||||||
$this->em->persist($owner);
|
|
||||||
|
|
||||||
return $owner;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSecondOverflowBucketOnTheSameScenarioRaisesAViolation(): void
|
public function testSecondOverflowBucketOnTheSameScenarioRaisesAViolation(): void
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
@ -70,7 +54,6 @@ final class SingleOverflowPerScenarioTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
@ -94,7 +77,6 @@ final class SingleOverflowPerScenarioTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue