Compare commits
11 commits
1b1d9db626
...
bebc0427b9
| Author | SHA1 | Date | |
|---|---|---|---|
| bebc0427b9 | |||
| 1a90a18b49 | |||
| e4ae6dabf6 | |||
| a2663be938 | |||
| 3163a95f37 | |||
| 7855e41e84 | |||
| a2d36eb4da | |||
| 016d4f497e | |||
| 7b87c6d665 | |||
| cda83371c4 | |||
| 59807d8e33 |
38 changed files with 3446 additions and 2 deletions
|
|
@ -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']
|
||||
|
|
|
|||
29
migrations/Version20260618213556.php
Normal file
29
migrations/Version20260618213556.php
Normal 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');
|
||||
}
|
||||
}
|
||||
33
migrations/Version20260618224747.php
Normal file
33
migrations/Version20260618224747.php
Normal 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');
|
||||
}
|
||||
}
|
||||
35
migrations/Version20260619175824.php
Normal file
35
migrations/Version20260619175824.php
Normal 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');
|
||||
}
|
||||
}
|
||||
29
migrations/Version20260619212401.php
Normal file
29
migrations/Version20260619212401.php
Normal 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');
|
||||
}
|
||||
}
|
||||
41
shell.nix
41
shell.nix
|
|
@ -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
183
src/Entity/Bucket.php
Normal 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
82
src/Entity/Scenario.php
Normal 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
195
src/Entity/Stream.php
Normal 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;
|
||||
}
|
||||
}
|
||||
10
src/Enum/BucketAllocationType.php
Normal file
10
src/Enum/BucketAllocationType.php
Normal 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
26
src/Enum/BucketType.php
Normal 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,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
9
src/Enum/DistributionMode.php
Normal file
9
src/Enum/DistributionMode.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum DistributionMode: string
|
||||
{
|
||||
case EVEN = 'even';
|
||||
case PRIORITY = 'priority';
|
||||
}
|
||||
14
src/Enum/StreamFrequency.php
Normal file
14
src/Enum/StreamFrequency.php
Normal 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
9
src/Enum/StreamType.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum StreamType: string
|
||||
{
|
||||
case INCOME = 'income';
|
||||
case EXPENSE = 'expense';
|
||||
}
|
||||
38
src/Repository/BucketRepository.php
Normal file
38
src/Repository/BucketRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
18
src/Repository/ScenarioRepository.php
Normal file
18
src/Repository/ScenarioRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
18
src/Repository/StreamRepository.php
Normal file
18
src/Repository/StreamRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
18
src/Validator/BufferOnlyForFixedLimit.php
Normal file
18
src/Validator/BufferOnlyForFixedLimit.php
Normal 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;
|
||||
}
|
||||
}
|
||||
34
src/Validator/BufferOnlyForFixedLimitValidator.php
Normal file
34
src/Validator/BufferOnlyForFixedLimitValidator.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/Validator/CompatibleAllocationType.php
Normal file
18
src/Validator/CompatibleAllocationType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
32
src/Validator/CompatibleAllocationTypeValidator.php
Normal file
32
src/Validator/CompatibleAllocationTypeValidator.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/Validator/SingleOverflowPerScenario.php
Normal file
18
src/Validator/SingleOverflowPerScenario.php
Normal 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;
|
||||
}
|
||||
}
|
||||
44
src/Validator/SingleOverflowPerScenarioValidator.php
Normal file
44
src/Validator/SingleOverflowPerScenarioValidator.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
208
tests/Entity/BucketPersistenceTest.php
Normal file
208
tests/Entity/BucketPersistenceTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
127
tests/Entity/ScenarioPersistenceTest.php
Normal file
127
tests/Entity/ScenarioPersistenceTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
158
tests/Entity/StreamPersistenceTest.php
Normal file
158
tests/Entity/StreamPersistenceTest.php
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
32
tests/Enum/BucketTypeTest.php
Normal file
32
tests/Enum/BucketTypeTest.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
36
tests/Enum/RemainingEnumsTest.php
Normal file
36
tests/Enum/RemainingEnumsTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
471
tests/Functional/BucketApiTest.php
Normal file
471
tests/Functional/BucketApiTest.php
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
259
tests/Functional/ScenarioApiTest.php
Normal file
259
tests/Functional/ScenarioApiTest.php
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
383
tests/Functional/StreamApiTest.php
Normal file
383
tests/Functional/StreamApiTest.php
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
125
tests/Repository/BucketRepositoryTest.php
Normal file
125
tests/Repository/BucketRepositoryTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
116
tests/Validator/BufferOnlyForFixedLimitTest.php
Normal file
116
tests/Validator/BufferOnlyForFixedLimitTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
102
tests/Validator/BufferOnlyForFixedLimitValidatorTest.php
Normal file
102
tests/Validator/BufferOnlyForFixedLimitValidatorTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
124
tests/Validator/CompatibleAllocationTypeTest.php
Normal file
124
tests/Validator/CompatibleAllocationTypeTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
135
tests/Validator/CompatibleAllocationTypeValidatorTest.php
Normal file
135
tests/Validator/CompatibleAllocationTypeValidatorTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
136
tests/Validator/SingleOverflowPerScenarioTest.php
Normal file
136
tests/Validator/SingleOverflowPerScenarioTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
97
tests/Validator/SingleOverflowPerScenarioValidatorTest.php
Normal file
97
tests/Validator/SingleOverflowPerScenarioValidatorTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue