diff --git a/src/Entity/Bucket.php b/src/Entity/Bucket.php index 6d6811d..4ce047f 100644 --- a/src/Entity/Bucket.php +++ b/src/Entity/Bucket.php @@ -6,6 +6,7 @@ use App\Enum\BucketAllocationType; use App\Enum\BucketType; use App\Repository\BucketRepository; use App\Validator\CompatibleAllocationType; +use App\Validator\SingleOverflowPerScenario; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\UuidV7; @@ -13,6 +14,7 @@ use Symfony\Component\Uid\UuidV7; #[ORM\Table(name: 'bucket')] #[ORM\UniqueConstraint(name: 'unique_scenario_priority', columns: ['scenario_id', 'priority'])] #[CompatibleAllocationType] +#[SingleOverflowPerScenario] class Bucket { #[ORM\Id] diff --git a/src/Repository/BucketRepository.php b/src/Repository/BucketRepository.php index e3794f3..16eff92 100644 --- a/src/Repository/BucketRepository.php +++ b/src/Repository/BucketRepository.php @@ -3,8 +3,11 @@ 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 @@ -16,28 +19,20 @@ class BucketRepository extends ServiceEntityRepository parent::__construct($registry, Bucket::class); } - // /** - // * @return Bucket[] Returns an array of Bucket objects - // */ - // public function findByExampleField($value): array - // { - // return $this->createQueryBuilder('b') - // ->andWhere('b.exampleField = :val') - // ->setParameter('val', $value) - // ->orderBy('b.id', 'ASC') - // ->setMaxResults(10) - // ->getQuery() - // ->getResult() - // ; - // } + 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); - // public function findOneBySomeField($value): ?Bucket - // { - // return $this->createQueryBuilder('b') - // ->andWhere('b.exampleField = :val') - // ->setParameter('val', $value) - // ->getQuery() - // ->getOneOrNullResult() - // ; - // } + if (null !== $excludeId) { + $qb->andWhere('b.id != :excludeId') + ->setParameter('excludeId', $excludeId, 'uuid'); + } + + return (int) $qb->getQuery()->getSingleScalarResult(); + } } diff --git a/src/Validator/SingleOverflowPerScenario.php b/src/Validator/SingleOverflowPerScenario.php new file mode 100644 index 0000000..605935a --- /dev/null +++ b/src/Validator/SingleOverflowPerScenario.php @@ -0,0 +1,18 @@ +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(); + } + } +} diff --git a/tests/Repository/BucketRepositoryTest.php b/tests/Repository/BucketRepositoryTest.php new file mode 100644 index 0000000..6f9c2d5 --- /dev/null +++ b/tests/Repository/BucketRepositoryTest.php @@ -0,0 +1,125 @@ +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; + } +} diff --git a/tests/Validator/SingleOverflowPerScenarioTest.php b/tests/Validator/SingleOverflowPerScenarioTest.php new file mode 100644 index 0000000..1d07767 --- /dev/null +++ b/tests/Validator/SingleOverflowPerScenarioTest.php @@ -0,0 +1,136 @@ +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; + } +} diff --git a/tests/Validator/SingleOverflowPerScenarioValidatorTest.php b/tests/Validator/SingleOverflowPerScenarioValidatorTest.php new file mode 100644 index 0000000..191b448 --- /dev/null +++ b/tests/Validator/SingleOverflowPerScenarioValidatorTest.php @@ -0,0 +1,97 @@ + + */ +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; + } +}