31 - Add allocation-type invariant
This commit is contained in:
parent
a2d36eb4da
commit
7855e41e84
7 changed files with 422 additions and 2 deletions
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');
|
||||
}
|
||||
}
|
||||
|
|
@ -5,12 +5,14 @@ namespace App\Entity;
|
|||
use App\Enum\BucketAllocationType;
|
||||
use App\Enum\BucketType;
|
||||
use App\Repository\BucketRepository;
|
||||
use App\Validator\CompatibleAllocationType;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\UuidV7;
|
||||
|
||||
#[ORM\Entity(repositoryClass: BucketRepository::class)]
|
||||
#[ORM\Table(name: 'bucket')]
|
||||
#[ORM\UniqueConstraint(name: 'unique_scenario_priority', columns: ['scenario_id', 'priority'])]
|
||||
#[CompatibleAllocationType]
|
||||
class Bucket
|
||||
{
|
||||
#[ORM\Id]
|
||||
|
|
@ -33,8 +35,8 @@ class Bucket
|
|||
#[ORM\Column(options: ['default' => 0])]
|
||||
private int $sortOrder = 0;
|
||||
|
||||
#[ORM\Column(enumType: BucketAllocationType::class)]
|
||||
private BucketAllocationType $allocationType;
|
||||
#[ORM\Column(enumType: BucketAllocationType::class, options: ['default' => BucketAllocationType::FIXED_LIMIT])]
|
||||
private BucketAllocationType $allocationType = BucketAllocationType::FIXED_LIMIT;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $allocationValue = null;
|
||||
|
|
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ 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
|
||||
{
|
||||
|
|
@ -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
|
||||
{
|
||||
$scenario = new Scenario();
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue