Compare commits

...

11 commits

38 changed files with 3446 additions and 2 deletions

View file

@ -1,7 +1,9 @@
api_platform:
title: Hello API Platform
title: Buckets API Platform
version: 1.0.0
defaults:
stateless: true
stateless: false
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']
formats:
jsonld: ['application/ld+json']

View file

@ -0,0 +1,29 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260618213556 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('CREATE TABLE scenario (id UUID NOT NULL, distribution_mode VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, description TEXT DEFAULT NULL, PRIMARY KEY (id))');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE scenario');
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260618224747 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('CREATE TABLE bucket (id UUID NOT NULL, name VARCHAR(255) NOT NULL, type VARCHAR(255) NOT NULL, priority INT NOT NULL, sort_order INT DEFAULT 0 NOT NULL, allocation_type VARCHAR(255) NOT NULL, allocation_value INT DEFAULT NULL, starting_amount INT DEFAULT 0 NOT NULL, buffer_multiplier NUMERIC(5, 2) DEFAULT \'0.00\' NOT NULL, scenario_id UUID NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_E73F36A6E04E49DF ON bucket (scenario_id)');
$this->addSql('CREATE UNIQUE INDEX unique_scenario_priority ON bucket (scenario_id, priority)');
$this->addSql('ALTER TABLE bucket ADD CONSTRAINT FK_E73F36A6E04E49DF FOREIGN KEY (scenario_id) REFERENCES scenario (id) ON DELETE CASCADE NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bucket DROP CONSTRAINT FK_E73F36A6E04E49DF');
$this->addSql('DROP TABLE bucket');
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260619175824 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('CREATE TABLE stream (id UUID NOT NULL, name VARCHAR(255) NOT NULL, amount INT NOT NULL, type VARCHAR(255) NOT NULL, is_active BOOLEAN DEFAULT true NOT NULL, frequency VARCHAR(255) NOT NULL, start_date DATE NOT NULL, end_date DATE DEFAULT NULL, description TEXT DEFAULT NULL, bucket_id UUID DEFAULT NULL, scenario_id UUID NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_F0E9BE1C84CE584D ON stream (bucket_id)');
$this->addSql('CREATE INDEX IDX_F0E9BE1CE04E49DF ON stream (scenario_id)');
$this->addSql('ALTER TABLE stream ADD CONSTRAINT FK_F0E9BE1C84CE584D FOREIGN KEY (bucket_id) REFERENCES bucket (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE stream ADD CONSTRAINT FK_F0E9BE1CE04E49DF FOREIGN KEY (scenario_id) REFERENCES scenario (id) ON DELETE CASCADE NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE stream DROP CONSTRAINT FK_F0E9BE1C84CE584D');
$this->addSql('ALTER TABLE stream DROP CONSTRAINT FK_F0E9BE1CE04E49DF');
$this->addSql('DROP TABLE stream');
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260619212401 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 bucket ALTER allocation_type SET DEFAULT \'fixed_limit\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bucket ALTER allocation_type DROP DEFAULT');
}
}

View file

@ -119,6 +119,44 @@ pkgs.mkShell {
$COMPOSE exec php vendor/bin/php-cs-fixer fix "$@"
}
# ===================
# DATABASE / MIGRATION COMMANDS
# ===================
# The Doctrine migration workflow, simplified. Typical loop:
# 1. change an entity mapping
# 2. dev-migrate-diff -> generate ONE migration from the mapping (review it!)
# 3. dev-migrate -> apply to BOTH dev and test DBs
# If you generated/applied a wrong migration: dev-migrate-prev rolls back BOTH DBs,
# then delete the bad migrations/Version*.php file and re-run dev-migrate-diff.
dev-migrate-diff() {
# Generate a migration from the diff between entity mappings and the dev DB schema.
# ALWAYS read the generated migrations/Version*.php before applying it.
echo "Generating migration from entity mapping diff..."
$COMPOSE exec php php bin/console doctrine:migrations:diff "$@"
echo ""
echo ">> Review the new file in migrations/ before running dev-migrate."
}
dev-migrate() {
# Apply pending migrations to BOTH the dev and test databases.
# Interactive: Doctrine will prompt before destructive/unregistered changes — read them.
echo "=== Migrating DEV database (buckets) ==="
$COMPOSE exec php php bin/console doctrine:migrations:migrate "$@"
echo ""
echo "=== Migrating TEST database (buckets_test) ==="
$COMPOSE exec php php bin/console doctrine:migrations:migrate --env=test "$@"
}
dev-migrate-prev() {
# Roll back the most recent migration on BOTH the dev and test databases.
echo "=== Rolling back DEV database (buckets) one step ==="
$COMPOSE exec php php bin/console doctrine:migrations:migrate prev "$@"
echo ""
echo "=== Rolling back TEST database (buckets_test) one step ==="
$COMPOSE exec php php bin/console doctrine:migrations:migrate prev --env=test "$@"
}
# ===================
# BUILD COMMANDS
# ===================
@ -174,6 +212,9 @@ pkgs.mkShell {
echo " dev-stan [args] Run PHPStan static analysis"
echo " dev-cs [args] Check code style (dry-run)"
echo " dev-cs-fix [args] Apply code style fixes"
echo " dev-migrate-diff Generate a migration from entity mappings"
echo " dev-migrate Apply migrations to dev + test DBs"
echo " dev-migrate-prev Roll back one migration on dev + test DBs"
echo " base-build Build and push image"
echo ""
echo "Services:"

183
src/Entity/Bucket.php Normal file
View file

@ -0,0 +1,183 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\Enum\BucketAllocationType;
use App\Enum\BucketType;
use App\Repository\BucketRepository;
use App\Validator\BufferOnlyForFixedLimit;
use App\Validator\CompatibleAllocationType;
use App\Validator\SingleOverflowPerScenario;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\UuidV7;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: BucketRepository::class)]
#[ORM\Table(name: 'bucket')]
#[ORM\UniqueConstraint(name: 'unique_scenario_priority', columns: ['scenario_id', 'priority'])]
#[CompatibleAllocationType]
#[SingleOverflowPerScenario]
#[BufferOnlyForFixedLimit]
#[ApiResource(
operations: [new GetCollection(), new Get(), new Post()],
security: "is_granted('ROLE_USER')",
)]
class Bucket
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private UuidV7 $id;
#[ORM\ManyToOne(targetEntity: Scenario::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Scenario $scenario;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
private string $name;
#[ORM\Column(enumType: BucketType::class)]
private BucketType $type = BucketType::NEED;
#[ORM\Column]
#[Assert\NotNull]
private int $priority;
#[ORM\Column(options: ['default' => 0])]
private int $sortOrder = 0;
#[ORM\Column(enumType: BucketAllocationType::class, options: ['default' => BucketAllocationType::FIXED_LIMIT])]
private BucketAllocationType $allocationType = BucketAllocationType::FIXED_LIMIT;
#[ORM\Column(nullable: true)]
private ?int $allocationValue = null;
#[ORM\Column(options: ['default' => 0])]
private int $startingAmount = 0;
#[ORM\Column(type: 'decimal', precision: 5, scale: 2, options: ['default' => '0.00'])]
#[Assert\GreaterThanOrEqual(0)]
private string $bufferMultiplier = '0.00';
public function __construct()
{
$this->id = new UuidV7();
}
public function getId(): UuidV7
{
return $this->id;
}
public function getScenario(): Scenario
{
return $this->scenario;
}
public function getName(): string
{
return $this->name;
}
public function getType(): BucketType
{
return $this->type;
}
public function getPriority(): int
{
return $this->priority;
}
public function getSortOrder(): int
{
return $this->sortOrder;
}
public function getAllocationType(): BucketAllocationType
{
return $this->allocationType;
}
public function getAllocationValue(): ?int
{
return $this->allocationValue;
}
public function getStartingAmount(): int
{
return $this->startingAmount;
}
public function getBufferMultiplier(): string
{
return $this->bufferMultiplier;
}
public function setScenario(Scenario $scenario): static
{
$this->scenario = $scenario;
return $this;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function setType(BucketType $type): static
{
$this->type = $type;
return $this;
}
public function setPriority(int $priority): static
{
$this->priority = $priority;
return $this;
}
public function setSortOrder(int $sortOrder): static
{
$this->sortOrder = $sortOrder;
return $this;
}
public function setAllocationType(BucketAllocationType $allocationType): static
{
$this->allocationType = $allocationType;
return $this;
}
public function setAllocationValue(?int $allocationValue): static
{
$this->allocationValue = $allocationValue;
return $this;
}
public function setStartingAmount(int $startingAmount): static
{
$this->startingAmount = $startingAmount;
return $this;
}
public function setBufferMultiplier(string $bufferMultiplier): static
{
$this->bufferMultiplier = $bufferMultiplier;
return $this;
}
}

82
src/Entity/Scenario.php Normal file
View file

@ -0,0 +1,82 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\Enum\DistributionMode;
use App\Repository\ScenarioRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
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()],
security: "is_granted('ROLE_USER')",
)]
class Scenario
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private UuidV7 $id;
#[ORM\Column(enumType: DistributionMode::class)]
private DistributionMode $distributionMode = DistributionMode::EVEN;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
private string $name;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
public function __construct()
{
$this->id = new UuidV7();
}
public function getId(): UuidV7
{
return $this->id;
}
public function getDistributionMode(): DistributionMode
{
return $this->distributionMode;
}
public function getName(): string
{
return $this->name;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDistributionMode(DistributionMode $distributionMode): static
{
$this->distributionMode = $distributionMode;
return $this;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
}

195
src/Entity/Stream.php Normal file
View file

@ -0,0 +1,195 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\Enum\StreamFrequency;
use App\Enum\StreamType;
use App\Repository\StreamRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\UuidV7;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'stream')]
#[ORM\Entity(repositoryClass: StreamRepository::class)]
#[ApiResource(
operations: [new GetCollection(), new Get(), new Post()],
security: "is_granted('ROLE_USER')",
)]
class Stream
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private UuidV7 $id;
#[ORM\ManyToOne(targetEntity: Bucket::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Bucket $bucket;
#[ORM\ManyToOne(targetEntity: Scenario::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Scenario $scenario;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
private string $name;
#[ORM\Column]
#[Assert\NotNull]
private int $amount;
#[ORM\Column(enumType: StreamType::class)]
#[Assert\NotNull]
private StreamType $type;
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])]
private bool $isActive = true;
#[ORM\Column(enumType: StreamFrequency::class)]
#[Assert\NotNull]
private StreamFrequency $frequency;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Assert\NotNull]
private \DateTimeImmutable $startDate;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $endDate = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
public function __construct()
{
$this->id = new UuidV7();
}
public function getId(): UuidV7
{
return $this->id;
}
public function getBucket(): ?Bucket
{
return $this->bucket;
}
public function getScenario(): Scenario
{
return $this->scenario;
}
public function getName(): string
{
return $this->name;
}
public function getAmount(): int
{
return $this->amount;
}
public function getType(): StreamType
{
return $this->type;
}
public function isActive(): bool
{
return $this->isActive;
}
public function getFrequency(): StreamFrequency
{
return $this->frequency;
}
public function getStartDate(): \DateTimeImmutable
{
return $this->startDate;
}
public function getEndDate(): ?\DateTimeImmutable
{
return $this->endDate;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setBucket(?Bucket $bucket): static
{
$this->bucket = $bucket;
return $this;
}
public function setScenario(Scenario $scenario): static
{
$this->scenario = $scenario;
return $this;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function setAmount(int $amount): static
{
$this->amount = $amount;
return $this;
}
public function setType(StreamType $type): static
{
$this->type = $type;
return $this;
}
public function setIsActive(bool $isActive): static
{
$this->isActive = $isActive;
return $this;
}
public function setFrequency(StreamFrequency $frequency): static
{
$this->frequency = $frequency;
return $this;
}
public function setStartDate(\DateTimeImmutable $startDate): static
{
$this->startDate = $startDate;
return $this;
}
public function setEndDate(?\DateTimeImmutable $endDate): static
{
$this->endDate = $endDate;
return $this;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Enum;
enum BucketAllocationType: string
{
case FIXED_LIMIT = 'fixed_limit';
case PERCENTAGE = 'percentage';
case UNLIMITED = 'unlimited';
}

26
src/Enum/BucketType.php Normal file
View file

@ -0,0 +1,26 @@
<?php
namespace App\Enum;
enum BucketType: string
{
case NEED = 'need';
case WANT = 'want';
case OVERFLOW = 'overflow';
/**
* @return list<BucketAllocationType>
*/
public function getAllowedAllocationTypes(): array
{
return match ($this) {
self::NEED, self::WANT => [
BucketAllocationType::FIXED_LIMIT,
BucketAllocationType::PERCENTAGE,
],
self::OVERFLOW => [
BucketAllocationType::UNLIMITED,
],
};
}
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Enum;
enum DistributionMode: string
{
case EVEN = 'even';
case PRIORITY = 'priority';
}

View file

@ -0,0 +1,14 @@
<?php
namespace App\Enum;
enum StreamFrequency: string
{
case ONCE = 'once';
case DAILY = 'daily';
case WEEKLY = 'weekly';
case BIWEEKLY = 'biweekly';
case MONTHLY = 'monthly';
case QUARTERLY = 'quarterly';
case YEARLY = 'yearly';
}

9
src/Enum/StreamType.php Normal file
View file

@ -0,0 +1,9 @@
<?php
namespace App\Enum;
enum StreamType: string
{
case INCOME = 'income';
case EXPENSE = 'expense';
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Repository;
use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Enum\BucketType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Uid\UuidV7;
/**
* @extends ServiceEntityRepository<Bucket>
*/
class BucketRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Bucket::class);
}
public function countOverflowBucketsForScenario(Scenario $scenario, ?UuidV7 $excludeId = null): int
{
$qb = $this->createQueryBuilder('b')
->select('COUNT(b.id)')
->andWhere('b.scenario = :scenario')
->andWhere('b.type = :overflow')
->setParameter('scenario', $scenario)
->setParameter('overflow', BucketType::OVERFLOW);
if (null !== $excludeId) {
$qb->andWhere('b.id != :excludeId')
->setParameter('excludeId', $excludeId, 'uuid');
}
return (int) $qb->getQuery()->getSingleScalarResult();
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\Scenario;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Scenario>
*/
class ScenarioRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Scenario::class);
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\Stream;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Stream>
*/
class StreamRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Stream::class);
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_CLASS)]
final class BufferOnlyForFixedLimit extends Constraint
{
public string $message = 'The buffer multiplier can only be set for fixed-limit buckets.';
public const string BUFFER_NOT_ALLOWED_ERROR = 'BUFFER_NOT_ALLOWED_ERROR';
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Validator;
use App\Entity\Bucket;
use App\Enum\BucketAllocationType;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class BufferOnlyForFixedLimitValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof BufferOnlyForFixedLimit) {
throw new UnexpectedTypeException($constraint, BufferOnlyForFixedLimit::class);
}
if (!$value instanceof Bucket) {
return;
}
if (BucketAllocationType::FIXED_LIMIT === $value->getAllocationType()) {
return; // buffer allowed
}
if (0.0 !== (float) $value->getBufferMultiplier()) {
$this->context->buildViolation($constraint->message)
->atPath('bufferMultiplier')
->setCode(BufferOnlyForFixedLimit::BUFFER_NOT_ALLOWED_ERROR)
->addViolation();
}
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_CLASS)]
final class CompatibleAllocationType extends Constraint
{
public string $message = 'This allocation type is not compatible with the bucket type.';
public const string NOT_COMPATIBLE_ERROR = 'NOT_COMPATIBLE_ERROR';
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace App\Validator;
use App\Entity\Bucket;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class CompatibleAllocationTypeValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof CompatibleAllocationType) {
throw new UnexpectedTypeException($constraint, CompatibleAllocationType::class);
}
if (!$value instanceof Bucket) {
return;
}
$type = $value->getType();
$allocationType = $value->getAllocationType();
if (!\in_array($allocationType, $type->getAllowedAllocationTypes(), true)) {
$this->context->buildViolation($constraint->message)
->atPath('allocationType')
->setCode(CompatibleAllocationType::NOT_COMPATIBLE_ERROR)
->addViolation();
}
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_CLASS)]
final class SingleOverflowPerScenario extends Constraint
{
public string $message = 'A scenario can only have one overflow bucket.';
public const string TOO_MANY_OVERFLOW_ERROR = 'TOO_MANY_OVERFLOW_ERROR';
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Validator;
use App\Entity\Bucket;
use App\Enum\BucketType;
use App\Repository\BucketRepository;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class SingleOverflowPerScenarioValidator extends ConstraintValidator
{
public function __construct(private readonly BucketRepository $bucketRepository)
{
}
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof SingleOverflowPerScenario) {
throw new UnexpectedTypeException($constraint, SingleOverflowPerScenario::class);
}
if (!$value instanceof Bucket) {
return;
}
if (BucketType::OVERFLOW !== $value->getType()) {
return;
}
$existing = $this->bucketRepository->countOverflowBucketsForScenario(
$value->getScenario(),
$value->getId(),
);
if ($existing > 0) {
$this->context->buildViolation($constraint->message)
->atPath('type')
->setCode(SingleOverflowPerScenario::TOO_MANY_OVERFLOW_ERROR)
->addViolation();
}
}
}

View file

@ -0,0 +1,208 @@
<?php
namespace App\Tests\Entity;
use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Enum\BucketAllocationType;
use App\Enum\BucketType;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Uid\UuidV7;
final class BucketPersistenceTest extends KernelTestCase
{
private EntityManagerInterface $em;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
}
public function testItPersistsABucketLinkedToItsScenario(): void
{
$scenario = new Scenario();
$scenario->setName('Household Budget');
$this->em->persist($scenario);
$this->em->flush();
$scenarioId = $scenario->getId();
$bucket = new Bucket();
$bucket->setScenario($scenario);
$bucket->setType(BucketType::NEED);
$bucket->setName('Rent');
$bucket->setPriority(3);
$bucket->setSortOrder(2);
$bucket->setAllocationType(BucketAllocationType::FIXED_LIMIT);
$bucket->setAllocationValue(150000);
$bucket->setStartingAmount(5000);
$bucket->setBufferMultiplier('1.50');
$this->em->persist($bucket);
$this->em->flush();
$bucketId = $bucket->getId();
$this->em->clear();
$reloaded = $this->em->find(Bucket::class, $bucketId);
self::assertNotNull($reloaded, 'Bucket must be retrievable from the database after an identity-map clear.');
self::assertInstanceOf(Scenario::class, $reloaded->getScenario());
self::assertSame(
(string) $scenarioId,
(string) $reloaded->getScenario()->getId(),
'Reloaded bucket must be linked to the same scenario it was persisted with.',
);
self::assertInstanceOf(
BucketType::class,
$reloaded->getType(),
'type must hydrate as a BucketType enum instance, not a raw string.',
);
self::assertSame(BucketType::NEED, $reloaded->getType());
self::assertSame('Rent', $reloaded->getName());
self::assertSame(3, $reloaded->getPriority());
self::assertSame(2, $reloaded->getSortOrder());
self::assertInstanceOf(
BucketAllocationType::class,
$reloaded->getAllocationType(),
'allocation_type must hydrate as a BucketAllocationType enum instance, not a raw string.',
);
self::assertSame(BucketAllocationType::FIXED_LIMIT, $reloaded->getAllocationType());
self::assertSame(
150000,
$reloaded->getAllocationValue(),
'allocation_value must round-trip as an int (cents), not a string or float.',
);
self::assertSame(5000, $reloaded->getStartingAmount());
self::assertSame(
'1.50',
$reloaded->getBufferMultiplier(),
'buffer_multiplier is a Doctrine decimal column — it hydrates as a numeric string, not a float (precision-safe).',
);
}
public function testAllocationTypeDefaultsToFixedLimitWhenNotSet(): void
{
$scenario = new Scenario();
$scenario->setName('Household Budget');
$this->em->persist($scenario);
$this->em->flush();
$bucket = new Bucket();
$bucket->setScenario($scenario);
$bucket->setType(BucketType::NEED);
$bucket->setName('Rent');
$bucket->setPriority(1);
$bucket->setSortOrder(0);
// setAllocationType() deliberately NOT called — pins the default.
$bucket->setAllocationValue(150000);
$bucket->setStartingAmount(5000);
$bucket->setBufferMultiplier('0.00');
$this->em->persist($bucket);
$this->em->flush();
$bucketId = $bucket->getId();
$this->em->clear();
$reloaded = $this->em->find(Bucket::class, $bucketId);
self::assertNotNull($reloaded, 'Bucket must be retrievable from the database after an identity-map clear.');
self::assertSame(
BucketAllocationType::FIXED_LIMIT,
$reloaded->getAllocationType(),
'allocation_type must default to FIXED_LIMIT at the entity (PHP) level when not explicitly set. '
.'This does not exercise the DB column default, since Doctrine always includes allocation_type '
.'in its INSERT once the property holds a value — see testAllocationTypeDbColumnHasFixedLimitDefaultWhenOmittedFromInsert '
.'for the DB-level proof.',
);
}
public function testAllocationTypeDbColumnHasFixedLimitDefaultWhenOmittedFromInsert(): void
{
$scenario = new Scenario();
$scenario->setName('Household Budget');
$this->em->persist($scenario);
$this->em->flush();
$scenarioId = $scenario->getId();
$bucketId = new UuidV7();
$connection = $this->em->getConnection();
// Raw INSERT deliberately omits allocation_type — the DB column default must supply it.
// All other NOT NULL columns without a DB default (id, name, type, priority, scenario_id)
// are provided explicitly.
$connection->executeStatement(
'INSERT INTO bucket (id, name, type, priority, scenario_id) VALUES (:id, :name, :type, :priority, :scenarioId)',
[
'id' => $bucketId->toRfc4122(),
'name' => 'Rent',
'type' => BucketType::NEED->value,
'priority' => 1,
'scenarioId' => $scenarioId->toRfc4122(),
],
);
$allocationType = $connection->executeQuery(
'SELECT allocation_type FROM bucket WHERE id = :id',
['id' => $bucketId->toRfc4122()],
)->fetchOne();
self::assertSame(
'fixed_limit',
$allocationType,
'When allocation_type is omitted from a raw INSERT, the DB column default must supply '
.'\'fixed_limit\'. If this fails, the migration setting the column DEFAULT has regressed '
.'(the insert would fail outright with a not-null violation instead of defaulting).',
);
}
public function testScenarioPriorityUniquenessIsEnforced(): void
{
$scenario = new Scenario();
$scenario->setName('Household Budget');
$this->em->persist($scenario);
$this->em->flush();
$first = new Bucket();
$first->setScenario($scenario);
$first->setType(BucketType::NEED);
$first->setName('Rent');
$first->setPriority(3);
$first->setSortOrder(1);
$first->setAllocationType(BucketAllocationType::FIXED_LIMIT);
$first->setAllocationValue(150000);
$first->setStartingAmount(5000);
$first->setBufferMultiplier('0.00');
$this->em->persist($first);
$this->em->flush();
$second = new Bucket();
$second->setScenario($scenario);
$second->setType(BucketType::NEED);
$second->setName('Utilities');
$second->setPriority(3);
$second->setSortOrder(2);
$second->setAllocationType(BucketAllocationType::FIXED_LIMIT);
$second->setAllocationValue(50000);
$second->setStartingAmount(3000);
$second->setBufferMultiplier('0.00');
$this->em->persist($second);
$this->expectException(UniqueConstraintViolationException::class);
$this->em->flush();
}
}

View file

@ -0,0 +1,127 @@
<?php
namespace App\Tests\Entity;
use App\Entity\Scenario;
use App\Enum\DistributionMode;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Uid\UuidV7;
final class ScenarioPersistenceTest extends KernelTestCase
{
private EntityManagerInterface $em;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
}
public function testItPersistsAScenarioWithAUuidId(): void
{
$scenario = new Scenario();
$scenario->setName('Household Budget');
$this->em->persist($scenario);
$this->em->flush();
$id = $scenario->getId();
self::assertInstanceOf(UuidV7::class, $id);
$this->em->clear();
$reloaded = $this->em->find(Scenario::class, $id);
self::assertNotNull($reloaded, 'Scenario must be retrievable from the database after an identity-map clear.');
self::assertInstanceOf(UuidV7::class, $reloaded->getId());
self::assertSame('Household Budget', $reloaded->getName());
}
public function testDistributionModeDefaultsToEvenWhenNotExplicitlySet(): void
{
$scenario = new Scenario();
$scenario->setName('Default Mode Scenario');
$this->em->persist($scenario);
$this->em->flush();
$id = $scenario->getId();
$this->em->clear();
$reloaded = $this->em->find(Scenario::class, $id);
self::assertNotNull($reloaded);
self::assertSame(
DistributionMode::EVEN,
$reloaded->getDistributionMode(),
'distribution_mode must default to EVEN when not explicitly set.',
);
}
public function testItRoundTripsAnExplicitlySetPriorityDistributionMode(): void
{
$scenario = new Scenario();
$scenario->setName('Priority Mode Scenario');
$scenario->setDistributionMode(DistributionMode::PRIORITY);
$this->em->persist($scenario);
$this->em->flush();
$id = $scenario->getId();
$this->em->clear();
$reloaded = $this->em->find(Scenario::class, $id);
self::assertNotNull($reloaded);
self::assertInstanceOf(
DistributionMode::class,
$reloaded->getDistributionMode(),
'distribution_mode must hydrate as a DistributionMode enum instance, not a raw string.',
);
self::assertSame(
DistributionMode::PRIORITY,
$reloaded->getDistributionMode(),
'An explicitly set PRIORITY distribution_mode must survive a flush + clear + reload round-trip.',
);
}
public function testDescriptionRoundTripsWhenSet(): void
{
$scenario = new Scenario();
$scenario->setName('Described Scenario');
$scenario->setDescription('Covers rent, groceries, and the emergency buffer.');
$this->em->persist($scenario);
$this->em->flush();
$id = $scenario->getId();
$this->em->clear();
$reloaded = $this->em->find(Scenario::class, $id);
self::assertNotNull($reloaded);
self::assertSame('Covers rent, groceries, and the emergency buffer.', $reloaded->getDescription());
}
public function testDescriptionIsNullableAndRemainsNullWhenNotSet(): void
{
$scenario = new Scenario();
$scenario->setName('Undescribed Scenario');
$this->em->persist($scenario);
$this->em->flush();
$id = $scenario->getId();
$this->em->clear();
$reloaded = $this->em->find(Scenario::class, $id);
self::assertNotNull($reloaded);
self::assertNull($reloaded->getDescription());
}
}

View file

@ -0,0 +1,158 @@
<?php
namespace App\Tests\Entity;
use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Entity\Stream;
use App\Enum\BucketAllocationType;
use App\Enum\BucketType;
use App\Enum\StreamFrequency;
use App\Enum\StreamType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class StreamPersistenceTest extends KernelTestCase
{
private EntityManagerInterface $em;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
}
public function testItPersistsAStreamLinkedToItsScenarioAndBucket(): void
{
$scenario = new Scenario();
$scenario->setName('Household Budget');
$this->em->persist($scenario);
$bucket = new Bucket();
$bucket->setScenario($scenario);
$bucket->setType(BucketType::NEED);
$bucket->setName('Rent');
$bucket->setPriority(1);
$bucket->setSortOrder(0);
$bucket->setAllocationType(BucketAllocationType::FIXED_LIMIT);
$bucket->setAllocationValue(150000);
$bucket->setStartingAmount(0);
$bucket->setBufferMultiplier('0.00');
$this->em->persist($bucket);
$this->em->flush();
$scenarioId = $scenario->getId();
$bucketId = $bucket->getId();
$stream = new Stream();
$stream->setScenario($scenario);
$stream->setBucket($bucket);
$stream->setName('Paycheck');
$stream->setAmount(250000);
$stream->setType(StreamType::INCOME);
$stream->setFrequency(StreamFrequency::BIWEEKLY);
$stream->setStartDate(new \DateTimeImmutable('2026-01-01'));
$stream->setEndDate(new \DateTimeImmutable('2026-12-31'));
$stream->setIsActive(true);
$stream->setDescription('Primary employer income.');
$this->em->persist($stream);
$this->em->flush();
$streamId = $stream->getId();
$this->em->clear();
$reloaded = $this->em->find(Stream::class, $streamId);
self::assertNotNull($reloaded, 'Stream must be retrievable from the database after an identity-map clear.');
self::assertInstanceOf(Scenario::class, $reloaded->getScenario());
self::assertSame(
(string) $scenarioId,
(string) $reloaded->getScenario()->getId(),
'Reloaded stream must be linked to the same scenario it was persisted with.',
);
self::assertInstanceOf(Bucket::class, $reloaded->getBucket());
self::assertSame(
(string) $bucketId,
(string) $reloaded->getBucket()->getId(),
'Reloaded stream must be linked to the same bucket it was persisted with.',
);
self::assertSame('Paycheck', $reloaded->getName());
self::assertSame(
250000,
$reloaded->getAmount(),
'amount must round-trip as an int (cents), not a string or float.',
);
self::assertInstanceOf(
StreamType::class,
$reloaded->getType(),
'type must hydrate as a StreamType enum instance, not a raw string.',
);
self::assertSame(StreamType::INCOME, $reloaded->getType());
self::assertInstanceOf(
StreamFrequency::class,
$reloaded->getFrequency(),
'frequency must hydrate as a StreamFrequency enum instance, not a raw string.',
);
self::assertSame(StreamFrequency::BIWEEKLY, $reloaded->getFrequency());
self::assertInstanceOf(\DateTimeImmutable::class, $reloaded->getStartDate());
self::assertSame('2026-01-01', $reloaded->getStartDate()->format('Y-m-d'));
self::assertInstanceOf(\DateTimeImmutable::class, $reloaded->getEndDate());
self::assertSame('2026-12-31', $reloaded->getEndDate()->format('Y-m-d'));
self::assertTrue($reloaded->isActive());
self::assertSame('Primary employer income.', $reloaded->getDescription());
}
public function testItPersistsAStreamWithoutABucket(): void
{
$scenario = new Scenario();
$scenario->setName('Household Budget');
$this->em->persist($scenario);
$this->em->flush();
$stream = new Stream();
$stream->setScenario($scenario);
$stream->setName('Freelance Income');
$stream->setAmount(50000);
$stream->setType(StreamType::INCOME);
$stream->setFrequency(StreamFrequency::MONTHLY);
$stream->setStartDate(new \DateTimeImmutable('2026-02-01'));
$this->em->persist($stream);
$this->em->flush();
$streamId = $stream->getId();
$this->em->clear();
$reloaded = $this->em->find(Stream::class, $streamId);
self::assertNotNull($reloaded, 'Stream must be retrievable from the database after an identity-map clear.');
self::assertNull(
$reloaded->getBucket(),
'bucket must be nullable — a stream is not required to link to a bucket.',
);
self::assertNull(
$reloaded->getEndDate(),
'end_date must be nullable when not explicitly set.',
);
self::assertTrue(
$reloaded->isActive(),
'is_active must default to true when setIsActive() is never called.',
);
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace App\Tests\Enum;
use App\Enum\BucketAllocationType;
use App\Enum\BucketType;
use PHPUnit\Framework\TestCase;
final class BucketTypeTest extends TestCase
{
public function testBucketTypeCasesAndAllowedAllocationTypes(): void
{
$this->assertSame('need', BucketType::NEED->value);
$this->assertSame('want', BucketType::WANT->value);
$this->assertSame('overflow', BucketType::OVERFLOW->value);
$this->assertSame(
[BucketAllocationType::FIXED_LIMIT, BucketAllocationType::PERCENTAGE],
BucketType::NEED->getAllowedAllocationTypes(),
);
$this->assertSame(
[BucketAllocationType::FIXED_LIMIT, BucketAllocationType::PERCENTAGE],
BucketType::WANT->getAllowedAllocationTypes(),
);
$this->assertSame(
[BucketAllocationType::UNLIMITED],
BucketType::OVERFLOW->getAllowedAllocationTypes(),
);
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Tests\Enum;
use App\Enum\BucketAllocationType;
use App\Enum\DistributionMode;
use App\Enum\StreamFrequency;
use App\Enum\StreamType;
use PHPUnit\Framework\TestCase;
final class RemainingEnumsTest extends TestCase
{
public function testRemainingEnumsExposeExpectedCases(): void
{
$this->assertSame('once', StreamFrequency::ONCE->value);
$this->assertSame('daily', StreamFrequency::DAILY->value);
$this->assertSame('weekly', StreamFrequency::WEEKLY->value);
$this->assertSame('biweekly', StreamFrequency::BIWEEKLY->value);
$this->assertSame('monthly', StreamFrequency::MONTHLY->value);
$this->assertSame('quarterly', StreamFrequency::QUARTERLY->value);
$this->assertSame('yearly', StreamFrequency::YEARLY->value);
$this->assertSame('income', StreamType::INCOME->value);
$this->assertSame('expense', StreamType::EXPENSE->value);
$this->assertSame('even', DistributionMode::EVEN->value);
$this->assertSame('priority', DistributionMode::PRIORITY->value);
}
public function testBucketAllocationTypeBackingValues(): void
{
$this->assertSame('fixed_limit', BucketAllocationType::FIXED_LIMIT->value);
$this->assertSame('percentage', BucketAllocationType::PERCENTAGE->value);
$this->assertSame('unlimited', BucketAllocationType::UNLIMITED->value);
}
}

View file

@ -0,0 +1,471 @@
<?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\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
final class BucketApiTest extends WebTestCase
{
private const EMAIL = 'bucket-api-test@example.com';
private const PASSWORD = 'correct-horse-battery-staple';
private KernelBrowser $client;
private EntityManagerInterface $em;
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);
$user = new User();
$user->setEmail(self::EMAIL);
$user->setPassword($hasher->hashPassword($user, self::PASSWORD));
$this->em->persist($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 Bucket API.',
);
}
private function persistScenario(string $name): Scenario
{
$scenario = new Scenario();
$scenario->setName($name);
$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;
}
private function scenarioIri(Scenario $scenario): string
{
return '/api/scenarios/'.$scenario->getId();
}
/**
* Asserts that the JSON-LD 422 response body contains a Hydra
* ConstraintViolationList violation at the given propertyPath, pinning
* down WHICH validator rejected the request (not just that *some*
* validator did).
*/
private function assertHasViolationAt(string $propertyPath, Response $response): void
{
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The 422 response must be a JSON-LD object.');
self::assertArrayHasKey(
'violations',
$body,
'A 422 validation failure must expose a Hydra ConstraintViolationList violations array.',
);
$paths = array_column($body['violations'], 'propertyPath');
self::assertContains(
$propertyPath,
$paths,
\sprintf(
'Expected a violation at propertyPath "%s", got: %s',
$propertyPath,
json_encode($paths),
),
);
}
public function testGetCollectionIsRejectedWhenUnauthenticated(): void
{
$this->client->request('GET', '/api/buckets', server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
self::assertSame(
401,
$this->client->getResponse()->getStatusCode(),
'GET /api/buckets must require authentication.',
);
}
public function testPostIsRejectedWhenUnauthenticated(): void
{
$this->client->request(
'POST',
'/api/buckets',
server: [
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
content: json_encode(['name' => 'Groceries']),
);
self::assertSame(
401,
$this->client->getResponse()->getStatusCode(),
'POST /api/buckets must require authentication.',
);
}
public function testGetCollectionReturnsPersistedBucketsWhenAuthenticated(): void
{
$scenario = $this->persistScenario('Household Budget');
$this->persistBucket($scenario, 'Rent', 1);
$this->persistBucket($scenario, 'Groceries', 2);
$this->login();
$this->client->request('GET', '/api/buckets', server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
$response = $this->client->getResponse();
self::assertSame(
200,
$response->getStatusCode(),
'GET /api/buckets must return 200 for an authenticated user.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The collection response must be a JSON-LD/Hydra object.');
self::assertArrayHasKey(
'member',
$body,
'A Hydra collection response must expose its items under the member key.',
);
$names = array_column($body['member'], 'name');
self::assertContains('Rent', $names);
self::assertContains('Groceries', $names);
}
public function testGetItemReturnsTheBucketWhenAuthenticated(): void
{
$scenario = $this->persistScenario('Household Budget');
$bucket = $this->persistBucket($scenario, 'Rent', 1);
$this->login();
$this->client->request('GET', '/api/buckets/'.$bucket->getId(), server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
$response = $this->client->getResponse();
self::assertSame(
200,
$response->getStatusCode(),
'GET /api/buckets/{id} must return 200 for an authenticated user.',
);
$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('Bucket', $body['@type'] ?? null, 'The item @type must be Bucket.');
self::assertSame('Rent', $body['name'] ?? null);
}
public function testPostCreatesABucketWhenAuthenticatedAndResolvesScenarioIri(): void
{
$scenario = $this->persistScenario('Household Budget');
$this->login();
$this->client->request(
'POST',
'/api/buckets',
server: [
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
content: json_encode([
'scenario' => $this->scenarioIri($scenario),
'name' => 'Groceries',
'priority' => 1,
]),
);
$response = $this->client->getResponse();
self::assertSame(
201,
$response->getStatusCode(),
'POST /api/buckets with a valid body must return 201 Created.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The created resource response must be a JSON-LD object.');
self::assertArrayHasKey('@id', $body, 'The created resource must expose its IRI under @id.');
self::assertSame('Groceries', $body['name'] ?? null);
$this->em->clear();
$persisted = $this->em->getRepository(Bucket::class)->findOneBy(['name' => 'Groceries']);
self::assertNotNull(
$persisted,
'A successful POST /api/buckets must persist the new Bucket to the database.',
);
self::assertSame(
$scenario->getId()->toRfc4122(),
$persisted->getScenario()->getId()->toRfc4122(),
'The persisted Bucket must resolve its scenario relation from the posted IRI.',
);
}
public function testPostWithIncompatibleAllocationTypeIsRejectedAsUnprocessable(): void
{
$scenario = $this->persistScenario('Household Budget');
$this->login();
$this->client->request(
'POST',
'/api/buckets',
server: [
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
content: json_encode([
'scenario' => $this->scenarioIri($scenario),
'name' => 'Bad Overflow',
'priority' => 1,
'type' => BucketType::OVERFLOW->value,
'allocationType' => BucketAllocationType::FIXED_LIMIT->value,
]),
);
$response = $this->client->getResponse();
self::assertSame(
422,
$response->getStatusCode(),
'POST /api/buckets with an overflow bucket using a non-unlimited allocation type must be rejected by the CompatibleAllocationType validator (422, not 500).',
);
$this->assertHasViolationAt('allocationType', $response);
}
public function testPostWithSecondOverflowBucketOnSameScenarioIsRejectedAsUnprocessable(): void
{
$scenario = $this->persistScenario('Household Budget');
$existingOverflow = new Bucket();
$existingOverflow->setScenario($scenario);
$existingOverflow->setName('Overflow');
$existingOverflow->setPriority(1);
$existingOverflow->setType(BucketType::OVERFLOW);
$existingOverflow->setAllocationType(BucketAllocationType::UNLIMITED);
$this->em->persist($existingOverflow);
$this->em->flush();
$this->login();
$this->client->request(
'POST',
'/api/buckets',
server: [
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
content: json_encode([
'scenario' => $this->scenarioIri($scenario),
'name' => 'Second Overflow',
'priority' => 2,
'type' => BucketType::OVERFLOW->value,
'allocationType' => BucketAllocationType::UNLIMITED->value,
]),
);
$response = $this->client->getResponse();
self::assertSame(
422,
$response->getStatusCode(),
'POST /api/buckets adding a second overflow bucket to a scenario that already has one must be rejected by the SingleOverflowPerScenario validator (422, not 500).',
);
$this->assertHasViolationAt('type', $response);
}
public function testPostWithBufferOnNonFixedLimitBucketIsRejectedAsUnprocessable(): void
{
$scenario = $this->persistScenario('Household Budget');
$this->login();
$this->client->request(
'POST',
'/api/buckets',
server: [
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
content: json_encode([
'scenario' => $this->scenarioIri($scenario),
'name' => 'Wants',
'priority' => 1,
'type' => BucketType::WANT->value,
'allocationType' => BucketAllocationType::PERCENTAGE->value,
'allocationValue' => 2000,
'bufferMultiplier' => '1.50',
]),
);
$response = $this->client->getResponse();
self::assertSame(
422,
$response->getStatusCode(),
'POST /api/buckets with a non-zero buffer on a non-fixed-limit bucket must be rejected by the BufferOnlyForFixedLimit validator (422, not 500).',
);
$this->assertHasViolationAt('bufferMultiplier', $response);
}
public function testPostWithMissingNameIsRejectedAsUnprocessable(): void
{
$scenario = $this->persistScenario('Household Budget');
$this->login();
$this->client->request(
'POST',
'/api/buckets',
server: [
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
content: json_encode([
'scenario' => $this->scenarioIri($scenario),
'priority' => 1,
]),
);
self::assertSame(
422,
$this->client->getResponse()->getStatusCode(),
'POST /api/buckets without a name must return 422 Unprocessable Entity (validator-rejected, not a 500).',
);
}
public function testPostWithMissingPriorityIsRejectedAsUnprocessable(): void
{
$scenario = $this->persistScenario('Household Budget');
$this->login();
$this->client->request(
'POST',
'/api/buckets',
server: [
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
content: json_encode([
'scenario' => $this->scenarioIri($scenario),
'name' => 'Groceries',
]),
);
$response = $this->client->getResponse();
self::assertSame(
422,
$response->getStatusCode(),
'POST /api/buckets without a priority must return 422 Unprocessable Entity (validator-rejected, not a 500).',
);
$this->assertHasViolationAt('priority', $response);
}
public function testGetItemIsRejectedWhenUnauthenticated(): void
{
$scenario = $this->persistScenario('Household Budget');
$bucket = $this->persistBucket($scenario, 'Rent', 1);
$this->client->request('GET', '/api/buckets/'.$bucket->getId(), server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
self::assertSame(
401,
$this->client->getResponse()->getStatusCode(),
'GET /api/buckets/{id} must require authentication.',
);
}
public function testPostWithNonJsonContentTypeIsRejected(): void
{
$this->login();
$this->client->request(
'POST',
'/api/buckets',
['name' => 'Groceries'],
);
self::assertSame(
415,
$this->client->getResponse()->getStatusCode(),
'POST /api/buckets without a JSON Content-Type must be rejected with 415 Unsupported Media Type.',
);
$this->em->clear();
$persisted = $this->em->getRepository(Bucket::class)->findOneBy(['name' => 'Groceries']);
self::assertNull(
$persisted,
'A rejected non-JSON POST must not persist a Bucket.',
);
}
}

View file

@ -0,0 +1,259 @@
<?php
namespace App\Tests\Functional;
use App\Entity\Scenario;
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 ScenarioApiTest extends WebTestCase
{
private const EMAIL = 'scenario-api-test@example.com';
private const PASSWORD = 'correct-horse-battery-staple';
private KernelBrowser $client;
private EntityManagerInterface $em;
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);
$user = new User();
$user->setEmail(self::EMAIL);
$user->setPassword($hasher->hashPassword($user, self::PASSWORD));
$this->em->persist($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 Scenario API.',
);
}
private function persistScenario(string $name): Scenario
{
$scenario = new Scenario();
$scenario->setName($name);
$this->em->persist($scenario);
$this->em->flush();
return $scenario;
}
public function testGetCollectionIsRejectedWhenUnauthenticated(): void
{
$this->client->request('GET', '/api/scenarios', server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
self::assertSame(
401,
$this->client->getResponse()->getStatusCode(),
'GET /api/scenarios must require authentication.',
);
}
public function testGetItemIsRejectedWhenUnauthenticated(): void
{
$scenario = $this->persistScenario('Emergency Fund');
$this->client->request('GET', '/api/scenarios/'.$scenario->getId(), server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
self::assertSame(
401,
$this->client->getResponse()->getStatusCode(),
'GET /api/scenarios/{id} must require authentication.',
);
}
public function testPostIsRejectedWhenUnauthenticated(): void
{
$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(
401,
$this->client->getResponse()->getStatusCode(),
'POST /api/scenarios must require authentication.',
);
}
public function testGetCollectionReturnsPersistedScenariosWhenAuthenticated(): void
{
$this->persistScenario('Household Budget');
$this->persistScenario('Travel Fund');
$this->login();
$this->client->request('GET', '/api/scenarios', server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
$response = $this->client->getResponse();
self::assertSame(
200,
$response->getStatusCode(),
'GET /api/scenarios must return 200 for an authenticated user.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The collection response must be a JSON-LD/Hydra object.');
self::assertArrayHasKey(
'member',
$body,
'A Hydra collection response must expose its items under the member key.',
);
$names = array_column($body['member'], 'name');
self::assertContains('Household Budget', $names);
self::assertContains('Travel Fund', $names);
}
public function testGetItemReturnsTheScenarioWhenAuthenticated(): 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(),
'GET /api/scenarios/{id} must return 200 for an authenticated user.',
);
$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);
}
public function testPostCreatesAScenarioWhenAuthenticated(): 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']),
);
$response = $this->client->getResponse();
self::assertSame(
201,
$response->getStatusCode(),
'POST /api/scenarios with a valid body must return 201 Created.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The created resource response must be a JSON-LD object.');
self::assertArrayHasKey('@id', $body, 'The created resource must expose its IRI under @id.');
self::assertSame('Holiday Fund', $body['name'] ?? null);
$this->em->clear();
$persisted = $this->em->getRepository(Scenario::class)->findOneBy(['name' => 'Holiday Fund']);
self::assertNotNull(
$persisted,
'A successful POST /api/scenarios must persist the new Scenario to the database.',
);
}
public function testPostWithMissingNameIsRejectedAsUnprocessable(): 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 return 422 Unprocessable Entity (validator-rejected, not a 500).',
);
}
public function testPostWithNonJsonContentTypeIsRejected(): void
{
$this->login();
$this->client->request(
'POST',
'/api/scenarios',
['name' => 'Holiday Fund'],
);
self::assertSame(
415,
$this->client->getResponse()->getStatusCode(),
'POST /api/scenarios without a JSON Content-Type must be rejected with 415 Unsupported Media Type.',
);
$this->em->clear();
$persisted = $this->em->getRepository(Scenario::class)->findOneBy(['name' => 'Holiday Fund']);
self::assertNull(
$persisted,
'A rejected non-JSON POST must not persist a Scenario.',
);
}
}

View file

@ -0,0 +1,383 @@
<?php
namespace App\Tests\Functional;
use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Entity\Stream;
use App\Entity\User;
use App\Enum\StreamFrequency;
use App\Enum\StreamType;
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 StreamApiTest extends WebTestCase
{
private const EMAIL = 'stream-api-test@example.com';
private const PASSWORD = 'correct-horse-battery-staple';
private KernelBrowser $client;
private EntityManagerInterface $em;
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);
$user = new User();
$user->setEmail(self::EMAIL);
$user->setPassword($hasher->hashPassword($user, self::PASSWORD));
$this->em->persist($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 Stream API.',
);
}
private function persistScenario(string $name): Scenario
{
$scenario = new Scenario();
$scenario->setName($name);
$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;
}
private function persistStream(Scenario $scenario, string $name): Stream
{
$stream = new Stream();
$stream->setScenario($scenario);
$stream->setName($name);
$stream->setAmount(300000);
$stream->setType(StreamType::INCOME);
$stream->setFrequency(StreamFrequency::MONTHLY);
$stream->setStartDate(new \DateTimeImmutable('2026-01-01'));
$this->em->persist($stream);
$this->em->flush();
return $stream;
}
private function scenarioIri(Scenario $scenario): string
{
return '/api/scenarios/'.$scenario->getId();
}
private function bucketIri(Bucket $bucket): string
{
return '/api/buckets/'.$bucket->getId();
}
public function testGetCollectionIsRejectedWhenUnauthenticated(): void
{
$this->client->request('GET', '/api/streams', server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
self::assertSame(
401,
$this->client->getResponse()->getStatusCode(),
'GET /api/streams must require authentication.',
);
}
public function testGetCollectionReturnsPersistedStreamsWhenAuthenticated(): void
{
$scenario = $this->persistScenario('Household Budget');
$this->persistStream($scenario, 'Salary');
$this->login();
$this->client->request('GET', '/api/streams', server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
$response = $this->client->getResponse();
self::assertSame(
200,
$response->getStatusCode(),
'GET /api/streams must return 200 for an authenticated user.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The collection response must be a JSON-LD/Hydra object.');
self::assertArrayHasKey(
'member',
$body,
'A Hydra collection response must expose its items under the member key.',
);
$names = array_column($body['member'], 'name');
self::assertContains('Salary', $names);
}
public function testPostCreatesAStreamWithoutABucketWhenAuthenticated(): void
{
$scenario = $this->persistScenario('Household Budget');
$this->login();
$this->client->request(
'POST',
'/api/streams',
server: [
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
content: json_encode([
'scenario' => $this->scenarioIri($scenario),
'name' => 'Salary',
'amount' => 300000,
'type' => StreamType::INCOME->value,
'frequency' => StreamFrequency::MONTHLY->value,
'startDate' => '2026-01-01',
]),
);
$response = $this->client->getResponse();
self::assertSame(
201,
$response->getStatusCode(),
'POST /api/streams with a valid body and no bucket must return 201 Created.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The created resource response must be a JSON-LD object.');
self::assertArrayHasKey('@id', $body, 'The created resource must expose its IRI under @id.');
self::assertSame('Salary', $body['name'] ?? null);
$this->em->clear();
$persisted = $this->em->getRepository(Stream::class)->findOneBy(['name' => 'Salary']);
self::assertNotNull(
$persisted,
'A successful POST /api/streams must persist the new Stream to the database.',
);
self::assertNull(
$persisted->getBucket(),
'A Stream posted without a bucket must persist with a null bucket relation.',
);
}
public function testPostCreatesAStreamWithABucketWhenAuthenticated(): void
{
$scenario = $this->persistScenario('Household Budget');
$bucket = $this->persistBucket($scenario, 'Groceries', 1);
$this->login();
$this->client->request(
'POST',
'/api/streams',
server: [
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
content: json_encode([
'scenario' => $this->scenarioIri($scenario),
'bucket' => $this->bucketIri($bucket),
'name' => 'Grocery Spend',
'amount' => 15000,
'type' => StreamType::EXPENSE->value,
'frequency' => StreamFrequency::WEEKLY->value,
'startDate' => '2026-01-01',
]),
);
$response = $this->client->getResponse();
self::assertSame(
201,
$response->getStatusCode(),
'POST /api/streams with a valid body including a bucket IRI must return 201 Created.',
);
$this->em->clear();
$persisted = $this->em->getRepository(Stream::class)->findOneBy(['name' => 'Grocery Spend']);
self::assertNotNull(
$persisted,
'A successful POST /api/streams must persist the new Stream to the database.',
);
self::assertNotNull(
$persisted->getBucket(),
'The persisted Stream must resolve its nullable bucket relation from the posted IRI.',
);
self::assertSame(
$bucket->getId()->toRfc4122(),
$persisted->getBucket()->getId()->toRfc4122(),
);
}
public function testPostWithMissingStartDateIsRejectedAsUnprocessable(): void
{
$scenario = $this->persistScenario('Household Budget');
$this->login();
$this->client->request(
'POST',
'/api/streams',
server: [
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
content: json_encode([
'scenario' => $this->scenarioIri($scenario),
'name' => 'Salary',
'amount' => 300000,
'type' => StreamType::INCOME->value,
'frequency' => StreamFrequency::MONTHLY->value,
]),
);
self::assertSame(
422,
$this->client->getResponse()->getStatusCode(),
'POST /api/streams without a startDate must return 422 Unprocessable Entity (validator-rejected, not a 500).',
);
}
public function testPostWithMissingNameIsRejectedAsUnprocessable(): void
{
$scenario = $this->persistScenario('Household Budget');
$this->login();
$this->client->request(
'POST',
'/api/streams',
server: [
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
content: json_encode([
'scenario' => $this->scenarioIri($scenario),
'amount' => 300000,
'type' => StreamType::INCOME->value,
'frequency' => StreamFrequency::MONTHLY->value,
'startDate' => '2026-01-01',
]),
);
self::assertSame(
422,
$this->client->getResponse()->getStatusCode(),
'POST /api/streams without a name must return 422 Unprocessable Entity (validator-rejected, not a 500).',
);
}
public function testGetItemIsRejectedWhenUnauthenticated(): void
{
$scenario = $this->persistScenario('Household Budget');
$stream = $this->persistStream($scenario, 'Salary');
$this->client->request('GET', '/api/streams/'.$stream->getId(), server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
self::assertSame(
401,
$this->client->getResponse()->getStatusCode(),
'GET /api/streams/{id} must require authentication.',
);
}
public function testPostIsRejectedWhenUnauthenticated(): void
{
$this->client->request(
'POST',
'/api/streams',
server: [
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
content: json_encode([
'name' => 'Salary',
'amount' => 300000,
'type' => StreamType::INCOME->value,
'frequency' => StreamFrequency::MONTHLY->value,
'startDate' => '2026-01-01',
]),
);
self::assertSame(
401,
$this->client->getResponse()->getStatusCode(),
'POST /api/streams must require authentication.',
);
}
public function testPostWithNonJsonContentTypeIsRejected(): void
{
$this->login();
$this->client->request(
'POST',
'/api/streams',
['name' => 'Salary'],
);
self::assertSame(
415,
$this->client->getResponse()->getStatusCode(),
'POST /api/streams without a JSON Content-Type must be rejected with 415 Unsupported Media Type.',
);
$this->em->clear();
$persisted = $this->em->getRepository(Stream::class)->findOneBy(['name' => 'Salary']);
self::assertNull(
$persisted,
'A rejected non-JSON POST must not persist a Stream.',
);
}
}

View file

@ -0,0 +1,125 @@
<?php
namespace App\Tests\Repository;
use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Enum\BucketAllocationType;
use App\Enum\BucketType;
use App\Repository\BucketRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class BucketRepositoryTest extends KernelTestCase
{
private EntityManagerInterface $em;
private BucketRepository $repository;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->repository = self::getContainer()->get(BucketRepository::class);
}
public function testCountIsZeroWhenScenarioHasNoOverflowBucket(): void
{
$scenario = $this->persistScenario();
$this->persistBucket($scenario, BucketType::NEED, 1);
$this->persistBucket($scenario, BucketType::WANT, 2);
self::assertSame(
0,
$this->repository->countOverflowBucketsForScenario($scenario, null),
'A scenario with no OVERFLOW bucket must count zero.',
);
}
public function testCountIsOneWhenScenarioHasOneOverflowBucket(): void
{
$scenario = $this->persistScenario();
$this->persistBucket($scenario, BucketType::NEED, 1);
$this->persistBucket($scenario, BucketType::OVERFLOW, 2);
self::assertSame(
1,
$this->repository->countOverflowBucketsForScenario($scenario, null),
'A scenario with exactly one OVERFLOW bucket must count one.',
);
}
public function testCountExcludesTheGivenBucketIdFromTheTally(): void
{
$scenario = $this->persistScenario();
$overflow = $this->persistBucket($scenario, BucketType::OVERFLOW, 1);
self::assertSame(
0,
$this->repository->countOverflowBucketsForScenario($scenario, $overflow->getId()),
'Excluding the existing overflow bucket\'s own id must drop the count to zero — '
.'this is what allows updating the sole overflow bucket without falsely tripping the rule.',
);
}
public function testCountIsScopedToTheGivenScenarioOnly(): void
{
$scenarioA = $this->persistScenario('Scenario A');
$scenarioB = $this->persistScenario('Scenario B');
$this->persistBucket($scenarioA, BucketType::OVERFLOW, 1);
self::assertSame(
0,
$this->repository->countOverflowBucketsForScenario($scenarioB, null),
'An OVERFLOW bucket belonging to a different scenario must not be counted.',
);
}
public function testNeedAndWantBucketsDoNotCountTowardTheOverflowTotal(): void
{
$scenario = $this->persistScenario();
$this->persistBucket($scenario, BucketType::NEED, 1);
$this->persistBucket($scenario, BucketType::WANT, 2);
self::assertSame(
0,
$this->repository->countOverflowBucketsForScenario($scenario, null),
'NEED and WANT buckets must never count toward the overflow tally.',
);
}
private function persistScenario(string $name = 'Household Budget'): Scenario
{
$scenario = new Scenario();
$scenario->setName($name);
$this->em->persist($scenario);
$this->em->flush();
return $scenario;
}
private function persistBucket(Scenario $scenario, BucketType $type, int $priority): Bucket
{
$bucket = new Bucket();
$bucket->setScenario($scenario);
$bucket->setType($type);
$bucket->setName('Bucket '.$priority);
$bucket->setPriority($priority);
$bucket->setSortOrder($priority);
$bucket->setAllocationType(
BucketType::OVERFLOW === $type ? BucketAllocationType::UNLIMITED : BucketAllocationType::FIXED_LIMIT,
);
$bucket->setAllocationValue(BucketType::OVERFLOW === $type ? null : 50000);
$bucket->setStartingAmount(0);
$bucket->setBufferMultiplier('0.00');
$this->em->persist($bucket);
$this->em->flush();
return $bucket;
}
}

View file

@ -0,0 +1,116 @@
<?php
namespace App\Tests\Validator;
use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Enum\BucketAllocationType;
use App\Enum\BucketType;
use App\Validator\BufferOnlyForFixedLimit;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class BufferOnlyForFixedLimitTest extends KernelTestCase
{
private ValidatorInterface $validator;
protected function setUp(): void
{
self::bootKernel();
$this->validator = self::getContainer()->get(ValidatorInterface::class);
}
public function testFixedLimitBucketWithNonZeroBufferIsValid(): void
{
$bucket = $this->makeBucket(BucketType::NEED, BucketAllocationType::FIXED_LIMIT, 50000, '1.50');
$violations = $this->validator->validate($bucket);
$matching = $this->violationsAt($violations, 'bufferMultiplier', BufferOnlyForFixedLimit::class);
self::assertCount(
0,
$matching,
'FIXED_LIMIT bucket with a non-zero buffer must not raise BufferOnlyForFixedLimit.',
);
}
public function testUnlimitedBucketWithNonZeroBufferIsInvalid(): void
{
$bucket = $this->makeBucket(BucketType::OVERFLOW, BucketAllocationType::UNLIMITED, null, '2.00');
$violations = $this->validator->validate($bucket);
$matching = $this->violationsAt($violations, 'bufferMultiplier', BufferOnlyForFixedLimit::class);
self::assertNotCount(
0,
$matching,
'UNLIMITED bucket with a non-zero buffer must raise a violation on bufferMultiplier.',
);
}
public function testNegativeBufferIsInvalid(): void
{
$bucket = $this->makeBucket(BucketType::NEED, BucketAllocationType::FIXED_LIMIT, 50000, '-1.00');
$violations = $this->validator->validate($bucket);
$matching = $this->violationsAt($violations, 'bufferMultiplier');
self::assertNotCount(
0,
$matching,
'A negative bufferMultiplier must raise a violation on bufferMultiplier (GreaterThanOrEqual(0)).',
);
}
private function makeBucket(
BucketType $type,
BucketAllocationType $allocationType,
?int $allocationValue,
string $bufferMultiplier,
): Bucket {
$scenario = new Scenario();
$scenario->setName('Household Budget');
$bucket = new Bucket();
$bucket->setScenario($scenario);
$bucket->setType($type);
$bucket->setName('Test Bucket');
$bucket->setPriority(1);
$bucket->setSortOrder(0);
$bucket->setAllocationType($allocationType);
$bucket->setAllocationValue($allocationValue);
$bucket->setStartingAmount(0);
$bucket->setBufferMultiplier($bufferMultiplier);
return $bucket;
}
/**
* @return list<\Symfony\Component\Validator\ConstraintViolationInterface>
*/
private function violationsAt(
ConstraintViolationListInterface $violations,
string $propertyPath,
?string $constraintClass = null,
): array {
$matching = [];
foreach ($violations as $violation) {
if ($violation->getPropertyPath() !== $propertyPath) {
continue;
}
if (null !== $constraintClass && !$violation->getConstraint() instanceof $constraintClass) {
continue;
}
$matching[] = $violation;
}
return $matching;
}
}

View file

@ -0,0 +1,102 @@
<?php
namespace App\Tests\Validator;
use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Enum\BucketAllocationType;
use App\Enum\BucketType;
use App\Validator\BufferOnlyForFixedLimit;
use App\Validator\BufferOnlyForFixedLimitValidator;
use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @extends ConstraintValidatorTestCase<BufferOnlyForFixedLimitValidator>
*/
final class BufferOnlyForFixedLimitValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): ConstraintValidatorInterface
{
return new BufferOnlyForFixedLimitValidator();
}
public function testFixedLimitWithNonZeroBufferIsValid(): void
{
$bucket = $this->makeBucket(BucketAllocationType::FIXED_LIMIT, '1.50');
$this->validator->validate($bucket, new BufferOnlyForFixedLimit());
$this->assertNoViolation();
}
public function testFixedLimitWithZeroBufferIsValid(): void
{
$bucket = $this->makeBucket(BucketAllocationType::FIXED_LIMIT, '0.00');
$this->validator->validate($bucket, new BufferOnlyForFixedLimit());
$this->assertNoViolation();
}
public function testPercentageWithNonZeroBufferIsInvalid(): void
{
$bucket = $this->makeBucket(BucketAllocationType::PERCENTAGE, '1.50');
$this->validator->validate($bucket, new BufferOnlyForFixedLimit());
$this->buildViolation('The buffer multiplier can only be set for fixed-limit buckets.')
->atPath('property.path.bufferMultiplier')
->setCode(BufferOnlyForFixedLimit::BUFFER_NOT_ALLOWED_ERROR)
->assertRaised();
}
public function testUnlimitedWithNonZeroBufferIsInvalid(): void
{
$bucket = $this->makeBucket(BucketAllocationType::UNLIMITED, '2.00');
$this->validator->validate($bucket, new BufferOnlyForFixedLimit());
$this->buildViolation('The buffer multiplier can only be set for fixed-limit buckets.')
->atPath('property.path.bufferMultiplier')
->setCode(BufferOnlyForFixedLimit::BUFFER_NOT_ALLOWED_ERROR)
->assertRaised();
}
public function testPercentageWithZeroBufferIsValid(): void
{
$bucket = $this->makeBucket(BucketAllocationType::PERCENTAGE, '0.00');
$this->validator->validate($bucket, new BufferOnlyForFixedLimit());
$this->assertNoViolation();
}
public function testPercentageWithZeroBufferWithoutDecimalsIsValid(): void
{
$bucket = $this->makeBucket(BucketAllocationType::PERCENTAGE, '0');
$this->validator->validate($bucket, new BufferOnlyForFixedLimit());
$this->assertNoViolation();
}
private function makeBucket(BucketAllocationType $allocationType, string $bufferMultiplier): Bucket
{
$scenario = new Scenario();
$scenario->setName('Household Budget');
$bucket = new Bucket();
$bucket->setScenario($scenario);
$bucket->setType(BucketType::NEED);
$bucket->setName('Test Bucket');
$bucket->setPriority(1);
$bucket->setSortOrder(0);
$bucket->setAllocationType($allocationType);
$bucket->setAllocationValue(BucketAllocationType::FIXED_LIMIT === $allocationType ? 50000 : null);
$bucket->setStartingAmount(0);
$bucket->setBufferMultiplier($bufferMultiplier);
return $bucket;
}
}

View file

@ -0,0 +1,124 @@
<?php
namespace App\Tests\Validator;
use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Enum\BucketAllocationType;
use App\Enum\BucketType;
use App\Validator\CompatibleAllocationType;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class CompatibleAllocationTypeTest extends KernelTestCase
{
private ValidatorInterface $validator;
protected function setUp(): void
{
self::bootKernel();
$this->validator = self::getContainer()->get(ValidatorInterface::class);
}
public function testOverflowBucketWithFixedLimitAllocationIsInvalid(): void
{
$bucket = $this->makeBucket(BucketType::OVERFLOW, BucketAllocationType::FIXED_LIMIT, 50000);
$violations = $this->validator->validate($bucket);
$matching = $this->violationsAt($violations, 'allocationType');
self::assertNotCount(
0,
$matching,
'OVERFLOW bucket with FIXED_LIMIT allocation must raise a violation on allocationType.',
);
}
public function testNeedBucketWithUnlimitedAllocationIsInvalid(): void
{
$bucket = $this->makeBucket(BucketType::NEED, BucketAllocationType::UNLIMITED, null);
$violations = $this->validator->validate($bucket);
$matching = $this->violationsAt($violations, 'allocationType');
self::assertNotCount(
0,
$matching,
'NEED bucket with UNLIMITED allocation must raise a violation on allocationType.',
);
}
public function testOverflowBucketWithUnlimitedAllocationIsValid(): void
{
$bucket = $this->makeBucket(BucketType::OVERFLOW, BucketAllocationType::UNLIMITED, null);
$violations = $this->validator->validate($bucket);
$matching = $this->violationsAt($violations, 'allocationType');
self::assertCount(
0,
$matching,
'OVERFLOW bucket with UNLIMITED allocation is the compatible pairing and must not raise this constraint.',
);
}
public function testNeedBucketWithFixedLimitAllocationIsValid(): void
{
$bucket = $this->makeBucket(BucketType::NEED, BucketAllocationType::FIXED_LIMIT, 50000);
$violations = $this->validator->validate($bucket);
$matching = $this->violationsAt($violations, 'allocationType');
self::assertCount(
0,
$matching,
'NEED bucket with FIXED_LIMIT allocation is a compatible pairing and must not raise this constraint.',
);
}
private function makeBucket(
BucketType $type,
BucketAllocationType $allocationType,
?int $allocationValue,
): Bucket {
$scenario = new Scenario();
$scenario->setName('Household Budget');
$bucket = new Bucket();
$bucket->setScenario($scenario);
$bucket->setType($type);
$bucket->setName('Test Bucket');
$bucket->setPriority(1);
$bucket->setSortOrder(0);
$bucket->setAllocationType($allocationType);
$bucket->setAllocationValue($allocationValue);
$bucket->setStartingAmount(0);
$bucket->setBufferMultiplier('0.00');
return $bucket;
}
/**
* @return list<\Symfony\Component\Validator\ConstraintViolationInterface>
*/
private function violationsAt(
\Symfony\Component\Validator\ConstraintViolationListInterface $violations,
string $propertyPath,
): array {
$matching = [];
foreach ($violations as $violation) {
if ($violation->getPropertyPath() === $propertyPath
&& $violation->getConstraint() instanceof CompatibleAllocationType
) {
$matching[] = $violation;
}
}
return $matching;
}
}

View file

@ -0,0 +1,135 @@
<?php
namespace App\Tests\Validator;
use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Enum\BucketAllocationType;
use App\Enum\BucketType;
use App\Validator\CompatibleAllocationType;
use App\Validator\CompatibleAllocationTypeValidator;
use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @extends ConstraintValidatorTestCase<CompatibleAllocationTypeValidator>
*/
final class CompatibleAllocationTypeValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): ConstraintValidatorInterface
{
return new CompatibleAllocationTypeValidator();
}
public function testOverflowWithUnlimitedIsValid(): void
{
$bucket = $this->makeBucket(BucketType::OVERFLOW, BucketAllocationType::UNLIMITED);
$this->validator->validate($bucket, new CompatibleAllocationType());
$this->assertNoViolation();
}
public function testOverflowWithFixedLimitIsInvalid(): void
{
$bucket = $this->makeBucket(BucketType::OVERFLOW, BucketAllocationType::FIXED_LIMIT);
$this->validator->validate($bucket, new CompatibleAllocationType());
$this->buildViolation('This allocation type is not compatible with the bucket type.')
->atPath('property.path.allocationType')
->setCode(CompatibleAllocationType::NOT_COMPATIBLE_ERROR)
->assertRaised();
}
public function testOverflowWithPercentageIsInvalid(): void
{
$bucket = $this->makeBucket(BucketType::OVERFLOW, BucketAllocationType::PERCENTAGE);
$this->validator->validate($bucket, new CompatibleAllocationType());
$this->buildViolation('This allocation type is not compatible with the bucket type.')
->atPath('property.path.allocationType')
->setCode(CompatibleAllocationType::NOT_COMPATIBLE_ERROR)
->assertRaised();
}
public function testNeedWithFixedLimitIsValid(): void
{
$bucket = $this->makeBucket(BucketType::NEED, BucketAllocationType::FIXED_LIMIT);
$this->validator->validate($bucket, new CompatibleAllocationType());
$this->assertNoViolation();
}
public function testNeedWithPercentageIsValid(): void
{
$bucket = $this->makeBucket(BucketType::NEED, BucketAllocationType::PERCENTAGE);
$this->validator->validate($bucket, new CompatibleAllocationType());
$this->assertNoViolation();
}
public function testNeedWithUnlimitedIsInvalid(): void
{
$bucket = $this->makeBucket(BucketType::NEED, BucketAllocationType::UNLIMITED);
$this->validator->validate($bucket, new CompatibleAllocationType());
$this->buildViolation('This allocation type is not compatible with the bucket type.')
->atPath('property.path.allocationType')
->setCode(CompatibleAllocationType::NOT_COMPATIBLE_ERROR)
->assertRaised();
}
public function testWantWithFixedLimitIsValid(): void
{
$bucket = $this->makeBucket(BucketType::WANT, BucketAllocationType::FIXED_LIMIT);
$this->validator->validate($bucket, new CompatibleAllocationType());
$this->assertNoViolation();
}
public function testWantWithPercentageIsValid(): void
{
$bucket = $this->makeBucket(BucketType::WANT, BucketAllocationType::PERCENTAGE);
$this->validator->validate($bucket, new CompatibleAllocationType());
$this->assertNoViolation();
}
public function testWantWithUnlimitedIsInvalid(): void
{
$bucket = $this->makeBucket(BucketType::WANT, BucketAllocationType::UNLIMITED);
$this->validator->validate($bucket, new CompatibleAllocationType());
$this->buildViolation('This allocation type is not compatible with the bucket type.')
->atPath('property.path.allocationType')
->setCode(CompatibleAllocationType::NOT_COMPATIBLE_ERROR)
->assertRaised();
}
private function makeBucket(BucketType $type, BucketAllocationType $allocationType): Bucket
{
$scenario = new Scenario();
$scenario->setName('Household Budget');
$bucket = new Bucket();
$bucket->setScenario($scenario);
$bucket->setType($type);
$bucket->setName('Test Bucket');
$bucket->setPriority(1);
$bucket->setSortOrder(0);
$bucket->setAllocationType($allocationType);
$bucket->setAllocationValue(null);
$bucket->setStartingAmount(0);
$bucket->setBufferMultiplier('0.00');
return $bucket;
}
}

View file

@ -0,0 +1,136 @@
<?php
namespace App\Tests\Validator;
use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Enum\BucketAllocationType;
use App\Enum\BucketType;
use App\Validator\SingleOverflowPerScenario;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class SingleOverflowPerScenarioTest extends KernelTestCase
{
private EntityManagerInterface $em;
private ValidatorInterface $validator;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->validator = self::getContainer()->get(ValidatorInterface::class);
}
public function testSecondOverflowBucketOnTheSameScenarioRaisesAViolation(): void
{
$scenario = new Scenario();
$scenario->setName('Household Budget');
$this->em->persist($scenario);
$this->em->flush();
$existingOverflow = $this->makeBucket($scenario, BucketType::OVERFLOW, 1);
$this->em->persist($existingOverflow);
$this->em->flush();
$secondOverflow = $this->makeBucket($scenario, BucketType::OVERFLOW, 2);
$violations = $this->validator->validate($secondOverflow);
$matching = $this->violationsAt($violations, 'type');
self::assertNotCount(
0,
$matching,
'A second OVERFLOW bucket on a scenario that already has one must raise a violation on type. '
.'The existing overflow bucket is persisted-but-uncommitted in this test\'s DAMA transaction — '
.'the validator\'s repository query must still see it within the same transaction.',
);
}
public function testTheSoleExistingOverflowBucketValidatesWithNoSingleOverflowViolation(): void
{
$scenario = new Scenario();
$scenario->setName('Household Budget');
$this->em->persist($scenario);
$this->em->flush();
$overflow = $this->makeBucket($scenario, BucketType::OVERFLOW, 1);
$this->em->persist($overflow);
$this->em->flush();
$violations = $this->validator->validate($overflow);
$matching = $this->violationsAt($violations, 'type');
self::assertCount(
0,
$matching,
'The single existing OVERFLOW bucket must not raise this constraint against itself '
.'(exclude-self-by-id must be working).',
);
}
public function testFirstOverflowBucketOnAScenarioWithNoOverflowBucketsIsValid(): void
{
$scenario = new Scenario();
$scenario->setName('Household Budget');
$this->em->persist($scenario);
$this->em->flush();
$needBucket = $this->makeBucket($scenario, BucketType::NEED, 1);
$this->em->persist($needBucket);
$this->em->flush();
$firstOverflow = $this->makeBucket($scenario, BucketType::OVERFLOW, 2);
$violations = $this->validator->validate($firstOverflow);
$matching = $this->violationsAt($violations, 'type');
self::assertCount(
0,
$matching,
'A brand-new, unpersisted OVERFLOW bucket on a scenario with zero existing OVERFLOW buckets '
.'must not raise this constraint — this is the common create-the-first-overflow-bucket flow.',
);
}
private function makeBucket(Scenario $scenario, BucketType $type, int $priority): Bucket
{
$bucket = new Bucket();
$bucket->setScenario($scenario);
$bucket->setType($type);
$bucket->setName('Bucket '.$priority);
$bucket->setPriority($priority);
$bucket->setSortOrder($priority);
$bucket->setAllocationType(
BucketType::OVERFLOW === $type ? BucketAllocationType::UNLIMITED : BucketAllocationType::FIXED_LIMIT,
);
$bucket->setAllocationValue(BucketType::OVERFLOW === $type ? null : 50000);
$bucket->setStartingAmount(0);
$bucket->setBufferMultiplier('0.00');
return $bucket;
}
/**
* @return list<\Symfony\Component\Validator\ConstraintViolationInterface>
*/
private function violationsAt(ConstraintViolationListInterface $violations, string $propertyPath): array
{
$matching = [];
foreach ($violations as $violation) {
if ($violation->getPropertyPath() === $propertyPath
&& $violation->getConstraint() instanceof SingleOverflowPerScenario
) {
$matching[] = $violation;
}
}
return $matching;
}
}

View file

@ -0,0 +1,97 @@
<?php
namespace App\Tests\Validator;
use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Enum\BucketAllocationType;
use App\Enum\BucketType;
use App\Repository\BucketRepository;
use App\Validator\SingleOverflowPerScenario;
use App\Validator\SingleOverflowPerScenarioValidator;
use PHPUnit\Framework\MockObject\Stub;
use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @extends ConstraintValidatorTestCase<SingleOverflowPerScenarioValidator>
*/
final class SingleOverflowPerScenarioValidatorTest extends ConstraintValidatorTestCase
{
private BucketRepository&Stub $bucketRepository;
protected function createValidator(): ConstraintValidatorInterface
{
$this->bucketRepository = $this->createStub(BucketRepository::class);
return new SingleOverflowPerScenarioValidator($this->bucketRepository);
}
public function testOverflowBucketIsValidWhenRepositoryReportsNoOtherOverflowBuckets(): void
{
$this->bucketRepository
->method('countOverflowBucketsForScenario')
->willReturn(0);
$bucket = $this->makeBucket(BucketType::OVERFLOW);
$this->validator->validate($bucket, new SingleOverflowPerScenario());
$this->assertNoViolation();
}
public function testOverflowBucketIsInvalidWhenRepositoryReportsAnExistingOverflowBucket(): void
{
$this->bucketRepository
->method('countOverflowBucketsForScenario')
->willReturn(1);
$bucket = $this->makeBucket(BucketType::OVERFLOW);
$this->validator->validate($bucket, new SingleOverflowPerScenario());
$this->buildViolation('A scenario can only have one overflow bucket.')
->atPath('property.path.type')
->setCode(SingleOverflowPerScenario::TOO_MANY_OVERFLOW_ERROR)
->assertRaised();
}
public function testNeedBucketIsValid(): void
{
$bucket = $this->makeBucket(BucketType::NEED);
$this->validator->validate($bucket, new SingleOverflowPerScenario());
$this->assertNoViolation();
}
public function testWantBucketIsValid(): void
{
$bucket = $this->makeBucket(BucketType::WANT);
$this->validator->validate($bucket, new SingleOverflowPerScenario());
$this->assertNoViolation();
}
private function makeBucket(BucketType $type): Bucket
{
$scenario = new Scenario();
$scenario->setName('Household Budget');
$bucket = new Bucket();
$bucket->setScenario($scenario);
$bucket->setType($type);
$bucket->setName('Test Bucket');
$bucket->setPriority(1);
$bucket->setSortOrder(0);
$bucket->setAllocationType(
BucketType::OVERFLOW === $type ? BucketAllocationType::UNLIMITED : BucketAllocationType::FIXED_LIMIT,
);
$bucket->setAllocationValue(BucketType::OVERFLOW === $type ? null : 50000);
$bucket->setStartingAmount(0);
$bucket->setBufferMultiplier('0.00');
return $bucket;
}
}