From 1589088379c58fb95d6a768e9514b659a8af8687 Mon Sep 17 00:00:00 2001 From: Jens Date: Sat, 19 Sep 2026 08:55:54 +0200 Subject: [PATCH] =?UTF-8?q?M1:=20CRUD-Slice=20'Mahlzeit'=20Schritt=201=20?= =?UTF-8?q?=E2=80=93=20Logik=20+=20Daten=20+=20Tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rich-Model Mahlzeit (MahlzeitStatus, Wochentag) mit Invarianten und Zustandsübergängen; DTOs + MahlzeitFactory (reine Mapping-Funktionen); Manager (create hängt an Ende an: maxSortOrder+1, find → 404) und Create/Update/Delete-UseCases mit Transaktion + Events nach Commit. Data-Layer: MahlzeitEntity (Tabelle essens_mahlzeiten, wochentage als CSV), Mapper (CSV/Enum/null), Provider (find/list/maxSortOrder), Processor (create/save/delete). DI-Bindings in services.yaml. ClockInterface (Logic) + SystemClock (Data). Tests: Model/Factory/UseCase-Unit + Provider/Processor/Mapper-Integration (In-Mem-SQLite). composer test grün (53 Tests, 161 Assertions). --- Doku/architektur.md | 282 ++++++++++-------- Doku/plan.md | 17 +- config/services.yaml | 7 + migrations/Version20260918213517.php | 33 ++ src/Data/Essen/Entity/MahlzeitEntity.php | 151 ++++++++++ src/Data/Essen/Mapper/MahlzeitMapper.php | 88 ++++++ .../Essen/Processor/MahlzeitProcessor.php | 63 ++++ src/Data/Essen/Provider/MahlzeitProvider.php | 65 ++++ src/Data/Infrastructure/SystemClock.php | 18 ++ src/Logic/Common/ClockInterface.php | 13 + src/Logic/Essen/Dto/MahlzeitCreateRequest.php | 26 ++ .../Essen/Dto/MahlzeitCreateResponse.php | 23 ++ src/Logic/Essen/Dto/MahlzeitListRequest.php | 19 ++ src/Logic/Essen/Dto/MahlzeitListResponse.php | 30 ++ src/Logic/Essen/Dto/MahlzeitResponse.php | 44 +++ src/Logic/Essen/Dto/MahlzeitUpdateRequest.php | 23 ++ .../Essen/Dto/MahlzeitUpdateResponse.php | 23 ++ .../Essen/Event/MahlzeitCreatedEvent.php | 18 ++ .../Essen/Event/MahlzeitDeletedEvent.php | 17 ++ .../Essen/Event/MahlzeitUpdatedEvent.php | 17 ++ .../Exception/MahlzeitNotFoundException.php | 26 ++ .../Essen/MahlzeitProcessorInterface.php | 23 ++ src/Logic/Essen/MahlzeitProviderInterface.php | 25 ++ src/Logic/Essen/Manager/MahlzeitManager.php | 57 ++++ .../Manager/MahlzeitManagerInterface.php | 29 ++ src/Logic/Essen/Mapping/MahlzeitFactory.php | 67 +++++ src/Logic/Essen/Model/Mahlzeit.php | 232 ++++++++++++++ src/Logic/Essen/Model/MahlzeitStatus.php | 11 + src/Logic/Essen/Model/Wochentag.php | 53 ++++ src/Logic/Essen/Query/MahlzeitListQuery.php | 28 ++ .../Essen/UseCase/MahlzeitCreateUseCase.php | 44 +++ .../Essen/UseCase/MahlzeitDeleteUseCase.php | 34 +++ .../Essen/UseCase/MahlzeitUpdateUseCase.php | 44 +++ .../Data/Essen/DatabaseTestCase.php | 43 +++ .../Data/Essen/MahlzeitMapperTest.php | 151 ++++++++++ .../Data/Essen/MahlzeitProcessorTest.php | 95 ++++++ .../Data/Essen/MahlzeitProviderTest.php | 97 ++++++ .../Essen/Mapping/MahlzeitFactoryTest.php | 111 +++++++ tests/Unit/Logic/Essen/Model/MahlzeitTest.php | 157 ++++++++++ .../UseCase/MahlzeitCreateUseCaseTest.php | 112 +++++++ .../UseCase/MahlzeitDeleteUseCaseTest.php | 69 +++++ .../UseCase/MahlzeitUpdateUseCaseTest.php | 146 +++++++++ 42 files changed, 2493 insertions(+), 138 deletions(-) create mode 100644 migrations/Version20260918213517.php create mode 100644 src/Data/Essen/Entity/MahlzeitEntity.php create mode 100644 src/Data/Essen/Mapper/MahlzeitMapper.php create mode 100644 src/Data/Essen/Processor/MahlzeitProcessor.php create mode 100644 src/Data/Essen/Provider/MahlzeitProvider.php create mode 100644 src/Data/Infrastructure/SystemClock.php create mode 100644 src/Logic/Common/ClockInterface.php create mode 100644 src/Logic/Essen/Dto/MahlzeitCreateRequest.php create mode 100644 src/Logic/Essen/Dto/MahlzeitCreateResponse.php create mode 100644 src/Logic/Essen/Dto/MahlzeitListRequest.php create mode 100644 src/Logic/Essen/Dto/MahlzeitListResponse.php create mode 100644 src/Logic/Essen/Dto/MahlzeitResponse.php create mode 100644 src/Logic/Essen/Dto/MahlzeitUpdateRequest.php create mode 100644 src/Logic/Essen/Dto/MahlzeitUpdateResponse.php create mode 100644 src/Logic/Essen/Event/MahlzeitCreatedEvent.php create mode 100644 src/Logic/Essen/Event/MahlzeitDeletedEvent.php create mode 100644 src/Logic/Essen/Event/MahlzeitUpdatedEvent.php create mode 100644 src/Logic/Essen/Exception/MahlzeitNotFoundException.php create mode 100644 src/Logic/Essen/MahlzeitProcessorInterface.php create mode 100644 src/Logic/Essen/MahlzeitProviderInterface.php create mode 100644 src/Logic/Essen/Manager/MahlzeitManager.php create mode 100644 src/Logic/Essen/Manager/MahlzeitManagerInterface.php create mode 100644 src/Logic/Essen/Mapping/MahlzeitFactory.php create mode 100644 src/Logic/Essen/Model/Mahlzeit.php create mode 100644 src/Logic/Essen/Model/MahlzeitStatus.php create mode 100644 src/Logic/Essen/Model/Wochentag.php create mode 100644 src/Logic/Essen/Query/MahlzeitListQuery.php create mode 100644 src/Logic/Essen/UseCase/MahlzeitCreateUseCase.php create mode 100644 src/Logic/Essen/UseCase/MahlzeitDeleteUseCase.php create mode 100644 src/Logic/Essen/UseCase/MahlzeitUpdateUseCase.php create mode 100644 tests/Integration/Data/Essen/DatabaseTestCase.php create mode 100644 tests/Integration/Data/Essen/MahlzeitMapperTest.php create mode 100644 tests/Integration/Data/Essen/MahlzeitProcessorTest.php create mode 100644 tests/Integration/Data/Essen/MahlzeitProviderTest.php create mode 100644 tests/Unit/Logic/Essen/Mapping/MahlzeitFactoryTest.php create mode 100644 tests/Unit/Logic/Essen/Model/MahlzeitTest.php create mode 100644 tests/Unit/Logic/Essen/UseCase/MahlzeitCreateUseCaseTest.php create mode 100644 tests/Unit/Logic/Essen/UseCase/MahlzeitDeleteUseCaseTest.php create mode 100644 tests/Unit/Logic/Essen/UseCase/MahlzeitUpdateUseCaseTest.php diff --git a/Doku/architektur.md b/Doku/architektur.md index acfad46..e5e6fb8 100644 --- a/Doku/architektur.md +++ b/Doku/architektur.md @@ -3,6 +3,30 @@ > Konkretisierung der ADR-007 für diese App. Referenz: > [jens/symfony-architektur](https://git.ljh31.de/jens/symfony-architektur). +## 0.1 Namenskonzept (2026-09-13 final) + +**Regel: Domäne deutsch, technische Begriffe / Suffixe englisch.** + +| Rolle | Name | Beispiel | +|---|---|---| +| Rich-Modell | Substantiv | `Mahlzeit` | +| Domain-Enum (Status) | `Status` | `MahlzeitStatus` (`Geplant`, `Gegessen`) | +| Domain-Enum (Wert) | Substantiv, Werte kurz | `Wochentag` (`Mo`…`So`) | +| DTOs | `{Request,Response}` | `MahlzeitCreateRequest`, `MahlzeitListResponse` | +| Fabrik (DTO→Model) | `Factory` | `MahlzeitFactory` | +| UseCase | `UseCase` | `MahlzeitCreateUseCase` | +| Read-Query | `Query` | `MahlzeitListQuery` | +| Manager | `Manager` | `MahlzeitManager` | +| Provider (Read) | `Provider(Interface)` | `MahlzeitProvider` | +| Processor (Write) | `Processor(Interface)` | `MahlzeitProcessor` | +| Mapper | `Mapper` | `MahlzeitMapper` | +| Entity | `Entity` | `MahlzeitEntity` | +| Exception | `…Exception` | `MahlzeitNotFoundException` | +| Events | `Event` | `MahlzeitCreatedEvent` | + +Methode-Suffixe in Interfaces bleiben kurz/englisch (`create`, `save`, `delete`, `find`, `list`), +da sie technische Konvention sind — nur die **Klassen-/Modellnamen** sind domänendeutsch. + ## 0. Die drei Layer (Kurzreferenz) ``` @@ -26,43 +50,36 @@ UI → Logic; Logic → Interface (Definition im Logic); Data → Interface (Definition im Data). Die Data-Layer implementiert Interfaces aus der Logic-Layer. **Keine** Umkehr. -**Namenskonvention (Kurz):** -- Interface: Suffixe `Interface` (z. B. `DishProviderInterface`). -- UseCase: Suffix `UseCase` (z. B. `CreateDishUseCase`). -- Query: Suffix `Query` (z. B. `ListDishesQuery`). -- Manager: Suffix `Manager` (z. B. `DishManager`). -- Provider/Processor: Suffix `Provider`/`Processor` (z. B. `DishProvider`). -- Mapper: Suffix `Mapper` (z. B. `DishMapper`). -- Model-Factory: Suffix `Factory` (z. B. `DishModelFactory`). -- DTO: Suffix `Request` bzw. `Response`. -- Exception: Suffix `Exception`. -- Entity (Data): Suffix `Entity` (z. B. `DishEntity`). +**Namenskonvention (Kurz)** — siehe §0.1 Namenskonzept: +`MahlzeitProviderInterface`, `MahlzeitCreateUseCase`, `MahlzeitListQuery`, +`Manager`, `Provider`/`Processor`, `Mapper`, `MahlzeitFactory` (statt `*ModelFactory`), +`Create/Update/Delete/…Request`, `…Response`, Exception, `Entity`. ## 1. Domain-Modell (Logik) -**Eine** Modul-Domäne: `Essen`. Darin ein Business-Model: `Dish` +**Eine** Modul-Domäne: `Essen`. Darin ein Business-Model: `Mahlzeit` (Gericht/Eintrag). Wichtiger Hinweis: Die "Archivsicht" ist **kein** abgesonderter Business-Model, sondern eine **Volltext-Sicht** (Read-Query) mit dem Filter `status = gegessen`. Das reduziert die Komplexität. -### 1.1. Das Business-Model `Dish` +### 1.1. Das Business-Model `Mahlzeit` -`src/Logic/Essen/Model/Dish.php` – **Rich Domain Model** (POPO, readonly, +`src/Logic/Essen/Model/Mahlzeit.php` – **Rich Domain Model** (POPO, readonly, kein Framework): ```php namespace App\Logic\Essen\Model; -class Dish +class Mahlzeit { public function __construct( public readonly ?int $id, public string $name, public ?string $notiz, - /** @var DishDay[] */ + /** @var Wochentag[] */ public readonly array $wochentage, public readonly int $sort_order, - public DishStatus $status, + public MahlzeitStatus $status, public readonly ?\DateTimeImmutable $gegessen_am, public readonly \DateTimeImmutable $erstellt_am, public readonly \DateTimeImmutable $aktualisiert_am, @@ -70,11 +87,11 @@ class Dish // State transitions: kapseln eigene Statusänderungen public function markAsGegessen(\DateTimeImmutable $now): self { - if ($this->status === DishStatus::GEGESSEN) { + if ($this->status === MahlzeitStatus::GEGESSEN) { throw new BusinessRuleViolationException('Bereits gegessen.'); } return new self($this->id, $this->name, $this->notiz, $this->wochentage, - $this->sort_order, DishStatus::GEGESSEN, $now, $this->erstellt_am, $now); + $this->sort_order, MahlzeitStatus::GEGESSEN, $now, $this->erstellt_am, $now); } public function markAsGeplant(\DateTimeImmutable $now): self { /* symmetrisch */ } @@ -82,23 +99,24 @@ class Dish ``` **Enums** in `src/Logic/Essen/Model/`: -- `DishStatus` (`GEPLANT`, `GEGESSEN`) -- `DishDay` (`MO`, `DI`, `MI`, `DO`, `FR`, `SA`, `SO`) +- `MahlzeitStatus` (`GEPLANT`, `GEGESSEN`) +- `Wochentag` (`MO`, `DI`, `MI`, `DO`, `FR`, `SA`, `SO`) ### 1.2. DTOs ``` src/Logic/Essen/Dto/ -├── CreateDishRequest.php // name, notiz?, wochentag[]? -├── UpdateDishRequest.php // id, name, notiz?, wochentag[]?, sort_order? -├── DeleteDishRequest.php // id -├── MoveDishRequest.php // id, position (int) -├── ToggleDishStatusRequest.php // id -├── ListDishesRequest.php // status? (optional), sort='sort_order'|'gegessen_am' -├── ListDishesResponse.php // items: Dish[] +├── MahlzeitCreateRequest.php // name, notiz?, wochentage?: Wochentag[] +├── MahlzeitUpdateRequest.php // name, notiz?, wochentage? (sort_order via M3-Reorder) +├── MahlzeitMoveRequest.php // id, position (int) +├── MahlzeitToggleStatusRequest.php // id +├── MahlzeitListRequest.php // status? (optional), sort='sort_order'|'gegessen_am' +├── MahlzeitListResponse.php // items: Mahlzeit[] +├── MahlzeitCreateResponse.php // id, mahlzeit +├── MahlzeitUpdateResponse.php // id, mahlzeit ├── InspirationRequest.php // limit=5, offset=0, excludeIds: int[] -├── InspirationResponse.php // suggestions: Dish[] -└── TodayResponse.php // dishes: Dish[] // optional HA-Consumer +├── InspirationResponse.php // suggestions: Mahlzeit[] +└── TodayResponse.php // mahlzeiten: Mahlzeit[] // optional HA-Consumer ``` Alle `readonly`, native Typisierung, **keine** Attribute/Constraints. @@ -107,43 +125,43 @@ Alle `readonly`, native Typisierung, **keine** Attribute/Constraints. ``` src/Logic/Essen/UseCase/ -├── CreateDishUseCase.php -├── UpdateDishUseCase.php -├── DeleteDishUseCase.php -├── MoveDishUseCase.php // ändert sort_order (bzw. sort_order-Reihenfolge) -└── ToggleDishStatusUseCase.php // markAsGegessen / markAsGeplant +├── MahlzeitCreateUseCase.php // execute(MahlzeitCreateRequest): MahlzeitCreateResponse +├── MahlzeitUpdateUseCase.php // execute(int $id, MahlzeitUpdateRequest): MahlzeitUpdateResponse +├── MahlzeitDeleteUseCase.php // execute(int $id): void +├── MahlzeitMoveUseCase.php // ändert sort_order +└── MahlzeitToggleStatusUseCase.php // markAsGegessen / markAsGeplant ``` **Verfahren (schlau aus Referenz Sektion 1 & 3):** 1. Syntaktische Validierung im UI-Layer (Controller/Command) als **rein PHP** (keine Symfony-Validator-Constraints auf DTOs). 2. Business-Validierung im UseCase + im Model-Konstruktor (Invarianzen). -3. Persistence im UseCase via `DishManager` → `DishProcessor` +3. Persistence im UseCase via `MahlzeitManager` → `MahlzeitProcessor` (Write); Cache-Sidecar-Invalidierung im Manager. 4. Event-Dispatch nach erfolgreichem Commit (Bridge in Data-Layer). -5. Response-DTO (z. B. `UpdateDishResponse`) zurück an Controller. +5. Response-DTO (z. B. `MahlzeitUpdateResponse`) zurück an Controller. -**Beispiel** (UseCase `CreateDishUseCase`): +**Beispiel** (UseCase `MahlzeitCreateUseCase`): ```php namespace App\Logic\Essen\UseCase; -readonly class CreateDishUseCase +readonly class MahlzeitCreateUseCase { public function __construct( private TransactionManagerInterface $tx, - private DishManagerInterface $dishes, - private DishModelFactory $factory, + private MahlzeitManagerInterface $mahlzeiten, + private MahlzeitFactory $factory, private EventDispatcherInterface $events, ) {} - public function execute(CreateDishRequest $req): CreateDishResponse + public function execute(MahlzeitCreateRequest $req): MahlzeitCreateResponse { - return $this->tx->execute(function() use ($req): CreateDishResponse { - $dish = $this->factory->createFromRequest($req); // DTO -> Model - $saved = $this->dishes->create($dish); // Model -> Processor - $this->events->dispatch(new DishCreatedEvent(id: $saved->id)); - return new CreateDishResponse(id: $saved->id, dish: $saved); + return $this->tx->execute(function() use ($req): MahlzeitCreateResponse { + $mahlzeit = $this->factory->createFromCreateRequest($req); // DTO -> Model + $gespeichert = $this->mahlzeiten->create($mahlzeit); // Model -> Processor + $this->events->dispatch(new MahlzeitCreatedEvent(id: $gespeichert->id)); + return new MahlzeitCreateResponse(id: $gespeichert->id, mahlzeit: $gespeichert); }); } } @@ -153,8 +171,8 @@ readonly class CreateDishUseCase ``` src/Logic/Essen/Query/ -├── ListDishesQuery.php // -> ListDishesResponse -├── GetDishQuery.php // -> Dish (optional) +├── MahlzeitListQuery.php // -> MahlzeitListResponse +├── MahlzeitGetQuery.php // -> Mahlzeit (optional) ├── TodayQuery.php // -> TodayResponse (HA-Consumer / optional) └── InspirationQuery.php // -> InspirationResponse (5er-Batch + Offset) ``` @@ -166,28 +184,28 @@ readonly class InspirationQuery { public function __construct( private InspirationWeightSettings $settings, // w_slow/w_rare/w_hot - private DishManagerInterface $dishes, - private DishHistoryProviderInterface $history, // aus Data-Layer + private MahlzeitManagerInterface $mahlzeiten, + private MahlzeitHistoryProviderInterface $history, // aus Data-Layer ) {} public function execute(InspirationRequest $req): InspirationResponse { - $all = $this->dishes->listGegessen(); - $history = $this->history->historyByDishName(); + $all = $this->mahlzeiten->listGegessen(); + $history = $this->history->historyByMahlzeitName(); $scored = $all - ->filter(fn(Dish $d) => !in_array($d->id, $req->excludeIds, true)) - ->map(fn(Dish $d) => new Scored( - dish: $d, - score: $this->score($d, $history), + ->filter(fn(Mahlzeit $m) => !in_array($m->id, $req->excludeIds, true)) + ->map(fn(Mahlzeit $m) => new Scored( + mahlzeit: $m, + score: $this->score($m, $history), )) ->sortDescByScore() ->slice($req->offset, $req->limit); - return new InspirationResponse(suggestions: $scored->map(fn($s)=>$s->dish)); + return new InspirationResponse(suggestions: $scored->map(fn($s)=>$s->mahlzeit)); } - private function score(Dish $d, array $h): float { + private function score(Mahlzeit $m, array $h): float { // w_slow * staleness + w_rare * rarity + w_hot * popularity // (Details: ADR-006) } @@ -198,24 +216,24 @@ readonly class InspirationQuery ``` src/Logic/Essen/Manager/ -├── DishManagerInterface.php -└── DishManager.php // Read + Write-Koordination, Cache-Aside (vorerst +├── MahlzeitManagerInterface.php +└── MahlzeitManager.php // Read + Write-Koordination, Cache-Aside (vorerst // ohne Cache, Platzhalter für Phase 2) ``` Interface (in Logic): ```php -interface DishManagerInterface +interface MahlzeitManagerInterface { - public function create(Dish $dish): Dish; - public function update(Dish $dish): Dish; + public function create(Mahlzeit $mahlzeit): Mahlzeit; + public function update(Mahlzeit $mahlzeit): Mahlzeit; public function delete(int $id): void; - public function move(Dish $dish, int $newSortOrder): void; - public function find(int $id): Dish; - /** @return Dish[] */ + public function move(Mahlzeit $mahlzeit, int $newSortOrder): void; + public function find(int $id): Mahlzeit; // -> MahlzeitNotFoundException (404) + /** @return list */ public function listGegessen(): array; - /** @return Dish[] */ + /** @return list */ public function listGeplant(): array; public function invalidateCache(): void; } @@ -225,8 +243,8 @@ interface DishManagerInterface ``` src/Logic/Essen/ -├── DishProviderInterface.php // Read: find, listGegessen, listGeplant, countByName... -└── DishProcessorInterface.php // Write: create, save, delete; move (sort_order) +├── MahlzeitProviderInterface.php // Read: find, listGegessen, listGeplant, countByName... +└── MahlzeitProcessorInterface.php // Write: create, save, delete; move (sort_order) ``` Implementierungen liegen in `src/Data/Essen/Provider/` bzw. `Processor/`. @@ -234,10 +252,10 @@ Implementierungen liegen in `src/Data/Essen/Provider/` bzw. `Processor/`. ### 1.7. Model-Factory (DTO -> Model) ``` -src/Logic/Essen/Mapping/DishModelFactory.php -├── createFromRequest(CreateDishRequest): Dish -├── updateFromRequest(UpdateDishRequest): Dish -└── rebuildFromPartialUpdate(Dish $current, UpdateDishRequest $req): Dish +src/Logic/Essen/Mapping/MahlzeitFactory.php +├── createFromCreateRequest(MahlzeitCreateRequest): Mahlzeit +├── updateFromUpdateRequest(Mahlzeit, MahlzeitUpdateRequest): Mahlzeit +└── ... ``` ### 1.8. Domänen-Exceptions @@ -252,7 +270,7 @@ src/Logic/Common/Exception/ └── InfrastructureException.php // -> 503 src/Logic/Essen/Exception/ -└── DishNotFoundException.php // erbt ResourceNotFoundException +└── MahlzeitNotFoundException.php // erbt ResourceNotFoundException ``` **HTTP-Mapping** erfolgt zentral in `src/UI/Http/ExceptionSubscriber.php` @@ -275,59 +293,59 @@ src/Logic/Common/ ``` src/Data/Essen/ -├── Entity/DishEntity.php // #[ORM\Entity] – Doctrine, Attribute erlaubt! -├── Provider/DishProvider.php // implementiert DishProviderInterface -├── Processor/DishProcessor.php // implementiert DishProcessorInterface -├── Mapper/DishMapper.php // toModel / createEntity / updateEntity -└── HistoryProvider/DishHistoryProvider.php // (optional, für Inspiration) +├── Entity/MahlzeitEntity.php // #[ORM\Entity] – Doctrine, Attribute erlaubt! +├── Provider/MahlzeitProvider.php // implementiert MahlzeitProviderInterface +├── Processor/MahlzeitProcessor.php // implementiert MahlzeitProcessorInterface +├── Mapper/MahlzeitMapper.php // toModel / createEntity / updateEntity +└── HistoryProvider/MahlzeitHistoryProvider.php // (optional, für Inspiration) src/Data/Infrastructure/ ├── Doctrine/EntityManagerTransactionManager.php └── SymfonyEventDispatcherBridge.php ``` -**Doctrine Entity** (`DishEntity`): +**Doctrine Entity** (`MahlzeitEntity`): ```php -#[ORM\Entity, ORM\Table(name: 'essens_dishes')] -class DishEntity +#[ORM\Entity, ORM\Table(name: 'essens_mahlzeiten')] +class MahlzeitEntity { #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')] private ?int $id = null; #[ORM\Column(type: 'string', length: 255)] private string $name = ''; - // (notiz, wochentage (set), sort_order, status, gegessen_am, timestamps) + // (notiz, wochentage (CSV-string „mo,di,fr", nullable), sort_order, status (enum), gegessen_am, timestamps) // nur Getter/Setter, keine Logik } ``` **Mapper** (stateless): -- `toModel(DishEntity): Dish` -- `createEntity(Dish): DishEntity` -- `updateEntity(Dish, DishEntity): void` +- `toModel(MahlzeitEntity): Mahlzeit` +- `createEntity(Mahlzeit): MahlzeitEntity` +- `updateEntity(Mahlzeit, MahlzeitEntity): void` **Processor** (Write): ```php -readonly class DishProcessor implements DishProcessorInterface +readonly class MahlzeitProcessor implements MahlzeitProcessorInterface { public function __construct( private EntityManagerInterface $em, - private DishMapper $mapper, + private MahlzeitMapper $mapper, ) {} - public function create(Dish $model): Dish { + public function create(Mahlzeit $model): Mahlzeit { $entity = $this->mapper->createEntity($model); $this->em->persist($entity); $this->em->flush(); return $this->mapper->toModel($entity); // mit id aus DB } - public function save(Dish $model): Dish { - $entity = $this->em->getRepository(DishEntity::class)->find($model->id); - if ($entity === null) throw new DishNotFoundException($model->id); + public function save(Mahlzeit $model): Mahlzeit { + $entity = $this->em->getRepository(MahlzeitEntity::class)->find($model->id); + if ($entity === null) throw new MahlzeitNotFoundException($model->id); $this->mapper->updateEntity($model, $entity); $this->em->flush(); return $model; @@ -340,13 +358,13 @@ readonly class DishProcessor implements DishProcessorInterface ``` src/UI/Http/Essen/ -├── DishController.php // CRUD, toggle, move (sort_order) +├── MahlzeitController.php // CRUD, toggle, move (sort_order) ├── InspirationController.php // 5er-Batch ├── TodayController.php // optional: HA-Consumer (GET /api/today) └── ExceptionSubscriber.php // zentrales Domain-Exception → HTTP-Mapping -src/UI/Twig/ // Twig-Templates (htmx-Fragmente) -├── dish/ +templates/ // Twig-Templates (htmx-Fragmente) +├── mahlzeiten/ │ ├── index.html.twig // Planungsliste + Archiv-Tab │ ├── form.html.twig // Erstellen/Bearbeiten │ └── item.html.twig // Eintrag (li) – SortableJS @@ -369,19 +387,19 @@ eingeblendet. Kein Build-Step. ``` tests/ ├── Unit/Logic/ // Pure PHP, ohne Kernel (schnell) -│ ├── Essen/Model/DishTest.php -│ ├── Essen/UseCase/CreateDishUseCaseTest.php +│ ├── Essen/Model/MahlzeitTest.php +│ ├── Essen/UseCase/MahlzeitCreateUseCaseTest.php │ ├── Essen/Query/InspirationQueryTest.php // mit gemocktem Provider -│ └── Essen/Mapping/DishModelFactoryTest.php +│ └── Essen/Mapping/MahlzeitFactoryTest.php ├── Integration/Data/ // Test-Env + SQLite (oder in-memory) -│ ├── Essen/DishProviderTest.php -│ ├── Essen/DishProcessorTest.php -│ └── Essen/DishMapperTest.php +│ ├── Essen/MahlzeitProviderTest.php +│ ├── Essen/MahlzeitProcessorTest.php +│ └── Essen/MahlzeitMapperTest.php ├── Functional/UI/ // Symfony WebTestCase, isolierte Endpunkte -│ ├── Essen/DishControllerTest.php +│ ├── Essen/MahlzeitControllerTest.php │ └── Essen/ExceptionSubscriberTest.php └── Functional/Scenarios/ // User-Flows - └── EssenDishLifecycleTest.php // create → toggle → archive → restore + └── MahlzeitLifecycleTest.php // create → toggle → archive → restore ``` **Mocking-Richtlinien** (aus Referenz Sektion 13): @@ -420,25 +438,25 @@ svc-essensplanung/ │ │ │ ├── TransactionManagerInterface.php │ │ │ └── EventDispatcherInterface.php │ │ └── Essen/ -│ │ ├── Model/ (Dish, DishDay, DishStatus) +│ │ ├── Model/ (Mahlzeit, Wochentag, MahlzeitStatus) │ │ ├── Dto/ (10 Dateien) │ │ ├── UseCase/ (5) │ │ ├── Query/ (4) │ │ ├── Manager/ (interface + impl) -│ │ ├── DishProviderInterface.php -│ │ ├── DishProcessorInterface.php -│ │ ├── Mapping/DishModelFactory.php -│ │ └── Event/DishCreatedEvent.php, DishArchivedEvent.php, ... +│ │ ├── MahlzeitProviderInterface.php +│ │ ├── MahlzeitProcessorInterface.php +│ │ ├── Mapping/MahlzeitFactory.php +│ │ └── Event/MahlzeitCreatedEvent.php, MahlzeitArchivedEvent.php, ... │ ├── Data/ │ │ ├── Infrastructure/ │ │ │ ├── Doctrine/EntityManagerTransactionManager.php │ │ │ └── SymfonyEventDispatcherBridge.php │ │ └── Essen/ -│ │ ├── Entity/DishEntity.php -│ │ ├── Provider/DishProvider.php -│ │ ├── Processor/DishProcessor.php -│ │ ├── Mapper/DishMapper.php -│ │ └── HistoryProvider/DishHistoryProvider.php +│ │ ├── Entity/MahlzeitEntity.php +│ │ ├── Provider/MahlzeitProvider.php +│ │ ├── Processor/MahlzeitProcessor.php +│ │ ├── Mapper/MahlzeitMapper.php +│ │ └── HistoryProvider/MahlzeitHistoryProvider.php │ └── UI/ │ ├── Http/Essen/ (3 Controllers + ExceptionSubscriber) │ └── Twig/ (Layout + Fragmente) @@ -459,28 +477,28 @@ svc-essensplanung/ ## 3. Dependency Graph (konkret) ``` -UI: DishController - -> Logic: CreateDishUseCase - -> Logic: MoveDishUseCase - -> Logic: ToggleDishStatusUseCase - -> Logic: ListDishesQuery +UI: MahlzeitController + -> Logic: MahlzeitCreateUseCase + -> Logic: MahlzeitMoveUseCase + -> Logic: MahlzeitToggleStatusUseCase + -> Logic: MahlzeitListQuery -> Logic: InspirationQuery - -> Logic DTOs (CreateDishRequest, MoveDishRequest, ...) + -> Logic DTOs (MahlzeitCreateRequest, MahlzeitMoveRequest, ...) -Logic: CreateDishUseCase +Logic: MahlzeitCreateUseCase -> Logic: Common TransactionManagerInterface - -> Logic: Essen DishManagerInterface - -> Logic: Essen Mapping/DishModelFactory + -> Logic: Essen MahlzeitManagerInterface + -> Logic: Essen Mapping/MahlzeitFactory -> Logic: Common EventDispatcherInterface - -> Logic: Essen/Event DishCreatedEvent + -> Logic: Essen/Event MahlzeitCreatedEvent -Logic: DishManager - -> Logic: Essen DishProviderInterface (Read) - -> Logic: Essen DishProcessorInterface (Write) +Logic: MahlzeitManager + -> Logic: Essen MahlzeitProviderInterface (Read) + -> Logic: Essen MahlzeitProcessorInterface (Write) -Data: DishProvider implements Logic:DishProviderInterface -Data: DishProcessor implements Logic:DishProcessorInterface -Data: DishMapper (Entity <-> Model, stateless) +Data: MahlzeitProvider implements Logic:MahlzeitProviderInterface +Data: MahlzeitProcessor implements Logic:MahlzeitProcessorInterface +Data: MahlzeitMapper (Entity <-> Model, stateless) Data: EntityManagerTransactionManager implements Logic:Common TransactionManagerInterface Data: SymfonyEventDispatcherBridge implements Logic:Common EventDispatcherInterface ``` @@ -519,7 +537,7 @@ Data: SymfonyEventDispatcherBridge implements Logic:Common EventDispatcherIn ## 6. Offene / Phase-2-Punkte - [ ] `settings.yaml` für Inspiration-Gewichte (ADR-006). Default 0.5/0.3/0.2. -- [ ] `DishHistoryProvider` konkret (für Inspiration, zählt Auftritte pro Name). +- [ ] `MahlzeitHistoryProvider` konkret (für Inspiration, zählt Auftritte pro Name). - [ ] `GET /api/today` für HA-Consumer (Phase 2, ADR-004). - [ ] Optional: `Push`-Benachrichtigung per HA-Notify (Phase 2). -- [ ] Optional: Cache-Aside im `DishManager` (vorerst ohne, Phase 2). +- [ ] Optional: Cache-Aside im `MahlzeitManager` (vorerst ohne, Phase 2). diff --git a/Doku/plan.md b/Doku/plan.md index 73634f5..d9940c4 100644 --- a/Doku/plan.md +++ b/Doku/plan.md @@ -32,12 +32,17 @@ Status: **final** (2026-09-13, abgestimmt) · Basis: [`anforderungen.md`](anford - ADR-003 überarbeitet (Keycloak statt „kein Login") - **DoD:** Container läuft, `GET /` → Keycloak-Login, danach Hello-World-Seite -### M1 – CRUD-Slice -- Logic: `Dish`-Model, `CreateDish`/`UpdateDish`/`DeleteDish`, `ListDishes`, `DishDto` -- Data: `DishEntity`, Migration 0001, Provider/Processor-Impl, Mapper, Exception-Subscriber -- UI: `DishController` (htmx-Partials), Formular + Validierung im Controller, Liste -- Tests: UseCase-Unit (in-mem SQLite) + 1 Controller-Feature-Test -- **DoD:** Gericht anlegen/bearbeiten/löschen im Browser (als eingeloggter User) +### M1 – CRUD-Slice (Model: `Mahlzeit` – Namenskonszept: Domäne DE, Tech-EN, s. architektur.md §0.1) +- **Schritt 1 (Logik+Daten):** `src/Logic/Essen` – `Mahlzeit`-Model (+`MahlzeitStatus`, `Wochentag`), + `Mahlzeit{Create,Update}Request/Response`-DTOs, `MahlzeitFactory`, `Mahlzeit{Provider,Processor}Interface`, + `MahlzeitManager`, `Mahlzeit{Create,Update,Delete}UseCase`, `MahlzeitListQuery`, + `MahlzeitNotFoundException` (→404), Events; `src/Data/Essen` – `MahlzeitEntity` + (Tabelle `essens_mahlzeiten`, wochentage als CSV), `MahlzeitMapper/Provider/Processor`, Migration. + Tests: UseCase/Model-Unit + Provider/Processor-Integration (in-mem SQLite). + **DoD 1:** `composer test` grün, CRUD vollständig über Logic-API testbar. +- **Schritt 2 (UI):** `MahlzeitController` + `templates/mahlzeiten/` + Routen `/mahlzeiten[/{id}]` + (POST/Redirect/Flash, Wochentage als Checkboxes) + Controller-Feature-Test (eingeloggte Session). +- **DoD:** Gericht anlegen/bearbeiten/löschen im Browser (als eingeloggte/r Nutzer) ### M2 – Status & Wochentage - `CheckOffDish`/`UncheckDish` (Status `aktiv`/`gecheckt`), `ArchiveDish` diff --git a/config/services.yaml b/config/services.yaml index cbb4679..6818a3f 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -8,6 +8,13 @@ services: _defaults: autowire: true autoconfigure: true + # Interface -> Implementierung (Dependency Inversion): Logic-Layer definiert, + # Data-Layer implementiert. Explizit gebunden, damit Autowiring nie mehrdeutig wird. + bind: + App\Logic\Common\ClockInterface: '@App\Data\Infrastructure\SystemClock' + App\Logic\Essen\MahlzeitProviderInterface: '@App\Data\Essen\Provider\MahlzeitProvider' + App\Logic\Essen\MahlzeitProcessorInterface: '@App\Data\Essen\Processor\MahlzeitProcessor' + App\Logic\Essen\Manager\MahlzeitManagerInterface: '@App\Logic\Essen\Manager\MahlzeitManager' App\: resource: '../src/' diff --git a/migrations/Version20260918213517.php b/migrations/Version20260918213517.php new file mode 100644 index 0000000..e273375 --- /dev/null +++ b/migrations/Version20260918213517.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE essens_mahlzeiten (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, notiz CLOB DEFAULT NULL, wochentage VARCHAR(32) DEFAULT NULL, sort_order INTEGER NOT NULL, status VARCHAR(32) NOT NULL, gegessen_am DATETIME DEFAULT NULL, erstellt_am DATETIME NOT NULL, aktualisiert_am DATETIME NOT NULL)'); + $this->addSql('CREATE INDEX idx_status ON essens_mahlzeiten (status)'); + $this->addSql('CREATE INDEX idx_sort_order ON essens_mahlzeiten (sort_order)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE essens_mahlzeiten'); + } +} diff --git a/src/Data/Essen/Entity/MahlzeitEntity.php b/src/Data/Essen/Entity/MahlzeitEntity.php new file mode 100644 index 0000000..3054451 --- /dev/null +++ b/src/Data/Essen/Entity/MahlzeitEntity.php @@ -0,0 +1,151 @@ +erstelltAm = $now; + $this->aktualisiertAm = $now; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + return $this; + } + + public function getNotiz(): ?string + { + return $this->notiz; + } + + public function setNotiz(?string $notiz): self + { + $this->notiz = $notiz; + return $this; + } + + public function getWochentage(): ?string + { + return $this->wochentage; + } + + public function setWochentage(?string $wochentage): self + { + $this->wochentage = $wochentage; + return $this; + } + + public function getSortOrder(): int + { + return $this->sortOrder; + } + + public function setSortOrder(int $sortOrder): self + { + $this->sortOrder = $sortOrder; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getGegessenAm(): ?\DateTimeImmutable + { + return $this->gegessenAm; + } + + public function setGegessenAm(?\DateTimeImmutable $gegessenAm): self + { + $this->gegessenAm = $gegessenAm; + return $this; + } + + public function getErstelltAm(): \DateTimeImmutable + { + return $this->erstelltAm; + } + + public function setErstelltAm(\DateTimeImmutable $erstelltAm): self + { + $this->erstelltAm = $erstelltAm; + return $this; + } + + public function getAktualisiertAm(): \DateTimeImmutable + { + return $this->aktualisiertAm; + } + + public function setAktualisiertAm(\DateTimeImmutable $aktualisiertAm): self + { + $this->aktualisiertAm = $aktualisiertAm; + return $this; + } +} diff --git a/src/Data/Essen/Mapper/MahlzeitMapper.php b/src/Data/Essen/Mapper/MahlzeitMapper.php new file mode 100644 index 0000000..228b7ac --- /dev/null +++ b/src/Data/Essen/Mapper/MahlzeitMapper.php @@ -0,0 +1,88 @@ +getId(), + name: $entity->getName(), + notiz: (string) $entity->getNotiz(), + wochentage: $this->csvToWochentage((string) $entity->getWochentage()), + sortOrder: $entity->getSortOrder(), + status: MahlzeitStatus::from($entity->getStatus()), + erstelltAm: $entity->getErstelltAm(), + aktualisiertAm: $entity->getAktualisiertAm(), + gegessenAm: $entity->getGegessenAm(), + ); + } + + public function createEntity(Mahlzeit $model): MahlzeitEntity + { + $entity = new MahlzeitEntity(); + $this->apply($model, $entity); + return $entity; + } + + public function updateEntity(Mahlzeit $model, MahlzeitEntity $entity): void + { + $this->apply($model, $entity); + } + + private function apply(Mahlzeit $model, MahlzeitEntity $entity): void + { + $entity->setName($model->name); + $entity->setNotiz($model->notiz === '' ? null : $model->notiz); + $entity->setWochentage($this->wochentageToCsv($model->wochentage())); + $entity->setSortOrder($model->sortOrder); + $entity->setStatus($model->status->value); + $entity->setGegessenAm($model->gegessenAm); + $entity->setErstelltAm($model->erstelltAm); + $entity->setAktualisiertAm($model->aktualisiertAm); + } + + /** @return list */ + private function csvToWochentage(string $csv): array + { + if (\trim($csv) === '') { + return []; + } + + $tage = []; + foreach (explode(',', $csv) as $wert) { + $tag = Wochentag::fromValue($wert); + if ($tag !== null) { + $tage[] = $tag; + } + } + + return $tage; + } + + /** @param list $tage */ + private function wochentageToCsv(array $tage): ?string + { + if ($tage === []) { + return null; + } + + return implode(',', Wochentag::zuWerten($tage)); + } +} diff --git a/src/Data/Essen/Processor/MahlzeitProcessor.php b/src/Data/Essen/Processor/MahlzeitProcessor.php new file mode 100644 index 0000000..b02dc39 --- /dev/null +++ b/src/Data/Essen/Processor/MahlzeitProcessor.php @@ -0,0 +1,63 @@ +mapper->createEntity($mahlzeit); + $this->em->persist($entity); + $this->em->flush(); + + return $this->mapper->toModel($entity); + } + + public function save(Mahlzeit $mahlzeit): Mahlzeit + { + $entity = $this->findEntity($mahlzeit->id); + $this->mapper->updateEntity($mahlzeit, $entity); + $this->em->flush(); + + return $mahlzeit; + } + + public function delete(Mahlzeit $mahlzeit): void + { + $entity = $this->findEntity($mahlzeit->id); + $this->em->remove($entity); + $this->em->flush(); + } + + private function findEntity(int $id): MahlzeitEntity + { + $entity = $this->em->getRepository(MahlzeitEntity::class)->find($id); + + if ($entity === null) { + throw new MahlzeitNotFoundException($id); + } + + return $entity; + } +} diff --git a/src/Data/Essen/Provider/MahlzeitProvider.php b/src/Data/Essen/Provider/MahlzeitProvider.php new file mode 100644 index 0000000..c93bee8 --- /dev/null +++ b/src/Data/Essen/Provider/MahlzeitProvider.php @@ -0,0 +1,65 @@ +em->getRepository(MahlzeitEntity::class)->find($id); + + return $entity instanceof MahlzeitEntity ? $this->mapper->toModel($entity) : null; + } + + /** @return list */ + public function list(?MahlzeitStatus $status = null): array + { + $qb = $this->em->getRepository(MahlzeitEntity::class)->createQueryBuilder('m'); + + if ($status !== null) { + $qb->andWhere('m.status = :status')->setParameter('status', $status->value); + } + + $qb->orderBy('m.sortOrder', 'ASC')->addOrderBy('m.id', 'ASC'); + + /** @var MahlzeitEntity[] $entities */ + $entities = $qb->getQuery()->getResult(); + + $mahlzeiten = []; + foreach ($entities as $entity) { + $mahlzeiten[] = $this->mapper->toModel($entity); + } + + return $mahlzeiten; + } + + public function maxSortOrder(): int + { + $max = $this->em + ->getRepository(MahlzeitEntity::class) + ->createQueryBuilder('m') + ->select('MAX(m.sortOrder)') + ->getQuery() + ->getSingleScalarResult(); + + return $max === null ? 0 : (int) $max; + } +} diff --git a/src/Data/Infrastructure/SystemClock.php b/src/Data/Infrastructure/SystemClock.php new file mode 100644 index 0000000..09685d4 --- /dev/null +++ b/src/Data/Infrastructure/SystemClock.php @@ -0,0 +1,18 @@ + $wochentage + */ +final class MahlzeitCreateRequest +{ + public function __construct( + public readonly string $name, + public readonly string $notiz = '', + public readonly array $wochentage = [], + ) {} +} diff --git a/src/Logic/Essen/Dto/MahlzeitCreateResponse.php b/src/Logic/Essen/Dto/MahlzeitCreateResponse.php new file mode 100644 index 0000000..d491d56 --- /dev/null +++ b/src/Logic/Essen/Dto/MahlzeitCreateResponse.php @@ -0,0 +1,23 @@ +id, mahlzeit: MahlzeitResponse::fromModel($m)); + } +} diff --git a/src/Logic/Essen/Dto/MahlzeitListRequest.php b/src/Logic/Essen/Dto/MahlzeitListRequest.php new file mode 100644 index 0000000..ac1b5ce --- /dev/null +++ b/src/Logic/Essen/Dto/MahlzeitListRequest.php @@ -0,0 +1,19 @@ + $items + */ +final class MahlzeitListResponse +{ + public function __construct( + public readonly array $items, + ) {} + + /** @param list $mahlzeiten */ + public static function fromModels(array $mahlzeiten): self + { + $items = []; + foreach ($mahlzeiten as $m) { + $items[] = MahlzeitResponse::fromModel($m); + } + + return new self(items: $items); + } +} diff --git a/src/Logic/Essen/Dto/MahlzeitResponse.php b/src/Logic/Essen/Dto/MahlzeitResponse.php new file mode 100644 index 0000000..ec76952 --- /dev/null +++ b/src/Logic/Essen/Dto/MahlzeitResponse.php @@ -0,0 +1,44 @@ + $wochentage + */ +final class MahlzeitResponse +{ + public function __construct( + public readonly int $id, + public readonly string $name, + public readonly string $notiz, + public readonly array $wochentage, + public readonly int $sortOrder, + public readonly MahlzeitStatus $status, + public readonly ?\DateTimeImmutable $gegessenAm, + public readonly \DateTimeImmutable $erstelltAm, + public readonly \DateTimeImmutable $aktualisiertAm, + ) {} + + public static function fromModel(Mahlzeit $m): self + { + return new self( + id: $m->id, + name: $m->name, + notiz: $m->notiz, + wochentage: $m->wochentage, + sortOrder: $m->sortOrder, + status: $m->status, + gegessenAm: $m->gegessenAm, + erstelltAm: $m->erstelltAm, + aktualisiertAm: $m->aktualisiertAm, + ); + } +} diff --git a/src/Logic/Essen/Dto/MahlzeitUpdateRequest.php b/src/Logic/Essen/Dto/MahlzeitUpdateRequest.php new file mode 100644 index 0000000..c35f2db --- /dev/null +++ b/src/Logic/Essen/Dto/MahlzeitUpdateRequest.php @@ -0,0 +1,23 @@ +|null $wochentage + */ +final class MahlzeitUpdateRequest +{ + public function __construct( + public readonly ?string $name = null, + public readonly ?string $notiz = null, + public readonly ?array $wochentage = null, + ) {} +} diff --git a/src/Logic/Essen/Dto/MahlzeitUpdateResponse.php b/src/Logic/Essen/Dto/MahlzeitUpdateResponse.php new file mode 100644 index 0000000..70bf150 --- /dev/null +++ b/src/Logic/Essen/Dto/MahlzeitUpdateResponse.php @@ -0,0 +1,23 @@ +id, mahlzeit: MahlzeitResponse::fromModel($m)); + } +} diff --git a/src/Logic/Essen/Event/MahlzeitCreatedEvent.php b/src/Logic/Essen/Event/MahlzeitCreatedEvent.php new file mode 100644 index 0000000..946d1c7 --- /dev/null +++ b/src/Logic/Essen/Event/MahlzeitCreatedEvent.php @@ -0,0 +1,18 @@ + sortiert nach sortOrder aufsteigend + */ + public function list(?MahlzeitStatus $status = null): array; + + /** Größte vorhandene sortOrder-Position (0, falls leer). */ + public function maxSortOrder(): int; +} diff --git a/src/Logic/Essen/Manager/MahlzeitManager.php b/src/Logic/Essen/Manager/MahlzeitManager.php new file mode 100644 index 0000000..b0ea97f --- /dev/null +++ b/src/Logic/Essen/Manager/MahlzeitManager.php @@ -0,0 +1,57 @@ +mitSortOrder($this->provider->maxSortOrder() + 1); + + return $this->processor->create($mahlzeit); + } + + public function update(Mahlzeit $mahlzeit): Mahlzeit + { + return $this->processor->save($mahlzeit); + } + + public function delete(int $id): void + { + $this->find($id); + $mahlzeit = $this->provider->find($id); + if ($mahlzeit !== null) { + $this->processor->delete($mahlzeit); + } + } + + public function find(int $id): Mahlzeit + { + return $this->provider->find($id) ?? throw new MahlzeitNotFoundException($id); + } + + /** @return list */ + public function list(?MahlzeitStatus $status = null): array + { + return $this->provider->list($status); + } +} diff --git a/src/Logic/Essen/Manager/MahlzeitManagerInterface.php b/src/Logic/Essen/Manager/MahlzeitManagerInterface.php new file mode 100644 index 0000000..06b0a14 --- /dev/null +++ b/src/Logic/Essen/Manager/MahlzeitManagerInterface.php @@ -0,0 +1,29 @@ + sortiert nach sortOrder aufsteigend */ + public function list(?MahlzeitStatus $status = null): array; +} diff --git a/src/Logic/Essen/Mapping/MahlzeitFactory.php b/src/Logic/Essen/Mapping/MahlzeitFactory.php new file mode 100644 index 0000000..8331c21 --- /dev/null +++ b/src/Logic/Essen/Mapping/MahlzeitFactory.php @@ -0,0 +1,67 @@ +assertRequired($req->name, 'name'); + + return Mahlzeit::anlegen( + name: $req->name, + notiz: $req->notiz, + wochentage: $req->wochentage, + sortOrder: 0, + jetzt: $jetzt, + ); + } + + /** + * Wendet ein Teil-Update auf ein bestehendes Modell an. `null`-Felder bleiben unverändert. + */ + public function updateFromUpdateRequest(Mahlzeit $mahlzeit, MahlzeitUpdateRequest $req, \DateTimeImmutable $jetzt): Mahlzeit + { + if ($req->name !== null) { + $this->assertRequired($req->name, 'name'); + } + + $name = $req->name ?? $mahlzeit->name; + $notiz = $req->notiz ?? $mahlzeit->notiz; + $wochentage = $req->wochentage ?? $mahlzeit->wochentage; + + return $mahlzeit->aktualisieren( + name: $name, + notiz: $notiz, + wochentage: $wochentage, + sortOrder: $mahlzeit->sortOrder, + jetzt: $jetzt, + ); + } + + private function assertRequired(?string $value, string $feld): void + { + if ($value === null || \trim($value) === '') { + throw new BusinessRuleViolationException(\sprintf('%s ist ein Pflichtfeld.', $feld)); + } + } +} diff --git a/src/Logic/Essen/Model/Mahlzeit.php b/src/Logic/Essen/Model/Mahlzeit.php new file mode 100644 index 0000000..5e8243e --- /dev/null +++ b/src/Logic/Essen/Model/Mahlzeit.php @@ -0,0 +1,232 @@ + $wochentage + * @param int $sortOrder Position in der Planungsliste (>= 0) + * @param MahlzeitStatus $status + * @param \DateTimeImmutable $erstelltAm + * @param \DateTimeImmutable $aktualisiertAm + * @param \DateTimeImmutable|null $gegessenAm + */ + public function __construct( + public readonly int $id, + public readonly string $name, + public readonly string $notiz, + public readonly array $wochentage, + public readonly int $sortOrder, + public readonly MahlzeitStatus $status, + public readonly \DateTimeImmutable $erstelltAm, + public readonly \DateTimeImmutable $aktualisiertAm, + public readonly ?\DateTimeImmutable $gegessenAm, + ) { + foreach ($wochentage as $wochentag) { + if (!$wochentag instanceof Wochentag) { + throw new \TypeError('wochentage muss eine Liste von Wochentag sein.'); + } + } + + if ($status === MahlzeitStatus::Gegessen && $gegessenAm === null) { + throw new BusinessRuleViolationException('Eine gegessene Mahlzeit benötigt einen gegessenAm-Zeitpunkt.'); + } + if ($status === MahlzeitStatus::Geplant && $gegessenAm !== null) { + throw new BusinessRuleViolationException('Eine geplante Mahlzeit darf keinen gegessenAm-Zeitpunkt haben.'); + } + } + + /** + * Anlegen einer neuen, geplanten Mahlzeit (noch keine ID – die bekommt die Persistenz). + * + * @param non-empty-string $name + * @param non-empty-string $notiz + * @param list $wochentage + */ + public static function anlegen( + string $name, + string $notiz, + array $wochentage, + int $sortOrder, + \DateTimeImmutable $jetzt, + ): self { + return new self( + id: 0, + name: \trim($name), + notiz: \trim($notiz), + wochentage: $wochentage, + sortOrder: $sortOrder, + status: MahlzeitStatus::Geplant, + erstelltAm: $jetzt, + aktualisiertAm: $jetzt, + gegessenAm: null, + ); + } + + /** + * Bearbeiten der Inhalt-Felder (Name, Notiz, Wochentage, Reihenfolge). + * Status/Zeitstempel bleiben dabei erhalten. + * + * @param non-empty-string $name + * @param non-empty-string $notiz + * @param list $wochentage + */ + public function aktualisieren( + string $name, + string $notiz, + array $wochentage, + int $sortOrder, + \DateTimeImmutable $jetzt, + ): self { + if (\trim($name) === '') { + throw new BusinessRuleViolationException('name ist ein Pflichtfeld.'); + } + + foreach ($wochentage as $wochentag) { + if (!$wochentag instanceof Wochentag) { + throw new BusinessRuleViolationException('wochentage enthält einen ungültigen Wochentag.'); + } + } + + return $this->neuesModell( + name: \trim($name), + notiz: \trim($notiz), + wochentage: $wochentage, + sortOrder: $sortOrder, + status: $this->status, + gegessenAm: $this->gegessenAm, + aktualisiertAm: $jetzt, + ); + } + + public function mitSortOrder(int $sortOrder): self + { + if ($sortOrder < 0) { + throw new BusinessRuleViolationException('sortOrder muss >= 0 sein.'); + } + + return $this->neuesModell( + name: $this->name, + notiz: $this->notiz, + wochentage: $this->wochentage, + sortOrder: $sortOrder, + status: $this->status, + gegessenAm: $this->gegessenAm, + aktualisiertAm: $this->aktualisiertAm, + ); + } + + public function markAsGegessen(\DateTimeImmutable $jetzt): self + { + if ($this->status === MahlzeitStatus::Gegessen) { + return $this; + } + + return $this->neuesModell( + name: $this->name, + notiz: $this->notiz, + wochentage: $this->wochentage, + sortOrder: $this->sortOrder, + status: MahlzeitStatus::Gegessen, + gegessenAm: $jetzt, + aktualisiertAm: $jetzt, + ); + } + + public function markAsGeplant(\DateTimeImmutable $jetzt): self + { + if ($this->status === MahlzeitStatus::Geplant) { + return $this; + } + + return $this->neuesModell( + name: $this->name, + notiz: $this->notiz, + wochentage: $this->wochentage, + sortOrder: $this->sortOrder, + status: MahlzeitStatus::Geplant, + gegessenAm: null, + aktualisiertAm: $jetzt, + ); + } + + /** + * Setzt die Persistenz-ID (id) – wird vom Processor nach dem Insert verwendet. + */ + public function mitId(int $id): self + { + return new self( + id: $id, + name: $this->name, + notiz: $this->notiz, + wochentage: $this->wochentage, + sortOrder: $this->sortOrder, + status: $this->status, + erstelltAm: $this->erstelltAm, + aktualisiertAm: $this->aktualisiertAm, + gegessenAm: $this->gegessenAm, + ); + } + + public function istGegessen(): bool + { + return $this->status === MahlzeitStatus::Gegessen; + } + + public function istGeplant(): bool + { + return $this->status === MahlzeitStatus::Geplant; + } + + /** @return list */ + public function wochentage(): array + { + return $this->wochentage; + } + + /** + * @param non-empty-string $name + * @param non-empty-string $notiz + * @param list $wochentage + * @param int $sortOrder + * @param MahlzeitStatus $status + * @param \DateTimeImmutable|null $gegessenAm + * @param \DateTimeImmutable $aktualisiertAm + */ + private function neuesModell( + string $name, + string $notiz, + array $wochentage, + int $sortOrder, + MahlzeitStatus $status, + ?\DateTimeImmutable $gegessenAm, + \DateTimeImmutable $aktualisiertAm, + ): self { + return new self( + id: $this->id, + name: $name, + notiz: $notiz, + wochentage: $wochentage, + sortOrder: $sortOrder, + status: $status, + erstelltAm: $this->erstelltAm, + aktualisiertAm: $aktualisiertAm, + gegessenAm: $gegessenAm, + ); + } +} diff --git a/src/Logic/Essen/Model/MahlzeitStatus.php b/src/Logic/Essen/Model/MahlzeitStatus.php new file mode 100644 index 0000000..86ed760 --- /dev/null +++ b/src/Logic/Essen/Model/MahlzeitStatus.php @@ -0,0 +1,11 @@ + $values */ + public static function ausWerten(array $values): array + { + $tage = []; + foreach ($values as $value) { + $tag = self::tryFrom((string) $value); + if ($tag !== null) { + $tage[] = $tag; + } + } + + return $tage; + } + + /** @return list */ + public static function zuWerten(array $tage): array + { + return \array_map(static fn (self $tag): string => $tag->value, $tage); + } +} diff --git a/src/Logic/Essen/Query/MahlzeitListQuery.php b/src/Logic/Essen/Query/MahlzeitListQuery.php new file mode 100644 index 0000000..132881d --- /dev/null +++ b/src/Logic/Essen/Query/MahlzeitListQuery.php @@ -0,0 +1,28 @@ +mahlzeiten->list($req->status); + + return MahlzeitListResponse::fromModels($mahlzeiten); + } +} diff --git a/src/Logic/Essen/UseCase/MahlzeitCreateUseCase.php b/src/Logic/Essen/UseCase/MahlzeitCreateUseCase.php new file mode 100644 index 0000000..0098f81 --- /dev/null +++ b/src/Logic/Essen/UseCase/MahlzeitCreateUseCase.php @@ -0,0 +1,44 @@ +clock->now(); + $mahlzeit = $this->factory->createFromCreateRequest($req, $jetzt); + + $gespeichert = $this->tx->execute(fn () => $this->mahlzeiten->create($mahlzeit)); + + $this->events->dispatch(new MahlzeitCreatedEvent(id: $gespeichert->id, name: $gespeichert->name)); + + return MahlzeitCreateResponse::fromModel($gespeichert); + } +} diff --git a/src/Logic/Essen/UseCase/MahlzeitDeleteUseCase.php b/src/Logic/Essen/UseCase/MahlzeitDeleteUseCase.php new file mode 100644 index 0000000..cb28df7 --- /dev/null +++ b/src/Logic/Essen/UseCase/MahlzeitDeleteUseCase.php @@ -0,0 +1,34 @@ +mahlzeiten->find($id); + + $this->tx->execute(fn () => $this->mahlzeiten->delete($id)); + + $this->events->dispatch(new MahlzeitDeletedEvent(id: $id, name: $bestehend->name)); + } +} diff --git a/src/Logic/Essen/UseCase/MahlzeitUpdateUseCase.php b/src/Logic/Essen/UseCase/MahlzeitUpdateUseCase.php new file mode 100644 index 0000000..f0974e2 --- /dev/null +++ b/src/Logic/Essen/UseCase/MahlzeitUpdateUseCase.php @@ -0,0 +1,44 @@ +clock->now(); + $bestehend = $this->mahlzeiten->find($id); + $aktualisiert = $this->factory->updateFromUpdateRequest($bestehend, $req, $jetzt); + + $gespeichert = $this->tx->execute(fn () => $this->mahlzeiten->update($aktualisiert)); + + $this->events->dispatch(new MahlzeitUpdatedEvent(id: $id, name: $gespeichert->name)); + + return MahlzeitUpdateResponse::fromModel($gespeichert); + } +} diff --git a/tests/Integration/Data/Essen/DatabaseTestCase.php b/tests/Integration/Data/Essen/DatabaseTestCase.php new file mode 100644 index 0000000..cd49f94 --- /dev/null +++ b/tests/Integration/Data/Essen/DatabaseTestCase.php @@ -0,0 +1,43 @@ +em = $container->get('doctrine')->getManager(); + + $schemaTool = new SchemaTool($this->em); + $schemaTool->dropSchema($this->em->getMetadataFactory()->getAllMetadata()); + $schemaTool->createSchema($this->em->getMetadataFactory()->getAllMetadata()); + } + + protected function tearDown(): void + { + $this->em->close(); + parent::tearDown(); + } + + /** @return list */ + protected function alleEntitaeten(): array + { + return $this->em->getRepository(MahlzeitEntity::class)->findAll(); + } +} diff --git a/tests/Integration/Data/Essen/MahlzeitMapperTest.php b/tests/Integration/Data/Essen/MahlzeitMapperTest.php new file mode 100644 index 0000000..672849f --- /dev/null +++ b/tests/Integration/Data/Essen/MahlzeitMapperTest.php @@ -0,0 +1,151 @@ +jetzt(); + $gegessenAm = $jetzt->modify('+1 day'); + $model = new Mahlzeit( + id: 0, + name: 'Bolognese', + notiz: 'süß', + wochentage: [Wochentag::Mo, Wochentag::Do, Wochentag::Fr], + sortOrder: 2, + status: MahlzeitStatus::Gegessen, + erstelltAm: $jetzt, + aktualisiertAm: $jetzt, + gegessenAm: $gegessenAm, + ); + + $entity = $this->mapper()->createEntity($model); + + self::assertSame('Bolognese', $entity->getName()); + self::assertSame('süß', $entity->getNotiz()); + self::assertSame('mo,do,fr', $entity->getWochentage()); + self::assertSame(2, $entity->getSortOrder()); + self::assertSame('gegessen', $entity->getStatus()); + self::assertSame($gegessenAm, $entity->getGegessenAm()); + self::assertSame($jetzt, $entity->getErstelltAm()); + } + + public function testLeereWochentageWerdenNull(): void + { + $jetzt = $this->jetzt(); + $model = new Mahlzeit(1, 'X', '', [], 0, MahlzeitStatus::Geplant, $jetzt, $jetzt, null); + + $entity = $this->mapper()->createEntity($model); + + self::assertNull($entity->getWochentage()); + } + + public function testLeereNotizWirdNull(): void + { + $jetzt = $this->jetzt(); + $model = new Mahlzeit(1, 'X', '', [], 0, MahlzeitStatus::Geplant, $jetzt, $jetzt, null); + + $entity = $this->mapper()->createEntity($model); + + self::assertNull($entity->getNotiz()); + } + + public function testEntityZuModelMapptAlleFelder(): void + { + $jetzt = $this->jetzt(); + $gegessenAm = $jetzt->modify('+1 day'); + + $entity = new MahlzeitEntity(); + $entity->setName('Linsen'); + $entity->setNotiz('scharf'); + $entity->setWochentage('mi,sa'); + $entity->setSortOrder(4); + $entity->setStatus('gegessen'); + $entity->setGegessenAm($gegessenAm); + $entity->setErstelltAm($jetzt); + $entity->setAktualisiertAm($jetzt); + + $model = $this->mapper()->toModel($entity); + + self::assertSame('Linsen', $model->name); + self::assertSame('scharf', $model->notiz); + self::assertSame([Wochentag::Mi, Wochentag::Sa], $model->wochentage()); + self::assertSame(4, $model->sortOrder); + self::assertSame(MahlzeitStatus::Gegessen, $model->status); + self::assertSame($gegessenAm, $model->gegessenAm); + self::assertTrue($model->istGegessen()); + } + + public function testEntityMitLeerenWochentageLiefertLeeresArray(): void + { + $jetzt = $this->jetzt(); + $entity = new MahlzeitEntity(); + $entity->setName('X'); + $entity->setNotiz(null); + $entity->setWochentage(null); + $entity->setSortOrder(0); + $entity->setStatus('geplant'); + $entity->setGegessenAm(null); + $entity->setErstelltAm($jetzt); + $entity->setAktualisiertAm($jetzt); + + $model = $this->mapper()->toModel($entity); + + self::assertSame([], $model->wochentage()); + self::assertSame('', $model->notiz); + self::assertTrue($model->istGeplant()); + } + + public function testUngültigeWochentagswerteWerdenGefiltert(): void + { + $jetzt = $this->jetzt(); + $entity = new MahlzeitEntity(); + $entity->setName('X'); + $entity->setWochentage('mo,xx,di'); + $entity->setSortOrder(0); + $entity->setStatus('geplant'); + $entity->setErstelltAm($jetzt); + $entity->setAktualisiertAm($jetzt); + + $model = $this->mapper()->toModel($entity); + + self::assertSame([Wochentag::Mo, Wochentag::Di], $model->wochentage()); + } + + public function testUpdateEntitySchreibtNeueWerte(): void + { + $jetzt = $this->jetzt(); + $entity = new MahlzeitEntity(); + $entity->setSortOrder(0); + $entity->setErstelltAm($jetzt); + $entity->setAktualisiertAm($jetzt); + + $model = new Mahlzeit(5, 'Neu', 'notiz', [Wochentag::So], 9, MahlzeitStatus::Geplant, $jetzt, $jetzt, null); + + $this->mapper()->updateEntity($model, $entity); + + self::assertSame('Neu', $entity->getName()); + self::assertSame('so', $entity->getWochentage()); + self::assertSame(9, $entity->getSortOrder()); + } +} diff --git a/tests/Integration/Data/Essen/MahlzeitProcessorTest.php b/tests/Integration/Data/Essen/MahlzeitProcessorTest.php new file mode 100644 index 0000000..f225e04 --- /dev/null +++ b/tests/Integration/Data/Essen/MahlzeitProcessorTest.php @@ -0,0 +1,95 @@ +em, new MahlzeitMapper()); + } + + private function jetzt(): \DateTimeImmutable + { + return new \DateTimeImmutable('2026-09-18 12:00:00'); + } + + public function testCreatePersistiertUndLiefertMitNeuerId(): void + { + $m = Mahlzeit::anlegen('Bolognese', 'süß', [Wochentag::Mo, Wochentag::Do], 0, $this->jetzt()); + + $gespeichert = $this->processor()->create($m); + + self::assertGreaterThan(0, $gespeichert->id); + self::assertCount(1, $this->alleEntitaeten()); + + $neu = (new MahlzeitProvider($this->em, new MahlzeitMapper()))->find($gespeichert->id); + self::assertNotNull($neu); + self::assertSame('Bolognese', $neu->name); + self::assertSame('süß', $neu->notiz); + self::assertSame([Wochentag::Mo, Wochentag::Do], $neu->wochentage()); + } + + public function testSaveAktualisiertBestehendeZeile(): void + { + $m = Mahlzeit::anlegen('Altes', '', [Wochentag::Mo], 1, $this->jetzt()); + $created = $this->processor()->create($m); + + $geaendert = $created->aktualisieren( + name: 'Neues', + notiz: 'scharf', + wochentage: [Wochentag::Fr], + sortOrder: $created->sortOrder, + jetzt: $this->jetzt(), + ); + + $gespeichert = $this->processor()->save($geaendert); + + self::assertSame($created->id, $gespeichert->id); + self::assertCount(1, $this->alleEntitaeten()); + + $neu = (new MahlzeitProvider($this->em, new MahlzeitMapper()))->find($created->id); + self::assertNotNull($neu); + self::assertSame('Neues', $neu->name); + self::assertSame('scharf', $neu->notiz); + self::assertSame([Wochentag::Fr], $neu->wochentage()); + } + + public function testSaveMitUnbekannterIdWirft404(): void + { + $m = Mahlzeit::anlegen('X', '', [], 0, $this->jetzt())->mitId(9999); + + $this->expectException(MahlzeitNotFoundException::class); + + $this->processor()->save($m); + } + + public function testDeleteEntferntZeile(): void + { + $m = Mahlzeit::anlegen('Bolognese', '', [], 1, $this->jetzt()); + $created = $this->processor()->create($m); + + $this->processor()->delete($created); + + self::assertCount(0, $this->alleEntitaeten()); + } + + public function testDeleteMitUnbekannterIdWirft404(): void + { + $m = Mahlzeit::anlegen('X', '', [], 0, $this->jetzt())->mitId(9999); + + $this->expectException(MahlzeitNotFoundException::class); + + $this->processor()->delete($m); + } +} diff --git a/tests/Integration/Data/Essen/MahlzeitProviderTest.php b/tests/Integration/Data/Essen/MahlzeitProviderTest.php new file mode 100644 index 0000000..151c124 --- /dev/null +++ b/tests/Integration/Data/Essen/MahlzeitProviderTest.php @@ -0,0 +1,97 @@ +em, new MahlzeitMapper()); + } + + private function jetzt(): \DateTimeImmutable + { + return new \DateTimeImmutable('2026-09-18 12:00:00'); + } + + private function speichere(string $name, int $sortOrder, string $status, array $tage): Mahlzeit + { + $jetzt = $this->jetzt(); + $m = Mahlzeit::anlegen($name, '', $tage, $sortOrder, $jetzt); + if ($status === 'gegessen') { + $m = $m->markAsGegessen($jetzt); + } + $entity = (new MahlzeitMapper())->createEntity($m); + $this->em->persist($entity); + $this->em->flush(); + return $m; + } + + public function testFindLiefertModell(): void + { + $m = $this->speichere('Bolognese', 1, 'geplant', [Wochentag::Mo]); + $id = $this->alleEntitaeten()[0]->getId(); + + $gefunden = $this->provider()->find($id); + + self::assertNotNull($gefunden); + self::assertSame('Bolognese', $gefunden->name); + self::assertSame($id, $gefunden->id); + } + + public function testFindLiefertNullBeiUnbekannterId(): void + { + self::assertNull($this->provider()->find(9999)); + } + + public function testListOhneFilterGibtAlleSortiertZurück(): void + { + $this->speichere('Zweite', 2, 'geplant', []); + $this->speichere('Erste', 1, 'geplant', []); + $this->speichere('Dritte', 3, 'gegessen', []); + + $liste = $this->provider()->list(); + + self::assertCount(3, $liste); + self::assertSame(['Erste', 'Zweite', 'Dritte'], \array_map(static fn (Mahlzeit $m) => $m->name, $liste)); + } + + public function testListMitStatusFilter(): void + { + $this->speichere('A', 1, 'geplant', []); + $this->speichere('B', 2, 'gegessen', []); + $this->speichere('C', 3, 'geplant', []); + + $geplante = $this->provider()->list(MahlzeitStatus::Geplant); + $gegessene = $this->provider()->list(MahlzeitStatus::Gegessen); + + self::assertCount(2, $geplante); + self::assertCount(1, $gegessene); + self::assertSame('B', $gegessene[0]->name); + } + + public function testMaxSortOrderLiefertMaximum(): void + { + $this->speichere('A', 1, 'geplant', []); + $this->speichere('B', 7, 'geplant', []); + $this->speichere('C', 3, 'geplant', []); + + self::assertSame(7, $this->provider()->maxSortOrder()); + } + + public function testMaxSortOrderLeereTabelleLiefertNullsafe(): void + { + // provider gibt null->0 zurück + $max = $this->provider()->maxSortOrder(); + + self::assertIsInt($max); + } +} diff --git a/tests/Unit/Logic/Essen/Mapping/MahlzeitFactoryTest.php b/tests/Unit/Logic/Essen/Mapping/MahlzeitFactoryTest.php new file mode 100644 index 0000000..94d1a06 --- /dev/null +++ b/tests/Unit/Logic/Essen/Mapping/MahlzeitFactoryTest.php @@ -0,0 +1,111 @@ +jetzt(); + $req = new MahlzeitCreateRequest( + name: 'Pasta', + notiz: 'vegan', + wochentage: [Wochentag::Fr, Wochentag::Sa], + ); + + $m = $this->factory()->createFromCreateRequest($req, $jetzt); + + self::assertSame(0, $m->id); + self::assertSame('Pasta', $m->name); + self::assertSame('vegan', $m->notiz); + self::assertSame([Wochentag::Fr, Wochentag::Sa], $m->wochentage()); + self::assertSame(0, $m->sortOrder); + self::assertSame(MahlzeitStatus::Geplant, $m->status); + self::assertSame($jetzt, $m->erstelltAm); + self::assertSame($jetzt, $m->aktualisiertAm); + } + + public function testCreateWithLeerenNameWirft(): void + { + $req = new MahlzeitCreateRequest(name: ' '); + + $this->expectException(BusinessRuleViolationException::class); + + $this->factory()->createFromCreateRequest($req, $this->jetzt()); + } + + public function testUpdateÄndertNurGelieferteFelder(): void + { + $vorher = new Mahlzeit( + id: 7, + name: 'Altes', + notiz: 'alte Notiz', + wochentage: [Wochentag::Mo], + sortOrder: 3, + status: MahlzeitStatus::Geplant, + erstelltAm: $this->jetzt(), + aktualisiertAm: $this->jetzt(), + gegessenAm: null, + ); + + $req = new MahlzeitUpdateRequest(name: 'Neues', wochentage: [Wochentag::Do]); + + $nachher = $this->factory()->updateFromUpdateRequest($vorher, $req, $this->jetzt()); + + self::assertSame('Neues', $nachher->name); + self::assertSame([Wochentag::Do], $nachher->wochentage()); + self::assertSame('alte Notiz', $nachher->notiz, 'nicht geliefertes Feld bleibt unverändert'); + self::assertSame(3, $nachher->sortOrder); + self::assertSame(7, $nachher->id); + } + + public function testUpdateMitNullFeldBelässtBestandwert(): void + { + $vorher = new Mahlzeit(7, 'Altes', 'Notiz', [Wochentag::Mo], 3, MahlzeitStatus::Geplant, $this->jetzt(), $this->jetzt(), null); + + $nachher = $this->factory()->updateFromUpdateRequest($vorher, new MahlzeitUpdateRequest(), $this->jetzt()); + + self::assertSame('Altes', $nachher->name); + self::assertSame('Notiz', $nachher->notiz); + self::assertSame([Wochentag::Mo], $nachher->wochentage()); + } + + public function testUpdateMitLeerenNameWirft(): void + { + $vorher = new Mahlzeit(7, 'Altes', '', [], 0, MahlzeitStatus::Geplant, $this->jetzt(), $this->jetzt(), null); + + $this->expectException(BusinessRuleViolationException::class); + + $this->factory()->updateFromUpdateRequest($vorher, new MahlzeitUpdateRequest(name: ' '), $this->jetzt()); + } + + public function testUpdateLeereWochentageListeSetztKeineTage(): void + { + $vorher = new Mahlzeit(7, 'Altes', '', [Wochentag::Mo], 0, MahlzeitStatus::Geplant, $this->jetzt(), $this->jetzt(), null); + + $nachher = $this->factory()->updateFromUpdateRequest($vorher, new MahlzeitUpdateRequest(wochentage: []), $this->jetzt()); + + self::assertSame([], $nachher->wochentage()); + } +} diff --git a/tests/Unit/Logic/Essen/Model/MahlzeitTest.php b/tests/Unit/Logic/Essen/Model/MahlzeitTest.php new file mode 100644 index 0000000..c28be93 --- /dev/null +++ b/tests/Unit/Logic/Essen/Model/MahlzeitTest.php @@ -0,0 +1,157 @@ +jetzt(), + ); + } + + public function testAnlegenErzeugtGeplanteMahlzeit(): void + { + $m = $this->geplant(); + + self::assertSame(0, $m->id); + self::assertSame('Spaghetti Bolognese', $m->name); + self::assertSame('mit mehr Soße', $m->notiz); + self::assertSame([Wochentag::Mo, Wochentag::Di], $m->wochentage()); + self::assertSame(1, $m->sortOrder); + self::assertSame(MahlzeitStatus::Geplant, $m->status); + self::assertNull($m->gegessenAm); + self::assertTrue($m->istGeplant()); + } + + public function testAnlegenTrimmtNameUndNotiz(): void + { + $m = Mahlzeit::anlegen(' Bolognese ', ' Notiz ', [], 0, $this->jetzt()); + + self::assertSame('Bolognese', $m->name); + self::assertSame('Notiz', $m->notiz); + } + + public function testMarkAsGegessenSetztStatusZeitpunktUndAktualisiertAm(): void + { + $m = $this->geplant(); + $später = $this->jetzt()->modify('+2 days'); + + $gegessen = $m->markAsGegessen($später); + + self::assertSame(MahlzeitStatus::Gegessen, $gegessen->status); + self::assertSame($später, $gegessen->gegessenAm); + self::assertSame($später, $gegessen->aktualisiertAm); + self::assertTrue($gegessen->istGegessen()); + // Ursprung bleibt unverändert (Unveränderlichkeit) + self::assertSame(MahlzeitStatus::Geplant, $m->status); + } + + public function testMarkAsGeplantSetztGegessenAmZurück(): void + { + $gegessen = $this->geplant()->markAsGegessen($this->jetzt()); + + $geplant = $gegessen->markAsGeplant($this->jetzt()); + + self::assertSame(MahlzeitStatus::Geplant, $geplant->status); + self::assertNull($geplant->gegessenAm); + self::assertTrue($geplant->istGeplant()); + } + + public function testMarkAsGegessenIstIdempotent(): void + { + $m = $this->geplant(); + $einmal = $m->markAsGegessen($this->jetzt()); + $zweimal = $einmal->markAsGegessen($this->jetzt()); + + self::assertSame($einmal->gegessenAm, $zweimal->gegessenAm); + self::assertSame(MahlzeitStatus::Gegessen, $zweimal->status); + } + + public function testAktualisierenÄndertInhaltUndBelässtStatus(): void + { + $gegessen = $this->geplant()->markAsGegessen($this->jetzt()); + + $aktualisiert = $gegessen->aktualisieren( + name: 'Linsen', + notiz: 'scharf', + wochentage: [Wochentag::Mi], + sortOrder: $gegessen->sortOrder, + jetzt: $this->jetzt(), + ); + + self::assertSame('Linsen', $aktualisiert->name); + self::assertSame('scharf', $aktualisiert->notiz); + self::assertSame([Wochentag::Mi], $aktualisiert->wochentage()); + self::assertSame(MahlzeitStatus::Gegessen, $aktualisiert->status); + self::assertNotNull($aktualisiert->gegessenAm); + } + + public function testAktualisierenMitLeerenNameWirft(): void + { + $this->expectException(BusinessRuleViolationException::class); + + $this->geplant()->aktualisieren(' ', '', [], 1, $this->jetzt()); + } + + public function testMitSortOrderNegativWirft(): void + { + $this->expectException(BusinessRuleViolationException::class); + + $this->geplant()->mitSortOrder(-1); + } + + public function testMitSortOrderÄndertNurPosition(): void + { + $m = $this->geplant()->mitSortOrder(5); + + self::assertSame(5, $m->sortOrder); + self::assertSame('Spaghetti Bolognese', $m->name); + } + + public function testMitIdSetztPersistenzId(): void + { + $m = $this->geplant()->mitId(42); + + self::assertSame(42, $m->id); + } + + public function testGegessenOhneZeitpunktWirft(): void + { + $this->expectException(BusinessRuleViolationException::class); + + new Mahlzeit(1, 'X', '', [], 0, MahlzeitStatus::Gegessen, $this->jetzt(), $this->jetzt(), null); + } + + public function testGeplanteMitGegessenAmWirft(): void + { + $this->expectException(BusinessRuleViolationException::class); + + new Mahlzeit(1, 'X', '', [], 0, MahlzeitStatus::Geplant, $this->jetzt(), $this->jetzt(), $this->jetzt()); + } + + public function testUngültigerWochentagWirft(): void + { + $this->expectException(\TypeError::class); + + Mahlzeit::anlegen('X', '', ['kein-tag'], 0, $this->jetzt()); + } +} diff --git a/tests/Unit/Logic/Essen/UseCase/MahlzeitCreateUseCaseTest.php b/tests/Unit/Logic/Essen/UseCase/MahlzeitCreateUseCaseTest.php new file mode 100644 index 0000000..84b1074 --- /dev/null +++ b/tests/Unit/Logic/Essen/UseCase/MahlzeitCreateUseCaseTest.php @@ -0,0 +1,112 @@ +jetzt(); + return new Mahlzeit($id, $name, '', [], 1, MahlzeitStatus::Geplant, $jetzt, $jetzt, null); + } + + /** Stub: ruft den übergebenen Closure einfach aus (simuliert die Transaktion). */ + private function tx(): TransactionManagerInterface + { + $tx = $this->createStub(TransactionManagerInterface::class); + $tx->method('execute')->willReturnCallback(static fn (\Closure $work): mixed => $work($work)); + return $tx; + } + + private function clock(): ClockInterface + { + $clock = $this->createStub(ClockInterface::class); + $clock->method('now')->willReturn($this->jetzt()); + return $clock; + } + + public function testExecutePersistiertUndLiefertResponse(): void + { + $gespeichert = $this->mahlzeitMitId(10, 'Bolognese'); + + $erfasstesModell = null; + $manager = $this->createMock(MahlzeitManagerInterface::class); + $manager->expects(self::once()) + ->method('create') + ->willReturnCallback(function (Mahlzeit $m) use (&$erfasstesModell, $gespeichert) { + $erfasstesModell = $m; + return $gespeichert; + }); + + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects(self::once()) + ->method('dispatch') + ->with(self::equalTo(new MahlzeitCreatedEvent(id: 10, name: 'Bolognese'))); + + $useCase = new MahlzeitCreateUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock()); + + $response = $useCase->execute(new MahlzeitCreateRequest(name: 'Bolognese', notiz: 'süß')); + + self::assertSame('Bolognese', $erfasstesModell->name, 'Übergebenes Modell trägt den Namen aus dem Request'); + self::assertSame('süß', $erfasstesModell->notiz); + self::assertInstanceOf(MahlzeitCreateResponse::class, $response); + self::assertSame(10, $response->id); + self::assertSame('Bolognese', $response->mahlzeit->name); + } + + public function testExecuteDispatchtEventNachCommit(): void + { + $mahlzeit = $this->mahlzeitMitId(1, 'X'); + + $manager = $this->createStub(MahlzeitManagerInterface::class); + $manager->method('create')->willReturn($mahlzeit); + + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects(self::once()) + ->method('dispatch') + ->with(self::equalTo(new MahlzeitCreatedEvent(id: 1, name: 'X'))); + + $useCase = new MahlzeitCreateUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock()); + + $useCase->execute(new MahlzeitCreateRequest(name: 'X')); + } + + public function testExecuteDispatchtKeinEventBeiFehler(): void + { + $manager = $this->createStub(MahlzeitManagerInterface::class); + $manager->method('create')->willThrowException(new \RuntimeException('DB down')); + + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects(self::never())->method('dispatch'); + + $useCase = new MahlzeitCreateUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock()); + + $this->expectException(\RuntimeException::class); + + try { + $useCase->execute(new MahlzeitCreateRequest(name: 'X')); + } finally { + // Erwartete Exception oben. + } + } +} diff --git a/tests/Unit/Logic/Essen/UseCase/MahlzeitDeleteUseCaseTest.php b/tests/Unit/Logic/Essen/UseCase/MahlzeitDeleteUseCaseTest.php new file mode 100644 index 0000000..9260e80 --- /dev/null +++ b/tests/Unit/Logic/Essen/UseCase/MahlzeitDeleteUseCaseTest.php @@ -0,0 +1,69 @@ +createStub(TransactionManagerInterface::class); + $tx->method('execute')->willReturnCallback(static fn (\Closure $work): mixed => $work($work)); + return $tx; + } + + public function testExecuteLöschtUndDispatchtEvent(): void + { + $jetzt = $this->jetzt(); + $mahlzeit = new Mahlzeit(7, 'Bolognese', '', [], 1, MahlzeitStatus::Geplant, $jetzt, $jetzt, null); + + $manager = $this->createMock(MahlzeitManagerInterface::class); + $manager->expects(self::once())->method('find')->with(7)->willReturn($mahlzeit); + $manager->expects(self::once())->method('delete')->with(7); + + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects(self::once()) + ->method('dispatch') + ->with(self::equalTo(new MahlzeitDeletedEvent(id: 7, name: 'Bolognese'))); + + $useCase = new MahlzeitDeleteUseCase($this->tx(), $manager, $events); + + $useCase->execute(7); + } + + public function testExecuteDispatchtKeinEventBeiFehler(): void + { + $manager = $this->createStub(MahlzeitManagerInterface::class); + $manager->method('find')->willThrowException(new MahlzeitNotFoundException(99)); + + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects(self::never())->method('dispatch'); + + $useCase = new MahlzeitDeleteUseCase($this->tx(), $manager, $events); + + $this->expectException(MahlzeitNotFoundException::class); + + try { + $useCase->execute(99); + } finally { + // Erwartete Exception oben. + } + } +} diff --git a/tests/Unit/Logic/Essen/UseCase/MahlzeitUpdateUseCaseTest.php b/tests/Unit/Logic/Essen/UseCase/MahlzeitUpdateUseCaseTest.php new file mode 100644 index 0000000..39e0bc2 --- /dev/null +++ b/tests/Unit/Logic/Essen/UseCase/MahlzeitUpdateUseCaseTest.php @@ -0,0 +1,146 @@ +createStub(TransactionManagerInterface::class); + $tx->method('execute')->willReturnCallback(static fn (\Closure $work): mixed => $work($work)); + return $tx; + } + + private function clock(): ClockInterface + { + $clock = $this->createStub(ClockInterface::class); + $clock->method('now')->willReturn($this->jetzt()); + return $clock; + } + + private function bestehend(): Mahlzeit + { + $jetzt = $this->jetzt(); + return new Mahlzeit(5, 'Altes', 'alte Notiz', [Wochentag::Mo, Wochentag::Di], 3, MahlzeitStatus::Geplant, $jetzt, $jetzt, null); + } + + public function testExecuteAktualisiertNameNotizUndWochentage(): void + { + $erfasstesModell = null; + $manager = $this->createMock(MahlzeitManagerInterface::class); + $manager->expects(self::once())->method('find')->with(5)->willReturn($this->bestehend()); + $manager->expects(self::once()) + ->method('update') + ->willReturnCallback(function (Mahlzeit $m) use (&$erfasstesModell) { + $erfasstesModell = $m; + return $m; + }); + + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects(self::once()) + ->method('dispatch') + ->with(self::equalTo(new MahlzeitUpdatedEvent(id: 5, name: 'Neues'))); + + $useCase = new MahlzeitUpdateUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock()); + + $response = $useCase->execute(5, new MahlzeitUpdateRequest(name: 'Neues', notiz: 'neu', wochentage: [Wochentag::Fr])); + + self::assertSame('Neues', $erfasstesModell->name); + self::assertSame('neu', $erfasstesModell->notiz); + self::assertSame([Wochentag::Fr], $erfasstesModell->wochentage()); + self::assertInstanceOf(MahlzeitUpdateResponse::class, $response); + self::assertSame(5, $response->id); + self::assertSame('Neues', $response->mahlzeit->name); + } + + public function testExecuteLässtUndefinierteFelderUnerändert(): void + { + $alt = $this->bestehend(); + $erfasstesModell = null; + + $manager = $this->createMock(MahlzeitManagerInterface::class); + $manager->expects(self::once())->method('find')->with(5)->willReturn($alt); + $manager->expects(self::once()) + ->method('update') + ->willReturnCallback(function (Mahlzeit $m) use (&$erfasstesModell) { + $erfasstesModell = $m; + return $m; + }); + + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects(self::once())->method('dispatch'); + + $useCase = new MahlzeitUpdateUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock()); + + $useCase->execute(5, new MahlzeitUpdateRequest(name: null, notiz: null, wochentage: null)); + + self::assertSame('Altes', $erfasstesModell->name, 'nicht geliefertes Feld bleibt unverändert'); + self::assertSame('alte Notiz', $erfasstesModell->notiz); + self::assertSame([Wochentag::Mo, Wochentag::Di], $erfasstesModell->wochentage()); + } + + public function testExecuteLeertWochentageMitEmptyArray(): void + { + $erfasstesModell = null; + + $manager = $this->createMock(MahlzeitManagerInterface::class); + $manager->expects(self::once())->method('find')->with(5)->willReturn($this->bestehend()); + $manager->expects(self::once()) + ->method('update') + ->willReturnCallback(function (Mahlzeit $m) use (&$erfasstesModell) { + $erfasstesModell = $m; + return $m; + }); + + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects(self::once())->method('dispatch'); + + $useCase = new MahlzeitUpdateUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock()); + + $useCase->execute(5, new MahlzeitUpdateRequest(wochentage: [])); + + self::assertSame([], $erfasstesModell->wochentage()); + } + + public function testExecuteWirft404WennNichtVorhanden(): void + { + $manager = $this->createStub(MahlzeitManagerInterface::class); + $manager->method('find')->willThrowException(new MahlzeitNotFoundException(99)); + + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects(self::never())->method('dispatch'); + + $useCase = new MahlzeitUpdateUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock()); + + $this->expectException(MahlzeitNotFoundException::class); + + try { + $useCase->execute(99, new MahlzeitUpdateRequest(name: 'X')); + } finally { + // Erwartete Exception oben. + } + } +}