M1: CRUD-Slice 'Mahlzeit' Schritt 1 – Logik + Daten + Tests
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).
This commit is contained in:
+150
-132
@@ -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) | `<Model>Status` | `MahlzeitStatus` (`Geplant`, `Gegessen`) |
|
||||
| Domain-Enum (Wert) | Substantiv, Werte kurz | `Wochentag` (`Mo`…`So`) |
|
||||
| DTOs | `<Model><Aktion>{Request,Response}` | `MahlzeitCreateRequest`, `MahlzeitListResponse` |
|
||||
| Fabrik (DTO→Model) | `<Model>Factory` | `MahlzeitFactory` |
|
||||
| UseCase | `<Model><Aktion>UseCase` | `MahlzeitCreateUseCase` |
|
||||
| Read-Query | `<Model><Aktion>Query` | `MahlzeitListQuery` |
|
||||
| Manager | `<Model>Manager` | `MahlzeitManager` |
|
||||
| Provider (Read) | `<Model>Provider(Interface)` | `MahlzeitProvider` |
|
||||
| Processor (Write) | `<Model>Processor(Interface)` | `MahlzeitProcessor` |
|
||||
| Mapper | `<Model>Mapper` | `MahlzeitMapper` |
|
||||
| Entity | `<Model>Entity` | `MahlzeitEntity` |
|
||||
| Exception | `<Model>…Exception` | `MahlzeitNotFoundException` |
|
||||
| Events | `<Model><Aktion>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<Mahlzeit> */
|
||||
public function listGegessen(): array;
|
||||
/** @return Dish[] */
|
||||
/** @return list<Mahlzeit> */
|
||||
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).
|
||||
|
||||
+11
-6
@@ -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`
|
||||
|
||||
@@ -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/'
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260918213517 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 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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Essen\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* Doctrine-Entity für `essens_mahlzeiten`.
|
||||
*
|
||||
* Bewusst "dumm": nur Getter/Setter, keine Fachlogik (die lebt im Rich-Model
|
||||
* `App\Logic\Essen\Model\Mahlzeit`). `wochentage` wird als CSV-String
|
||||
* ("mo,di,fr") persistiert – das Mapping ins `Wochentag[]`-Array macht der Mapper.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'essens_mahlzeiten')]
|
||||
#[ORM\Index(columns: ['status'], name: 'idx_status')]
|
||||
#[ORM\Index(columns: ['sort_order'], name: 'idx_sort_order')]
|
||||
final class MahlzeitEntity
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
private string $name = '';
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $notiz = null;
|
||||
|
||||
/** CSV der Wochentag-Kurzformen, z. B. "mo,di,fr". */
|
||||
#[ORM\Column(type: 'string', length: 32, nullable: true)]
|
||||
private ?string $wochentage = null;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $sortOrder = 0;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 32)]
|
||||
private string $status = 'geplant';
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
private ?\DateTimeImmutable $gegessenAm = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $erstelltAm;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $aktualisiertAm;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Essen\Mapper;
|
||||
|
||||
use App\Data\Essen\Entity\MahlzeitEntity;
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||
use App\Logic\Essen\Model\Wochentag;
|
||||
|
||||
/**
|
||||
* Stateless-Mapping zwischen `MahlzeitEntity` und dem Rich-Model `Mahlzeit`.
|
||||
*
|
||||
* Hier leben die "unbequemen" Persistenz-Konvertierungen:
|
||||
* - `wochentage`: CSV-String ("mo,di,fr") ↔ `Wochentag[]`
|
||||
* - `status`: String ("geplant") ↔ `MahlzeitStatus`-Enum
|
||||
* - `notiz`: nullable-String ↔ nicht-leerer String
|
||||
*/
|
||||
final class MahlzeitMapper
|
||||
{
|
||||
public function toModel(MahlzeitEntity $entity): Mahlzeit
|
||||
{
|
||||
return new Mahlzeit(
|
||||
id: (int) $entity->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<Wochentag> */
|
||||
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<Wochentag> $tage */
|
||||
private function wochentageToCsv(array $tage): ?string
|
||||
{
|
||||
if ($tage === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode(',', Wochentag::zuWerten($tage));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Essen\Processor;
|
||||
|
||||
use App\Data\Essen\Entity\MahlzeitEntity;
|
||||
use App\Data\Essen\Mapper\MahlzeitMapper;
|
||||
use App\Logic\Essen\Exception\MahlzeitNotFoundException;
|
||||
use App\Logic\Essen\MahlzeitProcessorInterface;
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* Write-Zugriff auf den Mahlzeiten-Datenbestand (Doctrine-Implementierung).
|
||||
*
|
||||
* `create` persistiert ein neues Modell und liefert es mit der neuen DB-ID zurück.
|
||||
* `save`/`delete` setzen die ID voraus und werfen `MahlzeitNotFoundException` (→ 404).
|
||||
*/
|
||||
final class MahlzeitProcessor implements MahlzeitProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly MahlzeitMapper $mapper,
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(Mahlzeit $mahlzeit): Mahlzeit
|
||||
{
|
||||
$entity = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Essen\Provider;
|
||||
|
||||
use App\Data\Essen\Entity\MahlzeitEntity;
|
||||
use App\Data\Essen\Mapper\MahlzeitMapper;
|
||||
use App\Logic\Essen\MahlzeitProviderInterface;
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* Read-Zugriff auf den Mahlzeiten-Datenbestand (Doctrine-Implementierung).
|
||||
*/
|
||||
final class MahlzeitProvider implements MahlzeitProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly MahlzeitMapper $mapper,
|
||||
) {
|
||||
}
|
||||
|
||||
public function find(int $id): ?Mahlzeit
|
||||
{
|
||||
$entity = $this->em->getRepository(MahlzeitEntity::class)->find($id);
|
||||
|
||||
return $entity instanceof MahlzeitEntity ? $this->mapper->toModel($entity) : null;
|
||||
}
|
||||
|
||||
/** @return list<Mahlzeit> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Infrastructure;
|
||||
|
||||
use App\Logic\Common\ClockInterface;
|
||||
|
||||
/**
|
||||
* Bridge: liefert "jetzt" aus der Systemuhr für die Logik-Layer.
|
||||
*/
|
||||
final class SystemClock implements ClockInterface
|
||||
{
|
||||
public function now(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Common;
|
||||
|
||||
/**
|
||||
* Liefer "jetzt" für die Logik-Layer – macht Zeitstempel testbar/bar.
|
||||
*/
|
||||
interface ClockInterface
|
||||
{
|
||||
public function now(): \DateTimeImmutable;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\Dto;
|
||||
|
||||
use App\Logic\Essen\Model\Wochentag;
|
||||
|
||||
/**
|
||||
* Request-DTO: eine Mahlzeit anlegen.
|
||||
*
|
||||
* `notiz` und `wochentage` sind optional. Der Zeitpunkt (`erstelltAm`/`aktualisiertAm`)
|
||||
* wird nicht mitgeliefert, sondern vom UseCase über `ClockInterface` gestempelt.
|
||||
* Die Reihenfolge (`sortOrder`) wird beim Anlegen automatisch am Ende angehängt.
|
||||
*
|
||||
* @param non-empty-string $name
|
||||
* @param list<Wochentag> $wochentage
|
||||
*/
|
||||
final class MahlzeitCreateRequest
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
public readonly string $notiz = '',
|
||||
public readonly array $wochentage = [],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\Dto;
|
||||
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
|
||||
/**
|
||||
* Response-DTO: Anlegen abgeschlossen. Trägt die neue ID + das fertige Read-Modell.
|
||||
*/
|
||||
final class MahlzeitCreateResponse
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $id,
|
||||
public readonly MahlzeitResponse $mahlzeit,
|
||||
) {}
|
||||
|
||||
public static function fromModel(Mahlzeit $m): self
|
||||
{
|
||||
return new self(id: $m->id, mahlzeit: MahlzeitResponse::fromModel($m));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\Dto;
|
||||
|
||||
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||
|
||||
/**
|
||||
* Request-DTO: Mahlzeiten-Liste anfragen.
|
||||
*
|
||||
* `status = null` → alle Einträge (geplant + gegessen), sortiert nach `sortOrder`.
|
||||
*/
|
||||
final class MahlzeitListRequest
|
||||
{
|
||||
public function __construct(
|
||||
public readonly ?MahlzeitStatus $status = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\Dto;
|
||||
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
|
||||
/**
|
||||
* Response-DTO: eine (gefilterte) Liste von Mahlzeiten, sortiert nach `sortOrder`.
|
||||
*
|
||||
* @param list<MahlzeitResponse> $items
|
||||
*/
|
||||
final class MahlzeitListResponse
|
||||
{
|
||||
public function __construct(
|
||||
public readonly array $items,
|
||||
) {}
|
||||
|
||||
/** @param list<Mahlzeit> $mahlzeiten */
|
||||
public static function fromModels(array $mahlzeiten): self
|
||||
{
|
||||
$items = [];
|
||||
foreach ($mahlzeiten as $m) {
|
||||
$items[] = MahlzeitResponse::fromModel($m);
|
||||
}
|
||||
|
||||
return new self(items: $items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\Dto;
|
||||
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||
use App\Logic\Essen\Model\Wochentag;
|
||||
|
||||
/**
|
||||
* Read-Modell einer einzelnen Mahlzeit für Controller/Antworten.
|
||||
*
|
||||
* @param list<Wochentag> $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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\Dto;
|
||||
|
||||
use App\Logic\Essen\Model\Wochentag;
|
||||
|
||||
/**
|
||||
* Request-DTO: eine bestehende Mahlzeit bearbeiten (Teil-Update).
|
||||
*
|
||||
* `null` = Feld bleibt unverändert. `wochentage: []` = "keine Wochentage" setzen.
|
||||
*
|
||||
* @param list<Wochentag>|null $wochentage
|
||||
*/
|
||||
final class MahlzeitUpdateRequest
|
||||
{
|
||||
public function __construct(
|
||||
public readonly ?string $name = null,
|
||||
public readonly ?string $notiz = null,
|
||||
public readonly ?array $wochentage = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\Dto;
|
||||
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
|
||||
/**
|
||||
* Response-DTO: Bearbeitung abgeschlossen. Trägt die ID + das aktualisierte Read-Modell.
|
||||
*/
|
||||
final class MahlzeitUpdateResponse
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $id,
|
||||
public readonly MahlzeitResponse $mahlzeit,
|
||||
) {}
|
||||
|
||||
public static function fromModel(Mahlzeit $m): self
|
||||
{
|
||||
return new self(id: $m->id, mahlzeit: MahlzeitResponse::fromModel($m));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\Event;
|
||||
|
||||
/**
|
||||
* Domain-Event: eine Mahlzeit wurde angelegt (nach erfolgreichem Commit).
|
||||
* Trägt nur, was ein Subscriber braucht (State-Carry, kein ORM-Wissen).
|
||||
*/
|
||||
final readonly class MahlzeitCreatedEvent
|
||||
{
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public string $name,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\Event;
|
||||
|
||||
/**
|
||||
* Domain-Event: eine Mahlzeit wurde gelöscht (nach erfolgreichem Commit).
|
||||
*/
|
||||
final readonly class MahlzeitDeletedEvent
|
||||
{
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public string $name,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\Event;
|
||||
|
||||
/**
|
||||
* Domain-Event: eine Mahlzeit wurde bearbeitet (nach erfolgreichem Commit).
|
||||
*/
|
||||
final readonly class MahlzeitUpdatedEvent
|
||||
{
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public string $name,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\Exception;
|
||||
|
||||
use App\Logic\Common\Exception\DomainException;
|
||||
|
||||
/**
|
||||
* Mahlzeit existiert nicht → HTTP 404 (über `ExceptionSubscriber`).
|
||||
*
|
||||
* `ResourceNotFoundException` ist final, daher erbt hier direkt von der
|
||||
* abstrakten `DomainException` (gleicher Status/Vertrag).
|
||||
*/
|
||||
final class MahlzeitNotFoundException extends DomainException
|
||||
{
|
||||
public function __construct(int $id)
|
||||
{
|
||||
parent::__construct(\sprintf('Mahlzeit (id=%d) nicht gefunden.', $id));
|
||||
}
|
||||
|
||||
public function getHttpStatusCode(): int
|
||||
{
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen;
|
||||
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
|
||||
/**
|
||||
* Schreiben von Mahlzeiten (Persistenz).
|
||||
* Implementiert von `App\Data\Essen\Processor\MahlzeitProcessor` (Doctrine).
|
||||
*/
|
||||
interface MahlzeitProcessorInterface
|
||||
{
|
||||
/** Persistiert ein neues Modell und gibt es mit der neuen ID zurück. */
|
||||
public function create(Mahlzeit $mahlzeit): Mahlzeit;
|
||||
|
||||
/** Schreibt die Änderungen eines bestehenden Modells zurück. */
|
||||
public function save(Mahlzeit $mahlzeit): Mahlzeit;
|
||||
|
||||
/** Entfernt das Modell dauerhaft. */
|
||||
public function delete(Mahlzeit $mahlzeit): void;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen;
|
||||
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||
|
||||
/**
|
||||
* Lesen des Mahlzeiten-Datenbestands (Zugriff auf die persistierten Daten).
|
||||
* Implementiert von `App\Data\Essen\Provider\MahlzeitProvider` (Doctrine).
|
||||
*/
|
||||
interface MahlzeitProviderInterface
|
||||
{
|
||||
public function find(int $id): ?Mahlzeit;
|
||||
|
||||
/**
|
||||
* @return list<Mahlzeit> sortiert nach sortOrder aufsteigend
|
||||
*/
|
||||
public function list(?MahlzeitStatus $status = null): array;
|
||||
|
||||
/** Größte vorhandene sortOrder-Position (0, falls leer). */
|
||||
public function maxSortOrder(): int;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\Manager;
|
||||
|
||||
use App\Logic\Essen\Exception\MahlzeitNotFoundException;
|
||||
use App\Logic\Essen\MahlzeitProviderInterface;
|
||||
use App\Logic\Essen\MahlzeitProcessorInterface;
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||
|
||||
/**
|
||||
* Standard-Implementierung: kombiniert Provider (Read) + Processor (Write).
|
||||
*
|
||||
* @see MahlzeitManagerInterface
|
||||
*/
|
||||
final class MahlzeitManager implements MahlzeitManagerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MahlzeitProviderInterface $provider,
|
||||
private readonly MahlzeitProcessorInterface $processor,
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(Mahlzeit $mahlzeit): Mahlzeit
|
||||
{
|
||||
$mahlzeit = $mahlzeit->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<Mahlzeit> */
|
||||
public function list(?MahlzeitStatus $status = null): array
|
||||
{
|
||||
return $this->provider->list($status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\Manager;
|
||||
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||
|
||||
/**
|
||||
* Read/Write-Koordination pro Business-Model `Mahlzeit`.
|
||||
*
|
||||
* `find` wirft `MahlzeitNotFoundException` (→ 404), wenn der Eintrag nicht existiert.
|
||||
* Cache-Invalidierung ist bewusst ausgelassen (Phase 2, s. architektur.md §6).
|
||||
*/
|
||||
interface MahlzeitManagerInterface
|
||||
{
|
||||
/** Hängt das Modell am Ende der Planungsliste an und persistiert es. */
|
||||
public function create(Mahlzeit $mahlzeit): Mahlzeit;
|
||||
|
||||
public function update(Mahlzeit $mahlzeit): Mahlzeit;
|
||||
|
||||
public function delete(int $id): void;
|
||||
|
||||
public function find(int $id): Mahlzeit;
|
||||
|
||||
/** @return list<Mahlzeit> sortiert nach sortOrder aufsteigend */
|
||||
public function list(?MahlzeitStatus $status = null): array;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\Mapping;
|
||||
|
||||
use App\Logic\Common\Exception\BusinessRuleViolationException;
|
||||
use App\Logic\Essen\Dto\MahlzeitCreateRequest;
|
||||
use App\Logic\Essen\Dto\MahlzeitUpdateRequest;
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
|
||||
/**
|
||||
* Reine Funktionen: Request-DTO → Modell. Keine Framework-/DB-Abhängigkeit.
|
||||
*
|
||||
* Fachregeln (Pflichtfelder) werden hier als Business-Validierung geprüft.
|
||||
* Zeitstempel (`erstelltAm`/`aktualisiertAm`) liefert der UseCase über `ClockInterface`.
|
||||
*/
|
||||
final class MahlzeitFactory
|
||||
{
|
||||
/**
|
||||
* Erstellt ein neues, geplantes Modell aus einem Anlegen-DTO.
|
||||
*
|
||||
* `sortOrder` wird bewusst auf 0 gesetzt – der Manager hängt den Eintrag
|
||||
* anschließend am Ende an (max+1).
|
||||
*/
|
||||
public function createFromCreateRequest(MahlzeitCreateRequest $req, \DateTimeImmutable $jetzt): Mahlzeit
|
||||
{
|
||||
$this->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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\Model;
|
||||
|
||||
use App\Logic\Common\Exception\BusinessRuleViolationException;
|
||||
|
||||
/**
|
||||
* Aggregat "Mahlzeit" – reines Rich Domain Model (POPO, kein Framework).
|
||||
*
|
||||
* Unveränderlich: Zustandsübergaben geben ein neues Modell zurück und kapseln
|
||||
* dabei die fachlichen Regeln (Status + Zeitstempel). `sort_order` wird nicht
|
||||
* über `mitSortOrder` verändert, sondern über eine dedizierte Transition.
|
||||
*/
|
||||
final class Mahlzeit
|
||||
{
|
||||
/**
|
||||
* @param int $id
|
||||
* @param non-empty-string $name
|
||||
* @param non-empty-string $notiz
|
||||
* @param list<Wochentag> $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<Wochentag> $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<Wochentag> $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<Wochentag> */
|
||||
public function wochentage(): array
|
||||
{
|
||||
return $this->wochentage;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param non-empty-string $name
|
||||
* @param non-empty-string $notiz
|
||||
* @param list<Wochentag> $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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\Model;
|
||||
|
||||
enum MahlzeitStatus: string
|
||||
{
|
||||
case Geplant = 'geplant';
|
||||
case Gegessen = 'gegessen';
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\Model;
|
||||
|
||||
/**
|
||||
* Wochentag als Domänenwert.
|
||||
*
|
||||
* Kurzform-Bezeichner (ISO-nah) werden als stabilen Storage-Wert genutzt
|
||||
* (z. B. in der CSV-Spalte der Datenbank).
|
||||
*/
|
||||
enum Wochentag: string
|
||||
{
|
||||
case Mo = 'mo';
|
||||
case Di = 'di';
|
||||
case Mi = 'mi';
|
||||
case Do = 'do';
|
||||
case Fr = 'fr';
|
||||
case Sa = 'sa';
|
||||
case So = 'so';
|
||||
|
||||
/** Sortierte Werteliste (Mo…So) für UI- und Storage-Zwecke. */
|
||||
public static function alle(): array
|
||||
{
|
||||
return self::cases();
|
||||
}
|
||||
|
||||
public static function fromValue(string $value): ?self
|
||||
{
|
||||
return self::tryFrom($value);
|
||||
}
|
||||
|
||||
/** @param list<string> $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<string> */
|
||||
public static function zuWerten(array $tage): array
|
||||
{
|
||||
return \array_map(static fn (self $tag): string => $tag->value, $tage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\Query;
|
||||
|
||||
use App\Logic\Essen\Dto\MahlzeitListRequest;
|
||||
use App\Logic\Essen\Dto\MahlzeitListResponse;
|
||||
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
|
||||
|
||||
/**
|
||||
* Read-Query: Mahlzeiten-Liste (geplant, gegessen oder alle).
|
||||
* Sortierung erfolgt im Provider nach `sortOrder` aufsteigend.
|
||||
*/
|
||||
final readonly class MahlzeitListQuery
|
||||
{
|
||||
public function __construct(
|
||||
private MahlzeitManagerInterface $mahlzeiten,
|
||||
) {
|
||||
}
|
||||
|
||||
public function execute(MahlzeitListRequest $req): MahlzeitListResponse
|
||||
{
|
||||
$mahlzeiten = $this->mahlzeiten->list($req->status);
|
||||
|
||||
return MahlzeitListResponse::fromModels($mahlzeiten);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\UseCase;
|
||||
|
||||
use App\Logic\Common\ClockInterface;
|
||||
use App\Logic\Common\EventDispatcherInterface;
|
||||
use App\Logic\Common\TransactionManagerInterface;
|
||||
use App\Logic\Essen\Dto\MahlzeitCreateRequest;
|
||||
use App\Logic\Essen\Dto\MahlzeitCreateResponse;
|
||||
use App\Logic\Essen\Event\MahlzeitCreatedEvent;
|
||||
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
|
||||
use App\Logic\Essen\Mapping\MahlzeitFactory;
|
||||
|
||||
/**
|
||||
* Use-Case: eine Mahlzeit anlegen.
|
||||
*
|
||||
* Ablauf: DTO → Modell (Factory) → Persistenz (Manager, in Transaktion)
|
||||
* → Event (nach Commit) → Response.
|
||||
*/
|
||||
final readonly class MahlzeitCreateUseCase
|
||||
{
|
||||
public function __construct(
|
||||
private TransactionManagerInterface $tx,
|
||||
private MahlzeitManagerInterface $mahlzeiten,
|
||||
private MahlzeitFactory $factory,
|
||||
private EventDispatcherInterface $events,
|
||||
private ClockInterface $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function execute(MahlzeitCreateRequest $req): MahlzeitCreateResponse
|
||||
{
|
||||
$jetzt = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\UseCase;
|
||||
|
||||
use App\Logic\Common\EventDispatcherInterface;
|
||||
use App\Logic\Common\TransactionManagerInterface;
|
||||
use App\Logic\Essen\Event\MahlzeitDeletedEvent;
|
||||
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
|
||||
|
||||
/**
|
||||
* Use-Case: eine Mahlzeit hart löschen.
|
||||
*
|
||||
* Wirft `MahlzeitNotFoundException` (→ 404), wenn die ID unbekannt ist.
|
||||
*/
|
||||
final readonly class MahlzeitDeleteUseCase
|
||||
{
|
||||
public function __construct(
|
||||
private TransactionManagerInterface $tx,
|
||||
private MahlzeitManagerInterface $mahlzeiten,
|
||||
private EventDispatcherInterface $events,
|
||||
) {
|
||||
}
|
||||
|
||||
public function execute(int $id): void
|
||||
{
|
||||
$bestehend = $this->mahlzeiten->find($id);
|
||||
|
||||
$this->tx->execute(fn () => $this->mahlzeiten->delete($id));
|
||||
|
||||
$this->events->dispatch(new MahlzeitDeletedEvent(id: $id, name: $bestehend->name));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logic\Essen\UseCase;
|
||||
|
||||
use App\Logic\Common\ClockInterface;
|
||||
use App\Logic\Common\EventDispatcherInterface;
|
||||
use App\Logic\Common\TransactionManagerInterface;
|
||||
use App\Logic\Essen\Dto\MahlzeitUpdateRequest;
|
||||
use App\Logic\Essen\Dto\MahlzeitUpdateResponse;
|
||||
use App\Logic\Essen\Event\MahlzeitUpdatedEvent;
|
||||
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
|
||||
use App\Logic\Essen\Mapping\MahlzeitFactory;
|
||||
|
||||
/**
|
||||
* Use-Case: eine bestehende Mahlzeit bearbeiten (Teil-Update).
|
||||
*
|
||||
* Wirft `MahlzeitNotFoundException` (→ 404), wenn die ID unbekannt ist.
|
||||
*/
|
||||
final readonly class MahlzeitUpdateUseCase
|
||||
{
|
||||
public function __construct(
|
||||
private TransactionManagerInterface $tx,
|
||||
private MahlzeitManagerInterface $mahlzeiten,
|
||||
private MahlzeitFactory $factory,
|
||||
private EventDispatcherInterface $events,
|
||||
private ClockInterface $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function execute(int $id, MahlzeitUpdateRequest $req): MahlzeitUpdateResponse
|
||||
{
|
||||
$jetzt = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Integration\Data\Essen;
|
||||
|
||||
use App\Data\Essen\Entity\MahlzeitEntity;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Tools\SchemaTool;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* Basis für DB-Integrationstests auf In-Memory-SQLite:
|
||||
* Kernel booten, Schema erzeugen, sauberen EntityManager liefern.
|
||||
*/
|
||||
abstract class DatabaseTestCase extends KernelTestCase
|
||||
{
|
||||
protected EntityManagerInterface $em;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$container = static::getContainer();
|
||||
$this->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<MahlzeitEntity> */
|
||||
protected function alleEntitaeten(): array
|
||||
{
|
||||
return $this->em->getRepository(MahlzeitEntity::class)->findAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Integration\Data\Essen;
|
||||
|
||||
use App\Data\Essen\Entity\MahlzeitEntity;
|
||||
use App\Data\Essen\Mapper\MahlzeitMapper;
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||
use App\Logic\Essen\Model\Wochentag;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class MahlzeitMapperTest extends TestCase
|
||||
{
|
||||
private function mapper(): MahlzeitMapper
|
||||
{
|
||||
return new MahlzeitMapper();
|
||||
}
|
||||
|
||||
private function jetzt(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable('2026-09-18 12:00:00');
|
||||
}
|
||||
|
||||
public function testModelZuEntityMapptAlleFelder(): void
|
||||
{
|
||||
$jetzt = $this->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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Integration\Data\Essen;
|
||||
|
||||
use App\Data\Essen\Mapper\MahlzeitMapper;
|
||||
use App\Data\Essen\Processor\MahlzeitProcessor;
|
||||
use App\Data\Essen\Provider\MahlzeitProvider;
|
||||
use App\Logic\Essen\Exception\MahlzeitNotFoundException;
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||
use App\Logic\Essen\Model\Wochentag;
|
||||
|
||||
final class MahlzeitProcessorTest extends DatabaseTestCase
|
||||
{
|
||||
private function processor(): MahlzeitProcessor
|
||||
{
|
||||
return new MahlzeitProcessor($this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Integration\Data\Essen;
|
||||
|
||||
use App\Data\Essen\Mapper\MahlzeitMapper;
|
||||
use App\Data\Essen\Provider\MahlzeitProvider;
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||
use App\Logic\Essen\Model\Wochentag;
|
||||
|
||||
final class MahlzeitProviderTest extends DatabaseTestCase
|
||||
{
|
||||
private function provider(): MahlzeitProvider
|
||||
{
|
||||
return new MahlzeitProvider($this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Logic\Essen\Mapping;
|
||||
|
||||
use App\Logic\Common\Exception\BusinessRuleViolationException;
|
||||
use App\Logic\Essen\Dto\MahlzeitCreateRequest;
|
||||
use App\Logic\Essen\Dto\MahlzeitUpdateRequest;
|
||||
use App\Logic\Essen\Mapping\MahlzeitFactory;
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||
use App\Logic\Essen\Model\Wochentag;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class MahlzeitFactoryTest extends TestCase
|
||||
{
|
||||
private function factory(): MahlzeitFactory
|
||||
{
|
||||
return new MahlzeitFactory();
|
||||
}
|
||||
|
||||
private function jetzt(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable('2026-09-18 12:00:00');
|
||||
}
|
||||
|
||||
public function testCreateFromCreateRequest(): void
|
||||
{
|
||||
$jetzt = $this->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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Logic\Essen\Model;
|
||||
|
||||
use App\Logic\Common\Exception\BusinessRuleViolationException;
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||
use App\Logic\Essen\Model\Wochentag;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class MahlzeitTest extends TestCase
|
||||
{
|
||||
private function jetzt(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable('2026-09-18 12:00:00');
|
||||
}
|
||||
|
||||
private function geplant(): Mahlzeit
|
||||
{
|
||||
return Mahlzeit::anlegen(
|
||||
name: 'Spaghetti Bolognese',
|
||||
notiz: 'mit mehr Soße',
|
||||
wochentage: [Wochentag::Mo, Wochentag::Di],
|
||||
sortOrder: 1,
|
||||
jetzt: $this->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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Logic\Essen\UseCase;
|
||||
|
||||
use App\Logic\Common\ClockInterface;
|
||||
use App\Logic\Common\EventDispatcherInterface;
|
||||
use App\Logic\Common\TransactionManagerInterface;
|
||||
use App\Logic\Essen\Dto\MahlzeitCreateRequest;
|
||||
use App\Logic\Essen\Dto\MahlzeitCreateResponse;
|
||||
use App\Logic\Essen\Event\MahlzeitCreatedEvent;
|
||||
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
|
||||
use App\Logic\Essen\Mapping\MahlzeitFactory;
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||
use App\Logic\Essen\UseCase\MahlzeitCreateUseCase;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class MahlzeitCreateUseCaseTest extends TestCase
|
||||
{
|
||||
private function jetzt(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable('2026-09-18 12:00:00');
|
||||
}
|
||||
|
||||
private function mahlzeitMitId(int $id, string $name): Mahlzeit
|
||||
{
|
||||
$jetzt = $this->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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Logic\Essen\UseCase;
|
||||
|
||||
use App\Logic\Common\EventDispatcherInterface;
|
||||
use App\Logic\Common\TransactionManagerInterface;
|
||||
use App\Logic\Essen\Event\MahlzeitDeletedEvent;
|
||||
use App\Logic\Essen\Exception\MahlzeitNotFoundException;
|
||||
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||
use App\Logic\Essen\UseCase\MahlzeitDeleteUseCase;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class MahlzeitDeleteUseCaseTest extends TestCase
|
||||
{
|
||||
private function jetzt(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable('2026-09-18 12:00:00');
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Logic\Essen\UseCase;
|
||||
|
||||
use App\Logic\Common\ClockInterface;
|
||||
use App\Logic\Common\EventDispatcherInterface;
|
||||
use App\Logic\Common\TransactionManagerInterface;
|
||||
use App\Logic\Essen\Dto\MahlzeitUpdateRequest;
|
||||
use App\Logic\Essen\Dto\MahlzeitUpdateResponse;
|
||||
use App\Logic\Essen\Event\MahlzeitUpdatedEvent;
|
||||
use App\Logic\Essen\Exception\MahlzeitNotFoundException;
|
||||
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
|
||||
use App\Logic\Essen\Mapping\MahlzeitFactory;
|
||||
use App\Logic\Essen\Model\Mahlzeit;
|
||||
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||
use App\Logic\Essen\Model\Wochentag;
|
||||
use App\Logic\Essen\UseCase\MahlzeitUpdateUseCase;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class MahlzeitUpdateUseCaseTest extends TestCase
|
||||
{
|
||||
private function jetzt(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable('2026-09-18 12:00:00');
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user