31 - Add allocation-type invariant

This commit is contained in:
myrmidex 2026-06-19 23:38:24 +02:00
parent a2d36eb4da
commit 7855e41e84
7 changed files with 422 additions and 2 deletions

View file

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

View file

@ -5,12 +5,14 @@ namespace App\Entity;
use App\Enum\BucketAllocationType; use App\Enum\BucketAllocationType;
use App\Enum\BucketType; use App\Enum\BucketType;
use App\Repository\BucketRepository; use App\Repository\BucketRepository;
use App\Validator\CompatibleAllocationType;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\UuidV7; use Symfony\Component\Uid\UuidV7;
#[ORM\Entity(repositoryClass: BucketRepository::class)] #[ORM\Entity(repositoryClass: BucketRepository::class)]
#[ORM\Table(name: 'bucket')] #[ORM\Table(name: 'bucket')]
#[ORM\UniqueConstraint(name: 'unique_scenario_priority', columns: ['scenario_id', 'priority'])] #[ORM\UniqueConstraint(name: 'unique_scenario_priority', columns: ['scenario_id', 'priority'])]
#[CompatibleAllocationType]
class Bucket class Bucket
{ {
#[ORM\Id] #[ORM\Id]
@ -33,8 +35,8 @@ class Bucket
#[ORM\Column(options: ['default' => 0])] #[ORM\Column(options: ['default' => 0])]
private int $sortOrder = 0; private int $sortOrder = 0;
#[ORM\Column(enumType: BucketAllocationType::class)] #[ORM\Column(enumType: BucketAllocationType::class, options: ['default' => BucketAllocationType::FIXED_LIMIT])]
private BucketAllocationType $allocationType; private BucketAllocationType $allocationType = BucketAllocationType::FIXED_LIMIT;
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?int $allocationValue = null; private ?int $allocationValue = null;

View file

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

View file

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

View file

@ -9,6 +9,7 @@ use App\Enum\BucketType;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Uid\UuidV7;
final class BucketPersistenceTest extends KernelTestCase final class BucketPersistenceTest extends KernelTestCase
{ {
@ -90,6 +91,85 @@ final class BucketPersistenceTest extends KernelTestCase
); );
} }
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 public function testScenarioPriorityUniquenessIsEnforced(): void
{ {
$scenario = new Scenario(); $scenario = new Scenario();

View file

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

View file

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