31 - Add single-overflow-per-scenario invariant

This commit is contained in:
myrmidex 2026-06-20 00:28:29 +02:00
parent 7855e41e84
commit 3163a95f37
7 changed files with 440 additions and 23 deletions

View file

@ -6,6 +6,7 @@ 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 App\Validator\CompatibleAllocationType;
use App\Validator\SingleOverflowPerScenario;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\UuidV7; use Symfony\Component\Uid\UuidV7;
@ -13,6 +14,7 @@ use Symfony\Component\Uid\UuidV7;
#[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] #[CompatibleAllocationType]
#[SingleOverflowPerScenario]
class Bucket class Bucket
{ {
#[ORM\Id] #[ORM\Id]

View file

@ -3,8 +3,11 @@
namespace App\Repository; namespace App\Repository;
use App\Entity\Bucket; use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Enum\BucketType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Uid\UuidV7;
/** /**
* @extends ServiceEntityRepository<Bucket> * @extends ServiceEntityRepository<Bucket>
@ -16,28 +19,20 @@ class BucketRepository extends ServiceEntityRepository
parent::__construct($registry, Bucket::class); parent::__construct($registry, Bucket::class);
} }
// /** public function countOverflowBucketsForScenario(Scenario $scenario, ?UuidV7 $excludeId = null): int
// * @return Bucket[] Returns an array of Bucket objects {
// */ $qb = $this->createQueryBuilder('b')
// public function findByExampleField($value): array ->select('COUNT(b.id)')
// { ->andWhere('b.scenario = :scenario')
// return $this->createQueryBuilder('b') ->andWhere('b.type = :overflow')
// ->andWhere('b.exampleField = :val') ->setParameter('scenario', $scenario)
// ->setParameter('val', $value) ->setParameter('overflow', BucketType::OVERFLOW);
// ->orderBy('b.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?Bucket if (null !== $excludeId) {
// { $qb->andWhere('b.id != :excludeId')
// return $this->createQueryBuilder('b') ->setParameter('excludeId', $excludeId, 'uuid');
// ->andWhere('b.exampleField = :val') }
// ->setParameter('val', $value)
// ->getQuery() return (int) $qb->getQuery()->getSingleScalarResult();
// ->getOneOrNullResult() }
// ;
// }
} }

View 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;
}
}

View 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();
}
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}