From a2d36eb4da65617af85e694d20ed3b4d60704c0e Mon Sep 17 00:00:00 2001 From: myrmidex Date: Fri, 19 Jun 2026 20:11:13 +0200 Subject: [PATCH] 31 - Add Stream entity with frequency and optional bucket link --- migrations/Version20260619175824.php | 35 +++++ src/Entity/Stream.php | 181 +++++++++++++++++++++++++ src/Repository/StreamRepository.php | 43 ++++++ tests/Entity/StreamPersistenceTest.php | 158 +++++++++++++++++++++ 4 files changed, 417 insertions(+) create mode 100644 migrations/Version20260619175824.php create mode 100644 src/Entity/Stream.php create mode 100644 src/Repository/StreamRepository.php create mode 100644 tests/Entity/StreamPersistenceTest.php diff --git a/migrations/Version20260619175824.php b/migrations/Version20260619175824.php new file mode 100644 index 0000000..4cadffd --- /dev/null +++ b/migrations/Version20260619175824.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE stream (id UUID NOT NULL, name VARCHAR(255) NOT NULL, amount INT NOT NULL, type VARCHAR(255) NOT NULL, is_active BOOLEAN DEFAULT true NOT NULL, frequency VARCHAR(255) NOT NULL, start_date DATE NOT NULL, end_date DATE DEFAULT NULL, description TEXT DEFAULT NULL, bucket_id UUID DEFAULT NULL, scenario_id UUID NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_F0E9BE1C84CE584D ON stream (bucket_id)'); + $this->addSql('CREATE INDEX IDX_F0E9BE1CE04E49DF ON stream (scenario_id)'); + $this->addSql('ALTER TABLE stream ADD CONSTRAINT FK_F0E9BE1C84CE584D FOREIGN KEY (bucket_id) REFERENCES bucket (id) ON DELETE SET NULL NOT DEFERRABLE'); + $this->addSql('ALTER TABLE stream ADD CONSTRAINT FK_F0E9BE1CE04E49DF FOREIGN KEY (scenario_id) REFERENCES scenario (id) ON DELETE CASCADE NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE stream DROP CONSTRAINT FK_F0E9BE1C84CE584D'); + $this->addSql('ALTER TABLE stream DROP CONSTRAINT FK_F0E9BE1CE04E49DF'); + $this->addSql('DROP TABLE stream'); + } +} diff --git a/src/Entity/Stream.php b/src/Entity/Stream.php new file mode 100644 index 0000000..ffbbf57 --- /dev/null +++ b/src/Entity/Stream.php @@ -0,0 +1,181 @@ + true])] + private bool $isActive = true; + + #[ORM\Column(enumType: StreamFrequency::class)] + private StreamFrequency $frequency; + + #[ORM\Column(type: Types::DATE_IMMUTABLE)] + private \DateTimeImmutable $startDate; + + #[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)] + private ?\DateTimeImmutable $endDate = null; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $description = null; + + public function __construct() + { + $this->id = new UuidV7(); + } + + public function getId(): UuidV7 + { + return $this->id; + } + + public function getBucket(): ?Bucket + { + return $this->bucket; + } + + public function getScenario(): Scenario + { + return $this->scenario; + } + + public function getName(): string + { + return $this->name; + } + + public function getAmount(): int + { + return $this->amount; + } + + public function getType(): StreamType + { + return $this->type; + } + + public function isActive(): bool + { + return $this->isActive; + } + + public function getFrequency(): StreamFrequency + { + return $this->frequency; + } + + public function getStartDate(): \DateTimeImmutable + { + return $this->startDate; + } + + public function getEndDate(): ?\DateTimeImmutable + { + return $this->endDate; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setBucket(?Bucket $bucket): static + { + $this->bucket = $bucket; + + return $this; + } + + public function setScenario(Scenario $scenario): static + { + $this->scenario = $scenario; + + return $this; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function setAmount(int $amount): static + { + $this->amount = $amount; + + return $this; + } + + public function setType(StreamType $type): static + { + $this->type = $type; + + return $this; + } + + public function setIsActive(bool $isActive): static + { + $this->isActive = $isActive; + + return $this; + } + + public function setFrequency(StreamFrequency $frequency): static + { + $this->frequency = $frequency; + + return $this; + } + + public function setStartDate(\DateTimeImmutable $startDate): static + { + $this->startDate = $startDate; + + return $this; + } + + public function setEndDate(?\DateTimeImmutable $endDate): static + { + $this->endDate = $endDate; + + return $this; + } + + public function setDescription(?string $description): static + { + $this->description = $description; + + return $this; + } +} diff --git a/src/Repository/StreamRepository.php b/src/Repository/StreamRepository.php new file mode 100644 index 0000000..65f38ae --- /dev/null +++ b/src/Repository/StreamRepository.php @@ -0,0 +1,43 @@ + + */ +class StreamRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Stream::class); + } + + // /** + // * @return Stream[] Returns an array of Stream objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('s') + // ->andWhere('s.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('s.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Stream + // { + // return $this->createQueryBuilder('s') + // ->andWhere('s.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/tests/Entity/StreamPersistenceTest.php b/tests/Entity/StreamPersistenceTest.php new file mode 100644 index 0000000..c94dbb1 --- /dev/null +++ b/tests/Entity/StreamPersistenceTest.php @@ -0,0 +1,158 @@ +em = self::getContainer()->get(EntityManagerInterface::class); + } + + public function testItPersistsAStreamLinkedToItsScenarioAndBucket(): void + { + $scenario = new Scenario(); + $scenario->setName('Household Budget'); + $this->em->persist($scenario); + + $bucket = new Bucket(); + $bucket->setScenario($scenario); + $bucket->setType(BucketType::NEED); + $bucket->setName('Rent'); + $bucket->setPriority(1); + $bucket->setSortOrder(0); + $bucket->setAllocationType(BucketAllocationType::FIXED_LIMIT); + $bucket->setAllocationValue(150000); + $bucket->setStartingAmount(0); + $bucket->setBufferMultiplier('0.00'); + $this->em->persist($bucket); + + $this->em->flush(); + + $scenarioId = $scenario->getId(); + $bucketId = $bucket->getId(); + + $stream = new Stream(); + $stream->setScenario($scenario); + $stream->setBucket($bucket); + $stream->setName('Paycheck'); + $stream->setAmount(250000); + $stream->setType(StreamType::INCOME); + $stream->setFrequency(StreamFrequency::BIWEEKLY); + $stream->setStartDate(new \DateTimeImmutable('2026-01-01')); + $stream->setEndDate(new \DateTimeImmutable('2026-12-31')); + $stream->setIsActive(true); + $stream->setDescription('Primary employer income.'); + + $this->em->persist($stream); + $this->em->flush(); + + $streamId = $stream->getId(); + + $this->em->clear(); + + $reloaded = $this->em->find(Stream::class, $streamId); + + self::assertNotNull($reloaded, 'Stream must be retrievable from the database after an identity-map clear.'); + + self::assertInstanceOf(Scenario::class, $reloaded->getScenario()); + self::assertSame( + (string) $scenarioId, + (string) $reloaded->getScenario()->getId(), + 'Reloaded stream must be linked to the same scenario it was persisted with.', + ); + + self::assertInstanceOf(Bucket::class, $reloaded->getBucket()); + self::assertSame( + (string) $bucketId, + (string) $reloaded->getBucket()->getId(), + 'Reloaded stream must be linked to the same bucket it was persisted with.', + ); + + self::assertSame('Paycheck', $reloaded->getName()); + + self::assertSame( + 250000, + $reloaded->getAmount(), + 'amount must round-trip as an int (cents), not a string or float.', + ); + + self::assertInstanceOf( + StreamType::class, + $reloaded->getType(), + 'type must hydrate as a StreamType enum instance, not a raw string.', + ); + self::assertSame(StreamType::INCOME, $reloaded->getType()); + + self::assertInstanceOf( + StreamFrequency::class, + $reloaded->getFrequency(), + 'frequency must hydrate as a StreamFrequency enum instance, not a raw string.', + ); + self::assertSame(StreamFrequency::BIWEEKLY, $reloaded->getFrequency()); + + self::assertInstanceOf(\DateTimeImmutable::class, $reloaded->getStartDate()); + self::assertSame('2026-01-01', $reloaded->getStartDate()->format('Y-m-d')); + + self::assertInstanceOf(\DateTimeImmutable::class, $reloaded->getEndDate()); + self::assertSame('2026-12-31', $reloaded->getEndDate()->format('Y-m-d')); + + self::assertTrue($reloaded->isActive()); + + self::assertSame('Primary employer income.', $reloaded->getDescription()); + } + + public function testItPersistsAStreamWithoutABucket(): void + { + $scenario = new Scenario(); + $scenario->setName('Household Budget'); + $this->em->persist($scenario); + $this->em->flush(); + + $stream = new Stream(); + $stream->setScenario($scenario); + $stream->setName('Freelance Income'); + $stream->setAmount(50000); + $stream->setType(StreamType::INCOME); + $stream->setFrequency(StreamFrequency::MONTHLY); + $stream->setStartDate(new \DateTimeImmutable('2026-02-01')); + + $this->em->persist($stream); + $this->em->flush(); + + $streamId = $stream->getId(); + + $this->em->clear(); + + $reloaded = $this->em->find(Stream::class, $streamId); + + self::assertNotNull($reloaded, 'Stream must be retrievable from the database after an identity-map clear.'); + self::assertNull( + $reloaded->getBucket(), + 'bucket must be nullable — a stream is not required to link to a bucket.', + ); + + self::assertNull( + $reloaded->getEndDate(), + 'end_date must be nullable when not explicitly set.', + ); + + self::assertTrue( + $reloaded->isActive(), + 'is_active must default to true when setIsActive() is never called.', + ); + } +}