31 - Add Stream entity with frequency and optional bucket link
This commit is contained in:
parent
016d4f497e
commit
a2d36eb4da
4 changed files with 417 additions and 0 deletions
35
migrations/Version20260619175824.php
Normal file
35
migrations/Version20260619175824.php
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260619175824 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/Entity/Stream.php
Normal file
181
src/Entity/Stream.php
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Enum\StreamFrequency;
|
||||||
|
use App\Enum\StreamType;
|
||||||
|
use App\Repository\StreamRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Uid\UuidV7;
|
||||||
|
|
||||||
|
#[ORM\Table(name: 'stream')]
|
||||||
|
#[ORM\Entity(repositoryClass: StreamRepository::class)]
|
||||||
|
class Stream
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\Column(type: 'uuid')]
|
||||||
|
private UuidV7 $id;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Bucket::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
private ?Bucket $bucket;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Scenario::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private Scenario $scenario;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private string $name;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $amount;
|
||||||
|
|
||||||
|
#[ORM\Column(enumType: StreamType::class)]
|
||||||
|
private StreamType $type;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Repository/StreamRepository.php
Normal file
43
src/Repository/StreamRepository.php
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Stream;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Stream>
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
// ;
|
||||||
|
// }
|
||||||
|
}
|
||||||
158
tests/Entity/StreamPersistenceTest.php
Normal file
158
tests/Entity/StreamPersistenceTest.php
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Entity;
|
||||||
|
|
||||||
|
use App\Entity\Bucket;
|
||||||
|
use App\Entity\Scenario;
|
||||||
|
use App\Entity\Stream;
|
||||||
|
use App\Enum\BucketAllocationType;
|
||||||
|
use App\Enum\BucketType;
|
||||||
|
use App\Enum\StreamFrequency;
|
||||||
|
use App\Enum\StreamType;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
|
||||||
|
final class StreamPersistenceTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$this->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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue