diff --git a/migrations/Version20260619212401.php b/migrations/Version20260619212401.php new file mode 100644 index 0000000..dec3a7d --- /dev/null +++ b/migrations/Version20260619212401.php @@ -0,0 +1,29 @@ +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'); + } +} diff --git a/src/Entity/Bucket.php b/src/Entity/Bucket.php index 5084446..6d6811d 100644 --- a/src/Entity/Bucket.php +++ b/src/Entity/Bucket.php @@ -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; diff --git a/src/Validator/CompatibleAllocationType.php b/src/Validator/CompatibleAllocationType.php new file mode 100644 index 0000000..3a2a08c --- /dev/null +++ b/src/Validator/CompatibleAllocationType.php @@ -0,0 +1,18 @@ +getType(); + $allocationType = $value->getAllocationType(); + + if (!\in_array($allocationType, $type->getAllowedAllocationTypes(), true)) { + $this->context->buildViolation($constraint->message) + ->atPath('allocationType') + ->setCode(CompatibleAllocationType::NOT_COMPATIBLE_ERROR) + ->addViolation(); + } + } +} diff --git a/tests/Entity/BucketPersistenceTest.php b/tests/Entity/BucketPersistenceTest.php index b93b0b6..da4cf94 100644 --- a/tests/Entity/BucketPersistenceTest.php +++ b/tests/Entity/BucketPersistenceTest.php @@ -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(); diff --git a/tests/Validator/CompatibleAllocationTypeTest.php b/tests/Validator/CompatibleAllocationTypeTest.php new file mode 100644 index 0000000..b4829e6 --- /dev/null +++ b/tests/Validator/CompatibleAllocationTypeTest.php @@ -0,0 +1,124 @@ +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; + } +} diff --git a/tests/Validator/CompatibleAllocationTypeValidatorTest.php b/tests/Validator/CompatibleAllocationTypeValidatorTest.php new file mode 100644 index 0000000..6593775 --- /dev/null +++ b/tests/Validator/CompatibleAllocationTypeValidatorTest.php @@ -0,0 +1,135 @@ + + */ +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; + } +}