31 - Add single-overflow-per-scenario invariant
This commit is contained in:
parent
7855e41e84
commit
3163a95f37
7 changed files with 440 additions and 23 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<Bucket>
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
src/Validator/SingleOverflowPerScenario.php
Normal file
18
src/Validator/SingleOverflowPerScenario.php
Normal 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;
|
||||
}
|
||||
}
|
||||
44
src/Validator/SingleOverflowPerScenarioValidator.php
Normal file
44
src/Validator/SingleOverflowPerScenarioValidator.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
125
tests/Repository/BucketRepositoryTest.php
Normal file
125
tests/Repository/BucketRepositoryTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
136
tests/Validator/SingleOverflowPerScenarioTest.php
Normal file
136
tests/Validator/SingleOverflowPerScenarioTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
97
tests/Validator/SingleOverflowPerScenarioValidatorTest.php
Normal file
97
tests/Validator/SingleOverflowPerScenarioValidatorTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue