Compare commits
21
Commits
cb3cbfb394
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b246a6d04e | ||
|
|
9c148a1242 | ||
|
|
ac4a2af7cb | ||
|
|
502dacd868 | ||
|
|
92d0327857 | ||
|
|
e943d50c9c | ||
|
|
15de8e5516 | ||
|
|
ca9ffe7e46 | ||
|
|
176394785f | ||
|
|
76690c919c | ||
|
|
4be948557e | ||
|
|
7cc1aa90b5 | ||
|
|
4e5efa9cb9 | ||
|
|
f4c20d0504 | ||
|
|
0db09bf098 | ||
|
|
fa5894426d | ||
|
|
52a34225fa | ||
|
|
ce77785003 | ||
|
|
3519ff08f2 | ||
|
|
160b89f5a1 | ||
|
|
1589088379 |
@@ -1,10 +1,10 @@
|
|||||||
# Referenzwerte für die Produktion (docker1: /root/stacks/svc-essensplanung/.env,
|
# Referenzwerte für die Produktion (docker1: /root/stacks/svc-essensplanung/.env.local,
|
||||||
# als env_file über compose gemountet – siehe Doku/plan.md M5).
|
# als env_file über compose gemountet – siehe Doku/plan.md M5).
|
||||||
# Lokale Entwicklung nutzt stattdessen .env (var/data.db).
|
# Lokale Entwicklung nutzt die gleiche DB (postgresql.lan).
|
||||||
APP_ENV=prod
|
APP_ENV=prod
|
||||||
APP_DEBUG=0
|
APP_DEBUG=0
|
||||||
APP_SECRET=<random-64-hex>
|
APP_SECRET=<random-64-hex>
|
||||||
DATABASE_URL=sqlite:////data/essens.db
|
DATABASE_URL=postgresql://essens:<passwort>@postgresql.lan:5432/essens?serverVersion=15&charset=utf8
|
||||||
KEYCLOAK_ISSUER=https://iam.ljh31.de/realms/family
|
KEYCLOAK_ISSUER=https://iam.ljh31.de/realms/family
|
||||||
KEYCLOAK_CLIENT_ID=essens
|
KEYCLOAK_CLIENT_ID=essens
|
||||||
KEYCLOAK_CLIENT_SECRET=<keycloak-client-secret>
|
KEYCLOAK_CLIENT_SECRET=<keycloak-client-secret>
|
||||||
|
|||||||
+6
-5
@@ -1,20 +1,21 @@
|
|||||||
FROM php:8.4-frankenphp
|
FROM dunglas/frankenphp:1.12.7-php8.4
|
||||||
|
|
||||||
ENV APP_ENV=prod \
|
ENV APP_ENV=prod \
|
||||||
COMPOSER_ALLOW_SUPERUSER=1
|
COMPOSER_ALLOW_SUPERUSER=1 \
|
||||||
|
SERVER_NAME=:8787
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends git unzip sqlite3 \
|
&& apt-get install -y --no-install-recommends git unzip sqlite3 libicu-dev libpq-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN docker-php-ext-install pdo pdo_sqlite opcache bcmath intl
|
RUN docker-php-ext-install bcmath intl pgsql pdo_pgsql
|
||||||
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# composer.json zuerst → Layer-Cache
|
# composer.json zuerst → Layer-Cache
|
||||||
COPY composer.json composer.lock ./
|
COPY composer.json composer.lock ./
|
||||||
RUN composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
|
RUN composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist --no-scripts
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
+151
-133
@@ -3,6 +3,30 @@
|
|||||||
> Konkretisierung der ADR-007 für diese App. Referenz:
|
> Konkretisierung der ADR-007 für diese App. Referenz:
|
||||||
> [jens/symfony-architektur](https://git.ljh31.de/jens/symfony-architektur).
|
> [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)
|
## 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
|
(Definition im Data). Die Data-Layer implementiert Interfaces aus der
|
||||||
Logic-Layer. **Keine** Umkehr.
|
Logic-Layer. **Keine** Umkehr.
|
||||||
|
|
||||||
**Namenskonvention (Kurz):**
|
**Namenskonvention (Kurz)** — siehe §0.1 Namenskonzept:
|
||||||
- Interface: Suffixe `Interface` (z. B. `DishProviderInterface`).
|
`MahlzeitProviderInterface`, `MahlzeitCreateUseCase`, `MahlzeitListQuery`,
|
||||||
- UseCase: Suffix `UseCase` (z. B. `CreateDishUseCase`).
|
`Manager`, `Provider`/`Processor`, `Mapper`, `MahlzeitFactory` (statt `*ModelFactory`),
|
||||||
- Query: Suffix `Query` (z. B. `ListDishesQuery`).
|
`Create/Update/Delete/…Request`, `…Response`, Exception, `Entity`.
|
||||||
- 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`).
|
|
||||||
|
|
||||||
## 1. Domain-Modell (Logik)
|
## 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**
|
(Gericht/Eintrag). Wichtiger Hinweis: Die "Archivsicht" ist **kein**
|
||||||
abgesonderter Business-Model, sondern eine **Volltext-Sicht** (Read-Query) mit
|
abgesonderter Business-Model, sondern eine **Volltext-Sicht** (Read-Query) mit
|
||||||
dem Filter `status = gegessen`. Das reduziert die Komplexität.
|
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):
|
kein Framework):
|
||||||
|
|
||||||
```php
|
```php
|
||||||
namespace App\Logic\Essen\Model;
|
namespace App\Logic\Essen\Model;
|
||||||
|
|
||||||
class Dish
|
class Mahlzeit
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly ?int $id,
|
public readonly ?int $id,
|
||||||
public string $name,
|
public string $name,
|
||||||
public ?string $notiz,
|
public ?string $notiz,
|
||||||
/** @var DishDay[] */
|
/** @var Wochentag[] */
|
||||||
public readonly array $wochentage,
|
public readonly array $wochentage,
|
||||||
public readonly int $sort_order,
|
public readonly int $sort_order,
|
||||||
public DishStatus $status,
|
public MahlzeitStatus $status,
|
||||||
public readonly ?\DateTimeImmutable $gegessen_am,
|
public readonly ?\DateTimeImmutable $gegessen_am,
|
||||||
public readonly \DateTimeImmutable $erstellt_am,
|
public readonly \DateTimeImmutable $erstellt_am,
|
||||||
public readonly \DateTimeImmutable $aktualisiert_am,
|
public readonly \DateTimeImmutable $aktualisiert_am,
|
||||||
@@ -70,11 +87,11 @@ class Dish
|
|||||||
|
|
||||||
// State transitions: kapseln eigene Statusänderungen
|
// State transitions: kapseln eigene Statusänderungen
|
||||||
public function markAsGegessen(\DateTimeImmutable $now): self {
|
public function markAsGegessen(\DateTimeImmutable $now): self {
|
||||||
if ($this->status === DishStatus::GEGESSEN) {
|
if ($this->status === MahlzeitStatus::GEGESSEN) {
|
||||||
throw new BusinessRuleViolationException('Bereits gegessen.');
|
throw new BusinessRuleViolationException('Bereits gegessen.');
|
||||||
}
|
}
|
||||||
return new self($this->id, $this->name, $this->notiz, $this->wochentage,
|
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 */ }
|
public function markAsGeplant(\DateTimeImmutable $now): self { /* symmetrisch */ }
|
||||||
@@ -82,23 +99,24 @@ class Dish
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Enums** in `src/Logic/Essen/Model/`:
|
**Enums** in `src/Logic/Essen/Model/`:
|
||||||
- `DishStatus` (`GEPLANT`, `GEGESSEN`)
|
- `MahlzeitStatus` (`GEPLANT`, `GEGESSEN`)
|
||||||
- `DishDay` (`MO`, `DI`, `MI`, `DO`, `FR`, `SA`, `SO`)
|
- `Wochentag` (`MO`, `DI`, `MI`, `DO`, `FR`, `SA`, `SO`)
|
||||||
|
|
||||||
### 1.2. DTOs
|
### 1.2. DTOs
|
||||||
|
|
||||||
```
|
```
|
||||||
src/Logic/Essen/Dto/
|
src/Logic/Essen/Dto/
|
||||||
├── CreateDishRequest.php // name, notiz?, wochentag[]?
|
├── MahlzeitCreateRequest.php // name, notiz?, wochentage?: Wochentag[]
|
||||||
├── UpdateDishRequest.php // id, name, notiz?, wochentag[]?, sort_order?
|
├── MahlzeitUpdateRequest.php // name, notiz?, wochentage? (sort_order via M3-Reorder)
|
||||||
├── DeleteDishRequest.php // id
|
├── MahlzeitMoveRequest.php // id, position (int)
|
||||||
├── MoveDishRequest.php // id, position (int)
|
├── MahlzeitToggleStatusRequest.php // id
|
||||||
├── ToggleDishStatusRequest.php // id
|
├── MahlzeitListRequest.php // status? (optional), sort='sort_order'|'gegessen_am'
|
||||||
├── ListDishesRequest.php // status? (optional), sort='sort_order'|'gegessen_am'
|
├── MahlzeitListResponse.php // items: Mahlzeit[]
|
||||||
├── ListDishesResponse.php // items: Dish[]
|
├── MahlzeitCreateResponse.php // id, mahlzeit
|
||||||
|
├── MahlzeitUpdateResponse.php // id, mahlzeit
|
||||||
├── InspirationRequest.php // limit=5, offset=0, excludeIds: int[]
|
├── InspirationRequest.php // limit=5, offset=0, excludeIds: int[]
|
||||||
├── InspirationResponse.php // suggestions: Dish[]
|
├── InspirationResponse.php // suggestions: Mahlzeit[]
|
||||||
└── TodayResponse.php // dishes: Dish[] // optional HA-Consumer
|
└── TodayResponse.php // mahlzeiten: Mahlzeit[] // optional HA-Consumer
|
||||||
```
|
```
|
||||||
|
|
||||||
Alle `readonly`, native Typisierung, **keine** Attribute/Constraints.
|
Alle `readonly`, native Typisierung, **keine** Attribute/Constraints.
|
||||||
@@ -107,43 +125,43 @@ Alle `readonly`, native Typisierung, **keine** Attribute/Constraints.
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/Logic/Essen/UseCase/
|
src/Logic/Essen/UseCase/
|
||||||
├── CreateDishUseCase.php
|
├── MahlzeitCreateUseCase.php // execute(MahlzeitCreateRequest): MahlzeitCreateResponse
|
||||||
├── UpdateDishUseCase.php
|
├── MahlzeitUpdateUseCase.php // execute(int $id, MahlzeitUpdateRequest): MahlzeitUpdateResponse
|
||||||
├── DeleteDishUseCase.php
|
├── MahlzeitDeleteUseCase.php // execute(int $id): void
|
||||||
├── MoveDishUseCase.php // ändert sort_order (bzw. sort_order-Reihenfolge)
|
├── MahlzeitMoveUseCase.php // ändert sort_order
|
||||||
└── ToggleDishStatusUseCase.php // markAsGegessen / markAsGeplant
|
└── MahlzeitToggleStatusUseCase.php // markAsGegessen / markAsGeplant
|
||||||
```
|
```
|
||||||
|
|
||||||
**Verfahren (schlau aus Referenz Sektion 1 & 3):**
|
**Verfahren (schlau aus Referenz Sektion 1 & 3):**
|
||||||
1. Syntaktische Validierung im UI-Layer (Controller/Command) als **rein
|
1. Syntaktische Validierung im UI-Layer (Controller/Command) als **rein
|
||||||
PHP** (keine Symfony-Validator-Constraints auf DTOs).
|
PHP** (keine Symfony-Validator-Constraints auf DTOs).
|
||||||
2. Business-Validierung im UseCase + im Model-Konstruktor (Invarianzen).
|
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.
|
(Write); Cache-Sidecar-Invalidierung im Manager.
|
||||||
4. Event-Dispatch nach erfolgreichem Commit (Bridge in Data-Layer).
|
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
|
```php
|
||||||
namespace App\Logic\Essen\UseCase;
|
namespace App\Logic\Essen\UseCase;
|
||||||
|
|
||||||
readonly class CreateDishUseCase
|
readonly class MahlzeitCreateUseCase
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private TransactionManagerInterface $tx,
|
private TransactionManagerInterface $tx,
|
||||||
private DishManagerInterface $dishes,
|
private MahlzeitManagerInterface $mahlzeiten,
|
||||||
private DishModelFactory $factory,
|
private MahlzeitFactory $factory,
|
||||||
private EventDispatcherInterface $events,
|
private EventDispatcherInterface $events,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function execute(CreateDishRequest $req): CreateDishResponse
|
public function execute(MahlzeitCreateRequest $req): MahlzeitCreateResponse
|
||||||
{
|
{
|
||||||
return $this->tx->execute(function() use ($req): CreateDishResponse {
|
return $this->tx->execute(function() use ($req): MahlzeitCreateResponse {
|
||||||
$dish = $this->factory->createFromRequest($req); // DTO -> Model
|
$mahlzeit = $this->factory->createFromCreateRequest($req); // DTO -> Model
|
||||||
$saved = $this->dishes->create($dish); // Model -> Processor
|
$gespeichert = $this->mahlzeiten->create($mahlzeit); // Model -> Processor
|
||||||
$this->events->dispatch(new DishCreatedEvent(id: $saved->id));
|
$this->events->dispatch(new MahlzeitCreatedEvent(id: $gespeichert->id));
|
||||||
return new CreateDishResponse(id: $saved->id, dish: $saved);
|
return new MahlzeitCreateResponse(id: $gespeichert->id, mahlzeit: $gespeichert);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,8 +171,8 @@ readonly class CreateDishUseCase
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/Logic/Essen/Query/
|
src/Logic/Essen/Query/
|
||||||
├── ListDishesQuery.php // -> ListDishesResponse
|
├── MahlzeitListQuery.php // -> MahlzeitListResponse
|
||||||
├── GetDishQuery.php // -> Dish (optional)
|
├── MahlzeitGetQuery.php // -> Mahlzeit (optional)
|
||||||
├── TodayQuery.php // -> TodayResponse (HA-Consumer / optional)
|
├── TodayQuery.php // -> TodayResponse (HA-Consumer / optional)
|
||||||
└── InspirationQuery.php // -> InspirationResponse (5er-Batch + Offset)
|
└── InspirationQuery.php // -> InspirationResponse (5er-Batch + Offset)
|
||||||
```
|
```
|
||||||
@@ -166,28 +184,28 @@ readonly class InspirationQuery
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private InspirationWeightSettings $settings, // w_slow/w_rare/w_hot
|
private InspirationWeightSettings $settings, // w_slow/w_rare/w_hot
|
||||||
private DishManagerInterface $dishes,
|
private MahlzeitManagerInterface $mahlzeiten,
|
||||||
private DishHistoryProviderInterface $history, // aus Data-Layer
|
private MahlzeitHistoryProviderInterface $history, // aus Data-Layer
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function execute(InspirationRequest $req): InspirationResponse
|
public function execute(InspirationRequest $req): InspirationResponse
|
||||||
{
|
{
|
||||||
$all = $this->dishes->listGegessen();
|
$all = $this->mahlzeiten->listGegessen();
|
||||||
$history = $this->history->historyByDishName();
|
$history = $this->history->historyByMahlzeitName();
|
||||||
|
|
||||||
$scored = $all
|
$scored = $all
|
||||||
->filter(fn(Dish $d) => !in_array($d->id, $req->excludeIds, true))
|
->filter(fn(Mahlzeit $m) => !in_array($m->id, $req->excludeIds, true))
|
||||||
->map(fn(Dish $d) => new Scored(
|
->map(fn(Mahlzeit $m) => new Scored(
|
||||||
dish: $d,
|
mahlzeit: $m,
|
||||||
score: $this->score($d, $history),
|
score: $this->score($m, $history),
|
||||||
))
|
))
|
||||||
->sortDescByScore()
|
->sortDescByScore()
|
||||||
->slice($req->offset, $req->limit);
|
->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
|
// w_slow * staleness + w_rare * rarity + w_hot * popularity
|
||||||
// (Details: ADR-006)
|
// (Details: ADR-006)
|
||||||
}
|
}
|
||||||
@@ -198,24 +216,24 @@ readonly class InspirationQuery
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/Logic/Essen/Manager/
|
src/Logic/Essen/Manager/
|
||||||
├── DishManagerInterface.php
|
├── MahlzeitManagerInterface.php
|
||||||
└── DishManager.php // Read + Write-Koordination, Cache-Aside (vorerst
|
└── MahlzeitManager.php // Read + Write-Koordination, Cache-Aside (vorerst
|
||||||
// ohne Cache, Platzhalter für Phase 2)
|
// ohne Cache, Platzhalter für Phase 2)
|
||||||
```
|
```
|
||||||
|
|
||||||
Interface (in Logic):
|
Interface (in Logic):
|
||||||
|
|
||||||
```php
|
```php
|
||||||
interface DishManagerInterface
|
interface MahlzeitManagerInterface
|
||||||
{
|
{
|
||||||
public function create(Dish $dish): Dish;
|
public function create(Mahlzeit $mahlzeit): Mahlzeit;
|
||||||
public function update(Dish $dish): Dish;
|
public function update(Mahlzeit $mahlzeit): Mahlzeit;
|
||||||
public function delete(int $id): void;
|
public function delete(int $id): void;
|
||||||
public function move(Dish $dish, int $newSortOrder): void;
|
public function move(Mahlzeit $mahlzeit, int $newSortOrder): void;
|
||||||
public function find(int $id): Dish;
|
public function find(int $id): Mahlzeit; // -> MahlzeitNotFoundException (404)
|
||||||
/** @return Dish[] */
|
/** @return list<Mahlzeit> */
|
||||||
public function listGegessen(): array;
|
public function listGegessen(): array;
|
||||||
/** @return Dish[] */
|
/** @return list<Mahlzeit> */
|
||||||
public function listGeplant(): array;
|
public function listGeplant(): array;
|
||||||
public function invalidateCache(): void;
|
public function invalidateCache(): void;
|
||||||
}
|
}
|
||||||
@@ -225,8 +243,8 @@ interface DishManagerInterface
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/Logic/Essen/
|
src/Logic/Essen/
|
||||||
├── DishProviderInterface.php // Read: find, listGegessen, listGeplant, countByName...
|
├── MahlzeitProviderInterface.php // Read: find, listGegessen, listGeplant, countByName...
|
||||||
└── DishProcessorInterface.php // Write: create, save, delete; move (sort_order)
|
└── MahlzeitProcessorInterface.php // Write: create, save, delete; move (sort_order)
|
||||||
```
|
```
|
||||||
|
|
||||||
Implementierungen liegen in `src/Data/Essen/Provider/` bzw. `Processor/`.
|
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)
|
### 1.7. Model-Factory (DTO -> Model)
|
||||||
|
|
||||||
```
|
```
|
||||||
src/Logic/Essen/Mapping/DishModelFactory.php
|
src/Logic/Essen/Mapping/MahlzeitFactory.php
|
||||||
├── createFromRequest(CreateDishRequest): Dish
|
├── createFromCreateRequest(MahlzeitCreateRequest): Mahlzeit
|
||||||
├── updateFromRequest(UpdateDishRequest): Dish
|
├── updateFromUpdateRequest(Mahlzeit, MahlzeitUpdateRequest): Mahlzeit
|
||||||
└── rebuildFromPartialUpdate(Dish $current, UpdateDishRequest $req): Dish
|
└── ...
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.8. Domänen-Exceptions
|
### 1.8. Domänen-Exceptions
|
||||||
@@ -252,7 +270,7 @@ src/Logic/Common/Exception/
|
|||||||
└── InfrastructureException.php // -> 503
|
└── InfrastructureException.php // -> 503
|
||||||
|
|
||||||
src/Logic/Essen/Exception/
|
src/Logic/Essen/Exception/
|
||||||
└── DishNotFoundException.php // erbt ResourceNotFoundException
|
└── MahlzeitNotFoundException.php // erbt ResourceNotFoundException
|
||||||
```
|
```
|
||||||
|
|
||||||
**HTTP-Mapping** erfolgt zentral in `src/UI/Http/ExceptionSubscriber.php`
|
**HTTP-Mapping** erfolgt zentral in `src/UI/Http/ExceptionSubscriber.php`
|
||||||
@@ -275,59 +293,59 @@ src/Logic/Common/
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/Data/Essen/
|
src/Data/Essen/
|
||||||
├── Entity/DishEntity.php // #[ORM\Entity] – Doctrine, Attribute erlaubt!
|
├── Entity/MahlzeitEntity.php // #[ORM\Entity] – Doctrine, Attribute erlaubt!
|
||||||
├── Provider/DishProvider.php // implementiert DishProviderInterface
|
├── Provider/MahlzeitProvider.php // implementiert MahlzeitProviderInterface
|
||||||
├── Processor/DishProcessor.php // implementiert DishProcessorInterface
|
├── Processor/MahlzeitProcessor.php // implementiert MahlzeitProcessorInterface
|
||||||
├── Mapper/DishMapper.php // toModel / createEntity / updateEntity
|
├── Mapper/MahlzeitMapper.php // toModel / createEntity / updateEntity
|
||||||
└── HistoryProvider/DishHistoryProvider.php // (optional, für Inspiration)
|
└── HistoryProvider/MahlzeitHistoryProvider.php // (optional, für Inspiration)
|
||||||
|
|
||||||
src/Data/Infrastructure/
|
src/Data/Infrastructure/
|
||||||
├── Doctrine/EntityManagerTransactionManager.php
|
├── Doctrine/EntityManagerTransactionManager.php
|
||||||
└── SymfonyEventDispatcherBridge.php
|
└── SymfonyEventDispatcherBridge.php
|
||||||
```
|
```
|
||||||
|
|
||||||
**Doctrine Entity** (`DishEntity`):
|
**Doctrine Entity** (`MahlzeitEntity`):
|
||||||
|
|
||||||
```php
|
```php
|
||||||
#[ORM\Entity, ORM\Table(name: 'essens_dishes')]
|
#[ORM\Entity, ORM\Table(name: 'essens_mahlzeiten')]
|
||||||
class DishEntity
|
class MahlzeitEntity
|
||||||
{
|
{
|
||||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 255)]
|
#[ORM\Column(type: 'string', length: 255)]
|
||||||
private string $name = '';
|
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
|
// nur Getter/Setter, keine Logik
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Mapper** (stateless):
|
**Mapper** (stateless):
|
||||||
- `toModel(DishEntity): Dish`
|
- `toModel(MahlzeitEntity): Mahlzeit`
|
||||||
- `createEntity(Dish): DishEntity`
|
- `createEntity(Mahlzeit): MahlzeitEntity`
|
||||||
- `updateEntity(Dish, DishEntity): void`
|
- `updateEntity(Mahlzeit, MahlzeitEntity): void`
|
||||||
|
|
||||||
**Processor** (Write):
|
**Processor** (Write):
|
||||||
|
|
||||||
```php
|
```php
|
||||||
readonly class DishProcessor implements DishProcessorInterface
|
readonly class MahlzeitProcessor implements MahlzeitProcessorInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private EntityManagerInterface $em,
|
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);
|
$entity = $this->mapper->createEntity($model);
|
||||||
$this->em->persist($entity);
|
$this->em->persist($entity);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
return $this->mapper->toModel($entity); // mit id aus DB
|
return $this->mapper->toModel($entity); // mit id aus DB
|
||||||
}
|
}
|
||||||
|
|
||||||
public function save(Dish $model): Dish {
|
public function save(Mahlzeit $model): Mahlzeit {
|
||||||
$entity = $this->em->getRepository(DishEntity::class)->find($model->id);
|
$entity = $this->em->getRepository(MahlzeitEntity::class)->find($model->id);
|
||||||
if ($entity === null) throw new DishNotFoundException($model->id);
|
if ($entity === null) throw new MahlzeitNotFoundException($model->id);
|
||||||
$this->mapper->updateEntity($model, $entity);
|
$this->mapper->updateEntity($model, $entity);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
return $model;
|
return $model;
|
||||||
@@ -340,13 +358,13 @@ readonly class DishProcessor implements DishProcessorInterface
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/UI/Http/Essen/
|
src/UI/Http/Essen/
|
||||||
├── DishController.php // CRUD, toggle, move (sort_order)
|
├── MahlzeitController.php // CRUD, toggle, move (sort_order)
|
||||||
├── InspirationController.php // 5er-Batch
|
├── InspirationController.php // 5er-Batch
|
||||||
├── TodayController.php // optional: HA-Consumer (GET /api/today)
|
├── TodayController.php // optional: HA-Consumer (GET /api/today)
|
||||||
└── ExceptionSubscriber.php // zentrales Domain-Exception → HTTP-Mapping
|
└── ExceptionSubscriber.php // zentrales Domain-Exception → HTTP-Mapping
|
||||||
|
|
||||||
src/UI/Twig/ // Twig-Templates (htmx-Fragmente)
|
templates/ // Twig-Templates (htmx-Fragmente)
|
||||||
├── dish/
|
├── mahlzeiten/
|
||||||
│ ├── index.html.twig // Planungsliste + Archiv-Tab
|
│ ├── index.html.twig // Planungsliste + Archiv-Tab
|
||||||
│ ├── form.html.twig // Erstellen/Bearbeiten
|
│ ├── form.html.twig // Erstellen/Bearbeiten
|
||||||
│ └── item.html.twig // Eintrag (li) – SortableJS
|
│ └── item.html.twig // Eintrag (li) – SortableJS
|
||||||
@@ -369,19 +387,19 @@ eingeblendet. Kein Build-Step.
|
|||||||
```
|
```
|
||||||
tests/
|
tests/
|
||||||
├── Unit/Logic/ // Pure PHP, ohne Kernel (schnell)
|
├── Unit/Logic/ // Pure PHP, ohne Kernel (schnell)
|
||||||
│ ├── Essen/Model/DishTest.php
|
│ ├── Essen/Model/MahlzeitTest.php
|
||||||
│ ├── Essen/UseCase/CreateDishUseCaseTest.php
|
│ ├── Essen/UseCase/MahlzeitCreateUseCaseTest.php
|
||||||
│ ├── Essen/Query/InspirationQueryTest.php // mit gemocktem Provider
|
│ ├── Essen/Query/InspirationQueryTest.php // mit gemocktem Provider
|
||||||
│ └── Essen/Mapping/DishModelFactoryTest.php
|
│ └── Essen/Mapping/MahlzeitFactoryTest.php
|
||||||
├── Integration/Data/ // Test-Env + SQLite (oder in-memory)
|
├── Integration/Data/ // Test-Env + file-basierte SQLite (var/test.db)
|
||||||
│ ├── Essen/DishProviderTest.php
|
│ ├── Essen/MahlzeitProviderTest.php
|
||||||
│ ├── Essen/DishProcessorTest.php
|
│ ├── Essen/MahlzeitProcessorTest.php
|
||||||
│ └── Essen/DishMapperTest.php
|
│ └── Essen/MahlzeitMapperTest.php
|
||||||
├── Functional/UI/ // Symfony WebTestCase, isolierte Endpunkte
|
├── Functional/UI/ // Symfony WebTestCase, isolierte Endpunkte
|
||||||
│ ├── Essen/DishControllerTest.php
|
│ ├── Essen/MahlzeitControllerTest.php
|
||||||
│ └── Essen/ExceptionSubscriberTest.php
|
│ └── Essen/ExceptionSubscriberTest.php
|
||||||
└── Functional/Scenarios/ // User-Flows
|
└── Functional/Scenarios/ // User-Flows
|
||||||
└── EssenDishLifecycleTest.php // create → toggle → archive → restore
|
└── MahlzeitLifecycleTest.php // create → toggle → archive → restore
|
||||||
```
|
```
|
||||||
|
|
||||||
**Mocking-Richtlinien** (aus Referenz Sektion 13):
|
**Mocking-Richtlinien** (aus Referenz Sektion 13):
|
||||||
@@ -420,25 +438,25 @@ svc-essensplanung/
|
|||||||
│ │ │ ├── TransactionManagerInterface.php
|
│ │ │ ├── TransactionManagerInterface.php
|
||||||
│ │ │ └── EventDispatcherInterface.php
|
│ │ │ └── EventDispatcherInterface.php
|
||||||
│ │ └── Essen/
|
│ │ └── Essen/
|
||||||
│ │ ├── Model/ (Dish, DishDay, DishStatus)
|
│ │ ├── Model/ (Mahlzeit, Wochentag, MahlzeitStatus)
|
||||||
│ │ ├── Dto/ (10 Dateien)
|
│ │ ├── Dto/ (10 Dateien)
|
||||||
│ │ ├── UseCase/ (5)
|
│ │ ├── UseCase/ (5)
|
||||||
│ │ ├── Query/ (4)
|
│ │ ├── Query/ (4)
|
||||||
│ │ ├── Manager/ (interface + impl)
|
│ │ ├── Manager/ (interface + impl)
|
||||||
│ │ ├── DishProviderInterface.php
|
│ │ ├── MahlzeitProviderInterface.php
|
||||||
│ │ ├── DishProcessorInterface.php
|
│ │ ├── MahlzeitProcessorInterface.php
|
||||||
│ │ ├── Mapping/DishModelFactory.php
|
│ │ ├── Mapping/MahlzeitFactory.php
|
||||||
│ │ └── Event/DishCreatedEvent.php, DishArchivedEvent.php, ...
|
│ │ └── Event/MahlzeitCreatedEvent.php, MahlzeitArchivedEvent.php, ...
|
||||||
│ ├── Data/
|
│ ├── Data/
|
||||||
│ │ ├── Infrastructure/
|
│ │ ├── Infrastructure/
|
||||||
│ │ │ ├── Doctrine/EntityManagerTransactionManager.php
|
│ │ │ ├── Doctrine/EntityManagerTransactionManager.php
|
||||||
│ │ │ └── SymfonyEventDispatcherBridge.php
|
│ │ │ └── SymfonyEventDispatcherBridge.php
|
||||||
│ │ └── Essen/
|
│ │ └── Essen/
|
||||||
│ │ ├── Entity/DishEntity.php
|
│ │ ├── Entity/MahlzeitEntity.php
|
||||||
│ │ ├── Provider/DishProvider.php
|
│ │ ├── Provider/MahlzeitProvider.php
|
||||||
│ │ ├── Processor/DishProcessor.php
|
│ │ ├── Processor/MahlzeitProcessor.php
|
||||||
│ │ ├── Mapper/DishMapper.php
|
│ │ ├── Mapper/MahlzeitMapper.php
|
||||||
│ │ └── HistoryProvider/DishHistoryProvider.php
|
│ │ └── HistoryProvider/MahlzeitHistoryProvider.php
|
||||||
│ └── UI/
|
│ └── UI/
|
||||||
│ ├── Http/Essen/ (3 Controllers + ExceptionSubscriber)
|
│ ├── Http/Essen/ (3 Controllers + ExceptionSubscriber)
|
||||||
│ └── Twig/ (Layout + Fragmente)
|
│ └── Twig/ (Layout + Fragmente)
|
||||||
@@ -459,28 +477,28 @@ svc-essensplanung/
|
|||||||
## 3. Dependency Graph (konkret)
|
## 3. Dependency Graph (konkret)
|
||||||
|
|
||||||
```
|
```
|
||||||
UI: DishController
|
UI: MahlzeitController
|
||||||
-> Logic: CreateDishUseCase
|
-> Logic: MahlzeitCreateUseCase
|
||||||
-> Logic: MoveDishUseCase
|
-> Logic: MahlzeitMoveUseCase
|
||||||
-> Logic: ToggleDishStatusUseCase
|
-> Logic: MahlzeitToggleStatusUseCase
|
||||||
-> Logic: ListDishesQuery
|
-> Logic: MahlzeitListQuery
|
||||||
-> Logic: InspirationQuery
|
-> Logic: InspirationQuery
|
||||||
-> Logic DTOs (CreateDishRequest, MoveDishRequest, ...)
|
-> Logic DTOs (MahlzeitCreateRequest, MahlzeitMoveRequest, ...)
|
||||||
|
|
||||||
Logic: CreateDishUseCase
|
Logic: MahlzeitCreateUseCase
|
||||||
-> Logic: Common TransactionManagerInterface
|
-> Logic: Common TransactionManagerInterface
|
||||||
-> Logic: Essen DishManagerInterface
|
-> Logic: Essen MahlzeitManagerInterface
|
||||||
-> Logic: Essen Mapping/DishModelFactory
|
-> Logic: Essen Mapping/MahlzeitFactory
|
||||||
-> Logic: Common EventDispatcherInterface
|
-> Logic: Common EventDispatcherInterface
|
||||||
-> Logic: Essen/Event DishCreatedEvent
|
-> Logic: Essen/Event MahlzeitCreatedEvent
|
||||||
|
|
||||||
Logic: DishManager
|
Logic: MahlzeitManager
|
||||||
-> Logic: Essen DishProviderInterface (Read)
|
-> Logic: Essen MahlzeitProviderInterface (Read)
|
||||||
-> Logic: Essen DishProcessorInterface (Write)
|
-> Logic: Essen MahlzeitProcessorInterface (Write)
|
||||||
|
|
||||||
Data: DishProvider implements Logic:DishProviderInterface
|
Data: MahlzeitProvider implements Logic:MahlzeitProviderInterface
|
||||||
Data: DishProcessor implements Logic:DishProcessorInterface
|
Data: MahlzeitProcessor implements Logic:MahlzeitProcessorInterface
|
||||||
Data: DishMapper (Entity <-> Model, stateless)
|
Data: MahlzeitMapper (Entity <-> Model, stateless)
|
||||||
Data: EntityManagerTransactionManager implements Logic:Common TransactionManagerInterface
|
Data: EntityManagerTransactionManager implements Logic:Common TransactionManagerInterface
|
||||||
Data: SymfonyEventDispatcherBridge implements Logic:Common EventDispatcherInterface
|
Data: SymfonyEventDispatcherBridge implements Logic:Common EventDispatcherInterface
|
||||||
```
|
```
|
||||||
@@ -519,7 +537,7 @@ Data: SymfonyEventDispatcherBridge implements Logic:Common EventDispatcherIn
|
|||||||
## 6. Offene / Phase-2-Punkte
|
## 6. Offene / Phase-2-Punkte
|
||||||
|
|
||||||
- [ ] `settings.yaml` für Inspiration-Gewichte (ADR-006). Default 0.5/0.3/0.2.
|
- [ ] `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).
|
- [ ] `GET /api/today` für HA-Consumer (Phase 2, ADR-004).
|
||||||
- [ ] Optional: `Push`-Benachrichtigung per HA-Notify (Phase 2).
|
- [ ] 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).
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
// Einmalige Daten-Migration: SQLite /data/essens.db -> PostgreSQL (DATABASE_URL)
|
||||||
|
// Aufruf im App-Container: php bin/migrate-sqlite-to-pg.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$sqlitePath = '/data/essens.db';
|
||||||
|
$pgUrl = $_SERVER['DATABASE_URL'] ?? getenv('DATABASE_URL');
|
||||||
|
if (!$pgUrl) {
|
||||||
|
fwrite(STDERR, "DATABASE_URL nicht gesetzt\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sqlite = new PDO('sqlite:' . $sqlitePath);
|
||||||
|
$sqlite->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
// PDO pgsql akzeptiert keine // URLs -> DSN-Form
|
||||||
|
preg_match('#^[a-z+]+://([^:@/]*)(?::([^@/]*))?@([^:/?]*)(?::(\d+))?/([^?]*)(?:\?(.*))?$#', $pgUrl, $m);
|
||||||
|
[$authUser, $authPass, $host, $port, $dbname] = [$m[1] ?? 'essens', $m[2] ?? '', $m[3] ?? 'localhost', $m[4] ?? '5432', $m[5] ?? 'essens'];
|
||||||
|
$pg = new PDO(
|
||||||
|
sprintf('pgsql:host=%s;port=%s;dbname=%s;user=%s;password=%s', $host, $port, $dbname, $authUser, $authPass),
|
||||||
|
$authUser,
|
||||||
|
$authPass,
|
||||||
|
);
|
||||||
|
$pg->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
$pg->beginTransaction();
|
||||||
|
try {
|
||||||
|
$userStmt = $pg->prepare('INSERT INTO essens_user (id, username, email, kc_sub, created_am, updated_am) VALUES (?, ?, ?, ?, ?, ?)');
|
||||||
|
$mealStmt = $pg->prepare('INSERT INTO essens_mahlzeiten (id, name, notiz, wochentage, sort_order, status, gegessen_am, erstellt_am, aktualisiert_am) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||||
|
|
||||||
|
foreach ($sqlite->query('SELECT id, username, email, kc_sub, created_am, updated_am FROM essens_user') as $r) {
|
||||||
|
$userStmt->execute([$r['id'], $r['username'], $r['email'], $r['kc_sub'] ?: null, $r['created_am'] ?: null, $r['updated_am'] ?: null]);
|
||||||
|
}
|
||||||
|
foreach ($sqlite->query('SELECT id, name, notiz, wochentage, sort_order, status, gegessen_am, erstellt_am, aktualisiert_am FROM essens_mahlzeiten') as $r) {
|
||||||
|
$mealStmt->execute([
|
||||||
|
$r['id'], $r['name'], $r['notiz'] ?: null,
|
||||||
|
$r['wochentage'] ?: null,
|
||||||
|
(int) $r['sort_order'], $r['status'],
|
||||||
|
$r['gegessen_am'] ?: null, $r['erstellt_am'] ?: null, $r['aktualisiert_am'] ?: null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pg->exec("SELECT setval('essens_user_id_seq', COALESCE(MAX(id), 0) + 1, false) FROM essens_user");
|
||||||
|
$pg->exec("SELECT setval('essens_mahlzeiten_id_seq', COALESCE(MAX(id), 0) + 1, false) FROM essens_mahlzeiten");
|
||||||
|
$pg->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$pg->rollBack();
|
||||||
|
fwrite(STDERR, get_class($e) . ': ' . $e->getMessage() . "\n");
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$u = $pg->query('SELECT count(*) FROM essens_user')->fetchColumn();
|
||||||
|
$m = $pg->query('SELECT count(*) FROM essens_mahlzeiten')->fetchColumn();
|
||||||
|
echo "Import ok: $u User, $m Mahlzeiten\n";
|
||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Einmalige Migration: SQLite (/data/essens.db, Volume essens_data) -> PostgreSQL (postgresql.lan/essens)
|
||||||
|
# Läuft auf docker1 im Stack-Verzeichnis. Erwartet: .env.local enthält DATABASE_URL="postgresql://essens:<pw>@postgresql.lan:5432/essens?..."
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
DB_URL=$(grep -m1 '^DATABASE_URL=' .env.local | sed 's/^DATABASE_URL=//; s/^"//; s/"$//')
|
||||||
|
PG_USER=$(sed -n 's|.*://\([^:@]*\):.*|\1|p' <<<"$DB_URL")
|
||||||
|
PG_PASS=$(sed -n 's|.*://[^:@]*:\([^@]*\)@.*|\1|p' <<<"$DB_URL")
|
||||||
|
PG_HOST=$(sed -n 's|.*@\([^:/?]*\).*|\1|p' <<<"$DB_URL")
|
||||||
|
PG_DB=$(sed -n 's|.*/\([^/?]*\).*|\1|p' <<<"$DB_URL")
|
||||||
|
export PGPASSWORD="$PG_PASS"
|
||||||
|
|
||||||
|
echo "== 1) SQLite-Export (CSV) aus dem laufenden Container"
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
docker exec svc-essensplanung sh -c 'sqlite3 -cmd ".nullvalue NULL" -csv /data/essens.db "SELECT * FROM essens_user;"' > "$TMP/essens_user.csv"
|
||||||
|
docker exec svc-essensplanung sh -c 'sqlite3 -cmd ".nullvalue NULL" -csv /data/essens.db "SELECT * FROM essens_mahlzeiten;"' > "$TMP/essens_mahlzeiten.csv"
|
||||||
|
echo " essens_user: $(wc -l < "$TMP/essens_user.csv") Zeilen"
|
||||||
|
echo " essens_mahlzeiten: $(wc -l < "$TMP/essens_mahlzeiten.csv") Zeilen"
|
||||||
|
|
||||||
|
echo "== 2) Schema-Migrations auf PostgreSQL"
|
||||||
|
docker compose run --rm --no-deps app bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
|
||||||
|
echo "== 3) Datenimport via COPY"
|
||||||
|
psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB" <<EOF
|
||||||
|
BEGIN;
|
||||||
|
COPY essens_user (id, username, email, kc_sub, created_am, updated_am) FROM STDIN WITH (FORMAT csv);
|
||||||
|
$(cat "$TMP/essens_user.csv")
|
||||||
|
\.
|
||||||
|
COPY essens_mahlzeiten (id, name, notiz, wochentage, sort_order, status, gegessen_am, erstellt_am, aktualisiert_am) FROM STDIN WITH (FORMAT csv);
|
||||||
|
$(cat "$TMP/essens_mahlzeiten.csv")
|
||||||
|
\.
|
||||||
|
SELECT setval('essens_user_id_seq', COALESCE(MAX(id), 0) + 1, false) FROM essens_user;
|
||||||
|
SELECT setval('essens_mahlzeiten_id_seq', COALESCE(MAX(id), 0) + 1, false) FROM essens_mahlzeiten;
|
||||||
|
COMMIT;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "== 4) Verifikation"
|
||||||
|
psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB" -c 'SELECT (SELECT count(*) FROM essens_user) AS users, (SELECT count(*) FROM essens_mahlzeiten) AS mahlzeiten;'
|
||||||
+7
-3
@@ -4,16 +4,19 @@ services:
|
|||||||
container_name: svc-essensplanung
|
container_name: svc-essensplanung
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
# Secrets + Prod-Redirect-URI: APP_SECRET, API_TOKEN, KEYCLOAK_REDIRECT_URI
|
||||||
|
- .env.local
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: prod
|
APP_ENV: prod
|
||||||
APP_DEBUG: '0'
|
APP_DEBUG: '0'
|
||||||
DATABASE_URL: sqlite:////data/essens.db
|
DEFAULT_URI: https://essen.ljh31.de
|
||||||
|
KEYCLOAK_ISSUER: https://iam.ljh31.de/realms/family
|
||||||
|
KEYCLOAK_CLIENT_ID: essen
|
||||||
volumes:
|
volumes:
|
||||||
- essens_data:/data
|
- essens_data:/data
|
||||||
- essens_var:/app/var
|
- essens_var:/app/var
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'php', '-r', '$c=@fsockopen("127.0.0.1",8787); exit($c?0:1);']
|
test: ['CMD', 'php', '-r', '$$c=@fsockopen("127.0.0.1",8787); exit($$c?0:1);']
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -24,6 +27,7 @@ services:
|
|||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.essens.rule=Host(`essen.ljh31.de`)"
|
- "traefik.http.routers.essens.rule=Host(`essen.ljh31.de`)"
|
||||||
- "traefik.http.routers.essens.entrypoints=websecure"
|
- "traefik.http.routers.essens.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.essens.middlewares=keycloak-essens@file,error-404@file,error-500@file"
|
||||||
- "traefik.http.routers.essens.tls=true"
|
- "traefik.http.routers.essens.tls=true"
|
||||||
- "traefik.http.routers.essens.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.essens.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.essens.loadbalancer.server.port=8787"
|
- "traefik.http.services.essens.loadbalancer.server.port=8787"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"php": ">=8.2",
|
"php": ">=8.2",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
|
"ext-pgsql": "*",
|
||||||
"doctrine/doctrine-bundle": "^3.3",
|
"doctrine/doctrine-bundle": "^3.3",
|
||||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||||
"doctrine/orm": "^3.7",
|
"doctrine/orm": "^3.7",
|
||||||
|
|||||||
@@ -16,4 +16,6 @@ doctrine:
|
|||||||
when@test:
|
when@test:
|
||||||
doctrine:
|
doctrine:
|
||||||
dbal:
|
dbal:
|
||||||
url: 'sqlite:///:memory:'
|
# Datei-basiert (statt :memory:), damit das Schema über Request-Grenzen
|
||||||
|
# und geteilte Kernel-/Connection-Instanzen hinweg stabil bleibt.
|
||||||
|
url: 'sqlite:///%kernel.project_dir%/var/test.db'
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ framework:
|
|||||||
secret: '%env(APP_SECRET)%'
|
secret: '%env(APP_SECRET)%'
|
||||||
|
|
||||||
# Note that the session will be started ONLY if you read or write from it.
|
# Note that the session will be started ONLY if you read or write from it.
|
||||||
session: true
|
session:
|
||||||
|
# 7 Tage: Session überlebt Browser-Neustarts (Default 0 = Session-Cookie)
|
||||||
|
cookie_lifetime: 604800
|
||||||
|
# 7 Tage: PHP-GC löszt inaktive Sessions nicht nach 24 min (Default 1440)
|
||||||
|
gc_maxlifetime: 604800
|
||||||
|
# Außerhalb des Cache-Verzeichnisses: Deploy/Cache-Clear löscht keine Sessions
|
||||||
|
# (Volume essens_var → bleibt über Rebuilds erhalten)
|
||||||
|
save_path: '%kernel.project_dir%/var/sessions'
|
||||||
|
|
||||||
http_client:
|
http_client:
|
||||||
default_options:
|
default_options:
|
||||||
|
|||||||
@@ -946,6 +946,15 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* twig?: TwigConfig,
|
* twig?: TwigConfig,
|
||||||
* doctrine?: DoctrineConfig,
|
* doctrine?: DoctrineConfig,
|
||||||
* doctrine_migrations?: DoctrineMigrationsConfig,
|
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||||
|
* "when@dev"?: array{
|
||||||
|
* imports?: ImportsConfig,
|
||||||
|
* parameters?: ParametersConfig,
|
||||||
|
* services?: ServicesConfig,
|
||||||
|
* framework?: FrameworkConfig,
|
||||||
|
* twig?: TwigConfig,
|
||||||
|
* doctrine?: DoctrineConfig,
|
||||||
|
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||||
|
* },
|
||||||
* "when@prod"?: array{
|
* "when@prod"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1046,6 +1055,7 @@ namespace Symfony\Component\Routing\Loader\Configurator;
|
|||||||
* deprecated?: array{package:string, version:string, message?:string},
|
* deprecated?: array{package:string, version:string, message?:string},
|
||||||
* }
|
* }
|
||||||
* @psalm-type RoutesConfig = array{
|
* @psalm-type RoutesConfig = array{
|
||||||
|
* "when@dev"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||||
* "when@prod"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
* "when@prod"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||||
* "when@test"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
* "when@test"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||||
* ...<string, RouteConfig|ImportConfig|AliasConfig>
|
* ...<string, RouteConfig|ImportConfig|AliasConfig>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# yaml-language-server: $schema=https://symfony.com/schema/config/routing/routing.yaml
|
# yaml-language-server: $schema=https://symfony.com/schema/config/routing/routing.yaml
|
||||||
|
|
||||||
|
import:
|
||||||
|
resource: routes/api.yaml
|
||||||
|
|
||||||
home:
|
home:
|
||||||
path: /
|
path: /
|
||||||
controller: App\UI\Http\HomeController::index
|
controller: App\UI\Http\HomeController::index
|
||||||
@@ -19,3 +22,85 @@ logout:
|
|||||||
path: /logout
|
path: /logout
|
||||||
controller: App\UI\Http\Auth\OidcAuthController::logout
|
controller: App\UI\Http\Auth\OidcAuthController::logout
|
||||||
methods: [GET, POST]
|
methods: [GET, POST]
|
||||||
|
|
||||||
|
# M1: CRUD-Slice "Mahlzeit" (Web-UI, POST/Redirect/Flash)
|
||||||
|
mahlzeiten_index:
|
||||||
|
path: /mahlzeiten
|
||||||
|
controller: App\UI\Http\Essen\MahlzeitController::index
|
||||||
|
methods: [GET]
|
||||||
|
|
||||||
|
# M2: Archiv (gegessene Mahlzeiten)
|
||||||
|
mahlzeiten_archiv:
|
||||||
|
path: /mahlzeiten/archiv
|
||||||
|
controller: App\UI\Http\Essen\MahlzeitController::archiv
|
||||||
|
methods: [GET]
|
||||||
|
|
||||||
|
mahlzeiten_neu:
|
||||||
|
path: /mahlzeiten/neu
|
||||||
|
controller: App\UI\Http\Essen\MahlzeitController::neu
|
||||||
|
methods: [GET]
|
||||||
|
|
||||||
|
mahlzeiten_speichern:
|
||||||
|
path: /mahlzeiten
|
||||||
|
controller: App\UI\Http\Essen\MahlzeitController::speichern
|
||||||
|
methods: [POST]
|
||||||
|
|
||||||
|
mahlzeiten_bearbeiten:
|
||||||
|
path: /mahlzeiten/{id}/bearbeiten
|
||||||
|
controller: App\UI\Http\Essen\MahlzeitController::bearbeiten
|
||||||
|
methods: [GET]
|
||||||
|
requirements:
|
||||||
|
id: \d+
|
||||||
|
|
||||||
|
mahlzeiten_aktualisieren:
|
||||||
|
path: /mahlzeiten/{id}
|
||||||
|
controller: App\UI\Http\Essen\MahlzeitController::aktualisieren
|
||||||
|
methods: [POST]
|
||||||
|
requirements:
|
||||||
|
id: \d+
|
||||||
|
|
||||||
|
mahlzeiten_loeschen:
|
||||||
|
path: /mahlzeiten/{id}/loeschen
|
||||||
|
controller: App\UI\Http\Essen\MahlzeitController::loeschen
|
||||||
|
methods: [POST]
|
||||||
|
requirements:
|
||||||
|
id: \d+
|
||||||
|
|
||||||
|
# M3: Tagesansicht (alle Mahlzeiten nach Wochentag) + Reorder (Drag & Drop)
|
||||||
|
mahlzeiten_tage:
|
||||||
|
path: /mahlzeiten/tage
|
||||||
|
controller: App\UI\Http\Essen\MahlzeitController::tage
|
||||||
|
methods: [GET]
|
||||||
|
|
||||||
|
mahlzeiten_reorder:
|
||||||
|
path: /mahlzeiten/reorder
|
||||||
|
controller: App\UI\Http\Essen\MahlzeitController::reorder
|
||||||
|
methods: [POST]
|
||||||
|
|
||||||
|
# M3: Inspiration (ADR-006): Vorschläge aus dem Archiv + 1-Klick-Adopt
|
||||||
|
mahlzeiten_inspiration:
|
||||||
|
path: /inspiration
|
||||||
|
controller: App\UI\Http\Essen\MahlzeitController::inspiration
|
||||||
|
methods: [GET]
|
||||||
|
|
||||||
|
mahlzeiten_adopt:
|
||||||
|
path: /mahlzeiten/{id}/adopt
|
||||||
|
controller: App\UI\Http\Essen\MahlzeitController::adopt
|
||||||
|
methods: [POST]
|
||||||
|
requirements:
|
||||||
|
id: \d+
|
||||||
|
|
||||||
|
# M2: Status-Wechsel als htmx-Partial-Endpunkte (Fragment-Antwort)
|
||||||
|
mahlzeiten_checkoff:
|
||||||
|
path: /mahlzeiten/{id}/checkoff
|
||||||
|
controller: App\UI\Http\Essen\MahlzeitController::checkoff
|
||||||
|
methods: [POST]
|
||||||
|
requirements:
|
||||||
|
id: \d+
|
||||||
|
|
||||||
|
mahlzeiten_uncheck:
|
||||||
|
path: /mahlzeiten/{id}/uncheck
|
||||||
|
controller: App\UI\Http\Essen\MahlzeitController::uncheck
|
||||||
|
methods: [POST]
|
||||||
|
requirements:
|
||||||
|
id: \d+
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# yaml-language-server: $schema=https://symfony.com/schema/config/routing/routing.yaml
|
||||||
|
# Stateless-JSON-API für HA & Co. (Auth: X-Api-Token, s. ApiTokenSubscriber)
|
||||||
|
|
||||||
|
api_mahlzeiten_liste:
|
||||||
|
path: /api/mahlzeiten
|
||||||
|
controller: App\UI\Http\Api\MahlzeitApiController::index
|
||||||
|
methods: [GET]
|
||||||
|
|
||||||
|
api_mahlzeiten_create:
|
||||||
|
path: /api/mahlzeiten
|
||||||
|
controller: App\UI\Http\Api\MahlzeitApiController::create
|
||||||
|
methods: [POST]
|
||||||
|
|
||||||
|
api_mahlzeiten_checkoff:
|
||||||
|
path: /api/mahlzeiten/{id}/checkoff
|
||||||
|
controller: App\UI\Http\Api\MahlzeitApiController::checkoff
|
||||||
|
methods: [POST]
|
||||||
|
requirements:
|
||||||
|
id: \d+
|
||||||
|
|
||||||
|
api_mahlzeiten_uncheck:
|
||||||
|
path: /api/mahlzeiten/{id}/uncheck
|
||||||
|
controller: App\UI\Http\Api\MahlzeitApiController::uncheck
|
||||||
|
methods: [POST]
|
||||||
|
requirements:
|
||||||
|
id: \d+
|
||||||
@@ -8,6 +8,13 @@ services:
|
|||||||
_defaults:
|
_defaults:
|
||||||
autowire: true
|
autowire: true
|
||||||
autoconfigure: 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\:
|
App\:
|
||||||
resource: '../src/'
|
resource: '../src/'
|
||||||
|
|||||||
@@ -8,26 +8,24 @@ use Doctrine\DBAL\Schema\Schema;
|
|||||||
use Doctrine\Migrations\AbstractMigration;
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-generated Migration: Please modify to your needs!
|
* Postgres-Port der ursprünglichen SQLite-Migration (2026-09-13).
|
||||||
*/
|
*/
|
||||||
final class Version20260913204759 extends AbstractMigration
|
final class Version20260913204759 extends AbstractMigration
|
||||||
{
|
{
|
||||||
public function getDescription(): string
|
public function getDescription(): string
|
||||||
{
|
{
|
||||||
return '';
|
return 'essens_user (Postgres)';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
public function up(Schema $schema): void
|
||||||
{
|
{
|
||||||
// this up() migration is auto-generated, please modify it to your needs
|
$this->addSql('CREATE TABLE essens_user (id SERIAL PRIMARY KEY, username VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, kc_sub VARCHAR(64) NOT NULL, created_am TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_am TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL)');
|
||||||
$this->addSql('CREATE TABLE essens_user (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, username VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, kc_sub VARCHAR(64) NOT NULL, created_am DATETIME NOT NULL, updated_am DATETIME NOT NULL)');
|
|
||||||
$this->addSql('CREATE UNIQUE INDEX uniq_email ON essens_user (email)');
|
$this->addSql('CREATE UNIQUE INDEX uniq_email ON essens_user (email)');
|
||||||
$this->addSql('CREATE UNIQUE INDEX uniq_kc_sub ON essens_user (kc_sub)');
|
$this->addSql('CREATE UNIQUE INDEX uniq_kc_sub ON essens_user (kc_sub)');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
public function down(Schema $schema): void
|
||||||
{
|
{
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('DROP TABLE essens_user');
|
$this->addSql('DROP TABLE essens_user');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Postgres-Port der ursprünglichen SQLite-Migration (2026-09-18).
|
||||||
|
*/
|
||||||
|
final class Version20260918213517 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'essens_mahlzeiten (Postgres)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE essens_mahlzeiten (id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, notiz TEXT DEFAULT NULL, wochentage VARCHAR(32) DEFAULT NULL, sort_order INTEGER NOT NULL, status VARCHAR(32) NOT NULL, gegessen_am TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, erstellt_am TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, aktualisiert_am TIMESTAMP(0) WITHOUT TIME ZONE 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->addSql('DROP TABLE essens_mahlzeiten');
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "Essensplanung",
|
||||||
|
"short_name": "Essen",
|
||||||
|
"description": "Familien-Essensplanung: Planungsliste, Tagesansicht, Inspiration.",
|
||||||
|
"lang": "de",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#f4eee1",
|
||||||
|
"theme_color": "#3f6d4e",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/* Service Worker: App-Shell cachen (Offline + schneller Start).
|
||||||
|
Version bei jeder Template-/Asset-Änderung bumpen (SHELL_VERSION),
|
||||||
|
damit alte Caches beim Activate bereinigt werden. */
|
||||||
|
const SHELL_VERSION = 'v1';
|
||||||
|
const SHELL_CACHE = 'essen-shell-' + SHELL_VERSION;
|
||||||
|
const SHELL = [
|
||||||
|
'/',
|
||||||
|
'/manifest.webmanifest',
|
||||||
|
'/js/htmx.min.js',
|
||||||
|
'/js/sortable.min.js',
|
||||||
|
'/icons/icon-192.png',
|
||||||
|
'/icons/icon-512.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(SHELL_CACHE)
|
||||||
|
.then((cache) => cache.addAll(SHELL))
|
||||||
|
.then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => key.startsWith('essen-shell-') && key !== SHELL_CACHE)
|
||||||
|
.map((key) => caches.delete(key))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (event.request.method !== 'GET' || url.origin !== self.location.origin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigations: erst Netzwerk (frische Deploys!), dann Cache (Offline).
|
||||||
|
if (event.request.mode === 'navigate') {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
const kopie = response.clone();
|
||||||
|
caches.open(SHELL_CACHE).then((cache) => cache.put(event.request, kopie));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
caches.match(event.request).then((treffer) => treffer || caches.match('/'))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statische Assets: Cache-first (unveränderlich pro Version).
|
||||||
|
if (
|
||||||
|
url.pathname.startsWith('/js/') ||
|
||||||
|
url.pathname.startsWith('/icons/') ||
|
||||||
|
url.pathname === '/manifest.webmanifest'
|
||||||
|
) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then(
|
||||||
|
(treffer) =>
|
||||||
|
treffer ||
|
||||||
|
fetch(event.request).then((response) => {
|
||||||
|
const kopie = response.clone();
|
||||||
|
caches.open(SHELL_CACHE).then((cache) => cache.put(event.request, kopie));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ final class EntityManagerTransactionManager implements TransactionManagerInterfa
|
|||||||
public function execute(\Closure $work): mixed
|
public function execute(\Closure $work): mixed
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
return $this->em->transactional(static fn () => $work($work));
|
return $this->em->wrapInTransaction($work);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->em->clear();
|
$this->em->clear();
|
||||||
throw $e;
|
throw $e;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ interface TransactionManagerInterface
|
|||||||
* Bei Exception wird zurückgerollt, ansonsten committiert.
|
* Bei Exception wird zurückgerollt, ansonsten committiert.
|
||||||
*
|
*
|
||||||
* @template T
|
* @template T
|
||||||
* @param callable(\Closure|\Throwable): T $work
|
* @param \Closure(): T $work
|
||||||
* @return T
|
* @return T
|
||||||
* @throws \Throwable
|
* @throws \Throwable
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Logic\Essen\Dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-DTO: die Reihenfolge der Planungsliste festlegen.
|
||||||
|
*
|
||||||
|
* `reihenfolge` ist die Liste der Mahlzeiten-IDs in der gewünschten
|
||||||
|
* (neuen) Reihenfolge. Die Use-Case-Implementierung nummeriert daraus
|
||||||
|
* `sortOrder` neu (Position 1..N).
|
||||||
|
*
|
||||||
|
* @param list<int> $reihenfolge
|
||||||
|
*/
|
||||||
|
final class MahlzeitReorderRequest
|
||||||
|
{
|
||||||
|
/** @param list<int> $reihenfolge */
|
||||||
|
public function __construct(
|
||||||
|
public readonly array $reihenfolge,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Logic\Essen\Dto;
|
||||||
|
|
||||||
|
use App\Logic\Essen\Model\Mahlzeit;
|
||||||
|
use App\Logic\Essen\Model\Wochentag;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-Modell eines Inspiration-Vorschlags (ein Gericht aus dem Archiv).
|
||||||
|
*
|
||||||
|
* `score` ist der gewichtete Inspiration-Score (ADR-006), `auftraete` die
|
||||||
|
* Gesamtzahl aller Einträge (geplant + gegessen) mit diesem Namen.
|
||||||
|
*
|
||||||
|
* @param list<Wochentag> $wochentage
|
||||||
|
*/
|
||||||
|
final class MahlzeitVorschlagResponse
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $id,
|
||||||
|
public readonly string $name,
|
||||||
|
public readonly string $notiz,
|
||||||
|
public readonly array $wochentage,
|
||||||
|
public readonly float $score,
|
||||||
|
public readonly ?\DateTimeImmutable $gegessenAm,
|
||||||
|
public readonly int $auftraete,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromModel(Mahlzeit $m, float $score, int $auftraete): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
id: $m->id,
|
||||||
|
name: $m->name,
|
||||||
|
notiz: $m->notiz,
|
||||||
|
wochentage: $m->wochentage,
|
||||||
|
score: $score,
|
||||||
|
gegessenAm: $m->gegessenAm,
|
||||||
|
auftraete: $auftraete,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Logic\Essen\Event;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain-Event: ein Archiv-Gericht wurde per 1-Klick als neuer `geplant`-Eintrag
|
||||||
|
* übernommen (Adopt, nach Commit). `quellId` verweist auf die übernommenen Quelle.
|
||||||
|
*/
|
||||||
|
final readonly class MahlzeitAdoptedEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $id,
|
||||||
|
public string $name,
|
||||||
|
public int $quellId,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Logic\Essen\Event;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain-Event: die Reihenfolge der Planungsliste wurde geändert (nach Commit).
|
||||||
|
* Trägt die neue ID-Reihenfolge (State-Carry, kein ORM-Wissen).
|
||||||
|
*
|
||||||
|
* @param list<int> $reihenfolge
|
||||||
|
*/
|
||||||
|
final readonly class MahlzeitReorderedEvent
|
||||||
|
{
|
||||||
|
/** @param list<int> $reihenfolge */
|
||||||
|
public function __construct(
|
||||||
|
public array $reihenfolge,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Logic\Essen\Event;
|
||||||
|
|
||||||
|
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain-Event: der Status einer Mahlzeit wurde geändert (nach erfolgreichem Commit).
|
||||||
|
*
|
||||||
|
* Trägt den neuen Status + den (ggf. leeren) gegessenAm-Zeitpunkt, damit ein
|
||||||
|
* Subscriber (z. B. Archiv, Statistik) ohne erneuten DB-Zugriff reagieren kann.
|
||||||
|
*/
|
||||||
|
final readonly class MahlzeitStatusChangedEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $id,
|
||||||
|
public string $name,
|
||||||
|
public MahlzeitStatus $status,
|
||||||
|
public ?\DateTimeImmutable $gegessenAm,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Logic\Essen\Query;
|
||||||
|
|
||||||
|
use App\Logic\Common\ClockInterface;
|
||||||
|
use App\Logic\Essen\Dto\MahlzeitVorschlagResponse;
|
||||||
|
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
|
||||||
|
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-Query: Inspiration (ADR-006).
|
||||||
|
*
|
||||||
|
* Schlägt Gerichte **aus dem Archiv** (mindestens ein `gegessen`-Eintrag) vor,
|
||||||
|
* bewertet nach einem gewichteten Mix:
|
||||||
|
* score = w_slow * staleness (lange nicht gekocht, primär)
|
||||||
|
* + w_rare * rarity (seltene Gerichte, sekundär)
|
||||||
|
* + w_hot * popularity (beliebte Gerichte, tertiär)
|
||||||
|
*
|
||||||
|
* - staleness: `now - letzte_geschmack`, normalisiert an die längste Pause.
|
||||||
|
* - rarity: `1 / (auftraete + 1)`, auftraete = Anzahl ALLER Einträge (Name).
|
||||||
|
* - popularity: `auftraete / max(auftraete)`.
|
||||||
|
*
|
||||||
|
* `ausschluss` (bereits vorgeschlagene Repräsentanten-IDs) wird übersprungen,
|
||||||
|
* damit "Weitere 5" neue Gerichte liefert. Sortierung absteigend nach Score.
|
||||||
|
*/
|
||||||
|
final readonly class MahlzeitInspirationQuery
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private MahlzeitManagerInterface $mahlzeiten,
|
||||||
|
private ClockInterface $clock,
|
||||||
|
private float $gewichtSlow = 0.5,
|
||||||
|
private float $gewichtRare = 0.3,
|
||||||
|
private float $gewichtHot = 0.2,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $ausschluss bereits vorgeschlagene Repräsentanten-IDs
|
||||||
|
* @return list<MahlzeitVorschlagResponse>
|
||||||
|
*/
|
||||||
|
public function execute(int $limit = 5, array $ausschluss = []): array
|
||||||
|
{
|
||||||
|
$alle = $this->mahlzeiten->list(null);
|
||||||
|
if ($alle === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$jetzt = $this->clock->now();
|
||||||
|
|
||||||
|
/** @var array<string, array{letzte: ?\DateTimeImmutable, anzahl: int, repraesentant: mixed}> $perName */
|
||||||
|
$perName = [];
|
||||||
|
foreach ($alle as $m) {
|
||||||
|
$e = &$perName[$m->name];
|
||||||
|
if (!isset($e)) {
|
||||||
|
$e = ['letzte' => null, 'anzahl' => 0, 'repraesentant' => $m];
|
||||||
|
}
|
||||||
|
$e['anzahl']++;
|
||||||
|
if ($m->status === MahlzeitStatus::Gegessen && $m->gegessenAm !== null) {
|
||||||
|
if ($e['letzte'] === null || $m->gegessenAm > $e['letzte']) {
|
||||||
|
$e['letzte'] = $m->gegessenAm;
|
||||||
|
$e['repraesentant'] = $m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($e);
|
||||||
|
|
||||||
|
// Nur Gerichte, die im Archiv stehen (>= 1 gegessener Eintrag).
|
||||||
|
$kandidaten = [];
|
||||||
|
foreach ($perName as $name => $e) {
|
||||||
|
if ($e['letzte'] !== null) {
|
||||||
|
$kandidaten[] = [
|
||||||
|
'name' => $name,
|
||||||
|
'letzte' => $e['letzte'],
|
||||||
|
'anzahl' => $e['anzahl'],
|
||||||
|
'repraesentant' => $e['repraesentant'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($kandidaten === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalisierungs-Maxima.
|
||||||
|
$maxStale = 0;
|
||||||
|
$maxAnzahl = 0;
|
||||||
|
foreach ($kandidaten as $k) {
|
||||||
|
$stale = $jetzt->getTimestamp() - $k['letzte']->getTimestamp();
|
||||||
|
if ($stale > $maxStale) {
|
||||||
|
$maxStale = $stale;
|
||||||
|
}
|
||||||
|
if ($k['anzahl'] > $maxAnzahl) {
|
||||||
|
$maxAnzahl = $k['anzahl'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$scored = [];
|
||||||
|
foreach ($kandidaten as $k) {
|
||||||
|
$stale = $jetzt->getTimestamp() - $k['letzte']->getTimestamp();
|
||||||
|
$staleness = $maxStale > 0 ? $stale / $maxStale : 0.0;
|
||||||
|
$rarity = 1.0 / ($k['anzahl'] + 1);
|
||||||
|
$popularity = $maxAnzahl > 0 ? $k['anzahl'] / $maxAnzahl : 0.0;
|
||||||
|
$score = $this->gewichtSlow * $staleness + $this->gewichtRare * $rarity + $this->gewichtHot * $popularity;
|
||||||
|
$scored[] = ['k' => $k, 'score' => $score];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absteigend nach Score; bei Gleichstand alphabetisch (deterministisch).
|
||||||
|
usort($scored, static fn (array $a, array $b): int => $b['score'] <=> $a['score'] ?: strcmp($a['k']['name'], $b['k']['name']));
|
||||||
|
|
||||||
|
$ausschlussSet = array_flip(array_map('intval', $ausschluss));
|
||||||
|
$ergebnis = [];
|
||||||
|
foreach ($scored as $s) {
|
||||||
|
/** @var \App\Logic\Essen\Model\Mahlzeit $repraesentant */
|
||||||
|
$repraesentant = $s['k']['repraesentant'];
|
||||||
|
if (isset($ausschlussSet[$repraesentant->id])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ergebnis[] = MahlzeitVorschlagResponse::fromModel($repraesentant, $s['score'], (int) $s['k']['anzahl']);
|
||||||
|
if (\count($ergebnis) >= $limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ergebnis;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,51 @@
|
|||||||
|
<?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\MahlzeitResponse;
|
||||||
|
use App\Logic\Essen\Event\MahlzeitAdoptedEvent;
|
||||||
|
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
|
||||||
|
use App\Logic\Essen\Mapping\MahlzeitFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use-Case: ein Gericht (z. B. aus dem Archiv) per 1-Klick als NEUEN
|
||||||
|
* `geplant`-Eintrag übernehmen (Kopie – `gegessenAm` bleibt leer, neue ID,
|
||||||
|
* am Ende der Planungsliste angehängt).
|
||||||
|
*
|
||||||
|
* Wirft `MahlzeitNotFoundException` (→ 404), wenn die Quelle unbekannt ist.
|
||||||
|
*/
|
||||||
|
final readonly class MahlzeitAdoptUseCase
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TransactionManagerInterface $tx,
|
||||||
|
private MahlzeitManagerInterface $mahlzeiten,
|
||||||
|
private MahlzeitFactory $factory,
|
||||||
|
private EventDispatcherInterface $events,
|
||||||
|
private ClockInterface $clock,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(int $quellId): MahlzeitResponse
|
||||||
|
{
|
||||||
|
$quelle = $this->mahlzeiten->find($quellId);
|
||||||
|
|
||||||
|
$req = new MahlzeitCreateRequest(
|
||||||
|
name: $quelle->name,
|
||||||
|
notiz: $quelle->notiz,
|
||||||
|
wochentage: $quelle->wochentage,
|
||||||
|
);
|
||||||
|
$mahlzeit = $this->factory->createFromCreateRequest($req, $this->clock->now());
|
||||||
|
|
||||||
|
$gespeichert = $this->tx->execute(fn () => $this->mahlzeiten->create($mahlzeit));
|
||||||
|
|
||||||
|
$this->events->dispatch(new MahlzeitAdoptedEvent(id: $gespeichert->id, name: $gespeichert->name, quellId: $quellId));
|
||||||
|
|
||||||
|
return MahlzeitResponse::fromModel($gespeichert);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?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\MahlzeitResponse;
|
||||||
|
use App\Logic\Essen\Event\MahlzeitStatusChangedEvent;
|
||||||
|
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use-Case: eine Mahlzeit abhaken (geplant → gegessen).
|
||||||
|
*
|
||||||
|
* Idempotent: Eine bereits gegessene Mahlzeit bleibt unverändert (derselbe
|
||||||
|
* gegessenAm-Zeitpunkt bleibt erhalten). Wirft `MahlzeitNotFoundException`
|
||||||
|
* (→ 404), wenn die ID unbekannt ist.
|
||||||
|
*/
|
||||||
|
final readonly class MahlzeitCheckOffUseCase
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TransactionManagerInterface $tx,
|
||||||
|
private MahlzeitManagerInterface $mahlzeiten,
|
||||||
|
private EventDispatcherInterface $events,
|
||||||
|
private ClockInterface $clock,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(int $id): MahlzeitResponse
|
||||||
|
{
|
||||||
|
$jetzt = $this->clock->now();
|
||||||
|
$bestehend = $this->mahlzeiten->find($id);
|
||||||
|
$markiert = $bestehend->markAsGegessen($jetzt);
|
||||||
|
|
||||||
|
$gespeichert = $this->tx->execute(fn () => $this->mahlzeiten->update($markiert));
|
||||||
|
|
||||||
|
$this->events->dispatch(new MahlzeitStatusChangedEvent(
|
||||||
|
id: $id,
|
||||||
|
name: $gespeichert->name,
|
||||||
|
status: $gespeichert->status,
|
||||||
|
gegessenAm: $gespeichert->gegessenAm,
|
||||||
|
));
|
||||||
|
|
||||||
|
return MahlzeitResponse::fromModel($gespeichert);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Logic\Essen\UseCase;
|
||||||
|
|
||||||
|
use App\Logic\Common\EventDispatcherInterface;
|
||||||
|
use App\Logic\Common\TransactionManagerInterface;
|
||||||
|
use App\Logic\Essen\Dto\MahlzeitReorderRequest;
|
||||||
|
use App\Logic\Essen\Event\MahlzeitReorderedEvent;
|
||||||
|
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use-Case: die Reihenfolge der Planungsliste ändern (Drag & Drop).
|
||||||
|
*
|
||||||
|
* Die übergebene ID-Liste wird in `sortOrder` neu nummeriert (Position 1..N).
|
||||||
|
* Läuft in einer Transaktion; unbekannte IDs werfen `MahlzeitNotFoundException`
|
||||||
|
* (→ 404). Eine leere Liste ist ein No-Op.
|
||||||
|
*/
|
||||||
|
final readonly class MahlzeitReorderUseCase
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TransactionManagerInterface $tx,
|
||||||
|
private MahlzeitManagerInterface $mahlzeiten,
|
||||||
|
private EventDispatcherInterface $events,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(MahlzeitReorderRequest $req): void
|
||||||
|
{
|
||||||
|
$reihenfolge = array_values($req->reihenfolge);
|
||||||
|
if ($reihenfolge === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->tx->execute(function () use ($reihenfolge): void {
|
||||||
|
foreach ($reihenfolge as $position => $id) {
|
||||||
|
$sortOrder = $position + 1;
|
||||||
|
$mahlzeit = $this->mahlzeiten->find($id);
|
||||||
|
|
||||||
|
// Unveränderte Positionen überspringen (keine unnötigen Writes).
|
||||||
|
if ($mahlzeit->sortOrder !== $sortOrder) {
|
||||||
|
$this->mahlzeiten->update($mahlzeit->mitSortOrder($sortOrder));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->events->dispatch(new MahlzeitReorderedEvent($reihenfolge));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?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\MahlzeitResponse;
|
||||||
|
use App\Logic\Essen\Event\MahlzeitStatusChangedEvent;
|
||||||
|
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use-Case: eine Mahlzeit wieder aufheben (gegessen → geplant).
|
||||||
|
*
|
||||||
|
* Setzt `gegessenAm` zurück. Idempotent: Eine bereits geplante Mahlzeit bleibt
|
||||||
|
* unverändert. Wirft `MahlzeitNotFoundException` (→ 404), wenn die ID unbekannt ist.
|
||||||
|
*/
|
||||||
|
final readonly class MahlzeitUncheckUseCase
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TransactionManagerInterface $tx,
|
||||||
|
private MahlzeitManagerInterface $mahlzeiten,
|
||||||
|
private EventDispatcherInterface $events,
|
||||||
|
private ClockInterface $clock,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(int $id): MahlzeitResponse
|
||||||
|
{
|
||||||
|
$jetzt = $this->clock->now();
|
||||||
|
$bestehend = $this->mahlzeiten->find($id);
|
||||||
|
$markiert = $bestehend->markAsGeplant($jetzt);
|
||||||
|
|
||||||
|
$gespeichert = $this->tx->execute(fn () => $this->mahlzeiten->update($markiert));
|
||||||
|
|
||||||
|
$this->events->dispatch(new MahlzeitStatusChangedEvent(
|
||||||
|
id: $id,
|
||||||
|
name: $gespeichert->name,
|
||||||
|
status: $gespeichert->status,
|
||||||
|
gegessenAm: $gespeichert->gegessenAm,
|
||||||
|
));
|
||||||
|
|
||||||
|
return MahlzeitResponse::fromModel($gespeichert);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\UI\Http\Api;
|
||||||
|
|
||||||
|
use App\Logic\Essen\Dto\MahlzeitCreateRequest;
|
||||||
|
use App\Logic\Essen\Dto\MahlzeitListRequest;
|
||||||
|
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||||
|
use App\Logic\Essen\Model\Wochentag;
|
||||||
|
use App\Logic\Essen\Query\MahlzeitListQuery;
|
||||||
|
use App\Logic\Essen\UseCase\MahlzeitCheckOffUseCase;
|
||||||
|
use App\Logic\Essen\UseCase\MahlzeitCreateUseCase;
|
||||||
|
use App\Logic\Essen\UseCase\MahlzeitUncheckUseCase;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateless JSON-API für Consumer wie Home Assistant (Auth via `X-Api-Token`,
|
||||||
|
* s. `ApiTokenSubscriber`).
|
||||||
|
*
|
||||||
|
* - `GET /api/mahlzeiten` – Liste, optional `?status=geplant|gegessen`.
|
||||||
|
* - `POST /api/mahlzeiten/{id}/checkoff` – abhaken (geplant → gegessen).
|
||||||
|
* - `POST /api/mahlzeiten/{id}/uncheck` – aufheben (gegessen → geplant).
|
||||||
|
* - `POST /api/mahlzeiten` – anlegen (JSON `name` Pflicht, `notiz`/`wochentage` optional).
|
||||||
|
*
|
||||||
|
* Fachregeln laufen über dieselben UseCases/Queries wie die Web-UI;
|
||||||
|
* Fehler werden vom `ExceptionSubscriber` als JSON gerendert.
|
||||||
|
*/
|
||||||
|
final readonly class MahlzeitApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private MahlzeitListQuery $list,
|
||||||
|
private MahlzeitCheckOffUseCase $checkOff,
|
||||||
|
private MahlzeitUncheckUseCase $uncheck,
|
||||||
|
private MahlzeitCreateUseCase $create,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @param non-empty-string $name – für die Route-Signatur, hier nur Doku */
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$status = null;
|
||||||
|
$filter = (string) $request->query->get('status', '');
|
||||||
|
if ($filter !== '') {
|
||||||
|
$status = MahlzeitStatus::tryFrom($filter);
|
||||||
|
if ($status === null) {
|
||||||
|
throw new BadRequestHttpException('status muss "geplant" oder "gegessen" sein.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $this->list->execute(new MahlzeitListRequest(status: $status))->items;
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'mahlzeiten' => \array_map(self::serialize(...), $items),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkoff(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
return new JsonResponse(self::serialize($this->checkOff->execute($id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uncheck(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
return new JsonResponse(self::serialize($this->uncheck->execute($id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = json_decode((string) $request->getContent(), true);
|
||||||
|
if (!\is_array($payload)) {
|
||||||
|
throw new BadRequestHttpException('JSON-Body erwartet, z. B. {"name": "Bockbrot"}');
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = \trim((string) ($payload['name'] ?? ''));
|
||||||
|
if ($name === '') {
|
||||||
|
throw new BadRequestHttpException('name ist ein Pflichtfeld.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$notiz = \trim((string) ($payload['notiz'] ?? ''));
|
||||||
|
$wochentage = Wochentag::ausWerten((array) ($payload['wochentage'] ?? []));
|
||||||
|
|
||||||
|
return new JsonResponse(self::serialize($this->create->execute(new MahlzeitCreateRequest(
|
||||||
|
name: $name,
|
||||||
|
notiz: $notiz,
|
||||||
|
wochentage: $wochentage,
|
||||||
|
))->mahlzeit), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialisiert ein Read-Modell in das API-Format. */
|
||||||
|
public static function serialize(object $m): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $m->id,
|
||||||
|
'name' => $m->name,
|
||||||
|
'notiz' => $m->notiz,
|
||||||
|
'wochentage' => \array_map(static fn (Wochentag $t): string => $t->value, $m->wochentage),
|
||||||
|
'status' => $m->status->value,
|
||||||
|
'sort_order' => $m->sortOrder,
|
||||||
|
'gegessen_am' => $m->gegessenAm?->format(\DateTimeInterface::RFC3339),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,7 +65,8 @@ final class OidcAuthController
|
|||||||
try {
|
try {
|
||||||
$tokens = $this->oidc->exchangeCode($code);
|
$tokens = $this->oidc->exchangeCode($code);
|
||||||
$claims = $this->oidc->verifyIdToken($tokens['id_token']);
|
$claims = $this->oidc->verifyIdToken($tokens['id_token']);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable $e) {
|
||||||
|
error_log('OIDC-Callback fehlgeschlagen: ' . $e->getMessage());
|
||||||
return new RedirectResponse($this->router->generate('login'));
|
return new RedirectResponse($this->router->generate('login'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ final class OidcClient
|
|||||||
'client_id' => $this->clientId,
|
'client_id' => $this->clientId,
|
||||||
'redirect_uri' => $this->redirectUri,
|
'redirect_uri' => $this->redirectUri,
|
||||||
'scope' => implode(' ', $this->scopes),
|
'scope' => implode(' ', $this->scopes),
|
||||||
'prompt' => 'login',
|
|
||||||
];
|
];
|
||||||
if ($state !== null && $state !== '') {
|
if ($state !== null && $state !== '') {
|
||||||
$q['state'] = $state;
|
$q['state'] = $state;
|
||||||
@@ -71,7 +70,8 @@ final class OidcClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
$resp = $this->httpClient->request('POST', $this->issuer . '/protocol/openid-connect/token', [
|
$resp = $this->httpClient->request('POST', $this->issuer . '/protocol/openid-connect/token', [
|
||||||
'json' => $body,
|
'body' => http_build_query($body),
|
||||||
|
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
|
||||||
'timeout' => 10,
|
'timeout' => 10,
|
||||||
]);
|
]);
|
||||||
$sc = $resp->getStatusCode();
|
$sc = $resp->getStatusCode();
|
||||||
@@ -198,10 +198,9 @@ final class OidcClient
|
|||||||
|
|
||||||
$mod = $this->derInteger($n);
|
$mod = $this->derInteger($n);
|
||||||
$exp = $this->derInteger($e);
|
$exp = $this->derInteger($e);
|
||||||
$rsakey =
|
$modTLV = "\x02" . $this->derLen(strlen($mod)) . $mod;
|
||||||
"\x30" . $this->derLen(strlen($mod) + strlen($exp))
|
$expTLV = "\x02" . $this->derLen(strlen($exp)) . $exp;
|
||||||
. "\x02" . $this->derLen(strlen($mod)) . $mod
|
$rsakey = "\x30" . $this->derLen(strlen($modTLV) + strlen($expTLV)) . $modTLV . $expTLV;
|
||||||
. "\x02" . $this->derLen(strlen($exp)) . $exp;
|
|
||||||
|
|
||||||
// rsaEncryption OID 1.2.840.113549.1.1.1 → DER: 2A 86 48 86 F7 0D 01 01 01
|
// rsaEncryption OID 1.2.840.113549.1.1.1 → DER: 2A 86 48 86 F7 0D 01 01 01
|
||||||
$algId = "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01";
|
$algId = "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01";
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\UI\Http\Essen;
|
||||||
|
|
||||||
|
use App\Logic\Essen\Dto\MahlzeitCreateRequest;
|
||||||
|
use App\Logic\Essen\Dto\MahlzeitListRequest;
|
||||||
|
use App\Logic\Essen\Dto\MahlzeitResponse;
|
||||||
|
use App\Logic\Essen\Dto\MahlzeitReorderRequest;
|
||||||
|
use App\Logic\Essen\Dto\MahlzeitUpdateRequest;
|
||||||
|
use App\Logic\Essen\Dto\MahlzeitVorschlagResponse;
|
||||||
|
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
|
||||||
|
use App\Logic\Essen\Model\Mahlzeit;
|
||||||
|
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||||
|
use App\Logic\Essen\Model\Wochentag;
|
||||||
|
use App\Logic\Essen\Query\MahlzeitInspirationQuery;
|
||||||
|
use App\Logic\Essen\Query\MahlzeitListQuery;
|
||||||
|
use App\Logic\Essen\UseCase\MahlzeitAdoptUseCase;
|
||||||
|
use App\Logic\Essen\UseCase\MahlzeitCheckOffUseCase;
|
||||||
|
use App\Logic\Essen\UseCase\MahlzeitCreateUseCase;
|
||||||
|
use App\Logic\Essen\UseCase\MahlzeitDeleteUseCase;
|
||||||
|
use App\Logic\Essen\UseCase\MahlzeitReorderUseCase;
|
||||||
|
use App\Logic\Essen\UseCase\MahlzeitUncheckUseCase;
|
||||||
|
use App\Logic\Essen\UseCase\MahlzeitUpdateUseCase;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD-Slice "Mahlzeit" (M1) + Status & Tagesansicht (M2)
|
||||||
|
* + Reorder & Inspiration (M3) – Web-UI.
|
||||||
|
*
|
||||||
|
* - Reads über `MahlzeitListQuery` / `MahlzeitInspirationQuery`
|
||||||
|
* / `MahlzeitManagerInterface::find`.
|
||||||
|
* - Writes über die UseCases (Create/Update/Delete/CheckOff/Uncheck/Reorder/Adopt).
|
||||||
|
* - M1: klassisches POST/Redirect/Flash (Anlegen/Bearbeiten/Löschen).
|
||||||
|
* - M2: Check-Off als htmx-Toggle – die Endpunkte liefern nur das geänderte
|
||||||
|
* Mahlzeiten-Partial zurück (Fragment, kein Voll-HTML).
|
||||||
|
* - M3: Index = flache, per SortableJS sortierbare Planungsliste
|
||||||
|
* (`POST /mahlzeiten/reorder` → 204); Tagesansicht unter `/mahlzeiten/tage`;
|
||||||
|
* Inspiration unter `/inspiration` mit 1-Klick-Adopt.
|
||||||
|
* - Syntaktische Pflichtfeld-Prüfung (Name) passiert hier im UI-Layer
|
||||||
|
* (rein PHP, s. architektur.md §1.13); fachliche Invarianten bleiben
|
||||||
|
* im UseCase/Model und laufen zentral über den ExceptionSubscriber.
|
||||||
|
*/
|
||||||
|
final class MahlzeitController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MahlzeitCreateUseCase $create,
|
||||||
|
private readonly MahlzeitUpdateUseCase $update,
|
||||||
|
private readonly MahlzeitDeleteUseCase $delete,
|
||||||
|
private readonly MahlzeitCheckOffUseCase $checkOff,
|
||||||
|
private readonly MahlzeitUncheckUseCase $uncheck,
|
||||||
|
private readonly MahlzeitReorderUseCase $reorder,
|
||||||
|
private readonly MahlzeitAdoptUseCase $adopt,
|
||||||
|
private readonly MahlzeitListQuery $list,
|
||||||
|
private readonly MahlzeitInspirationQuery $inspiration,
|
||||||
|
private readonly MahlzeitManagerInterface $mahlzeiten,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** GET /mahlzeiten — Planungsliste: geplante Mahlzeiten in sortOrder-Reihenfolge (sortable). */
|
||||||
|
public function index(): Response
|
||||||
|
{
|
||||||
|
$mahlzeiten = $this->list->execute(new MahlzeitListRequest(status: MahlzeitStatus::Geplant))->items;
|
||||||
|
|
||||||
|
return $this->render('mahlzeiten/index.html.twig', [
|
||||||
|
'mahlzeiten' => $mahlzeiten,
|
||||||
|
'total' => \count($mahlzeiten),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /mahlzeiten/tage — Tagesansicht: alle Mahlzeiten nach Wochentag gruppiert. */
|
||||||
|
public function tage(): Response
|
||||||
|
{
|
||||||
|
$mahlzeiten = $this->list->execute(new MahlzeitListRequest())->items;
|
||||||
|
|
||||||
|
return $this->render('mahlzeiten/tage.html.twig', [
|
||||||
|
'tage' => $this->gruppierenNachTag($mahlzeiten),
|
||||||
|
'total' => \count($mahlzeiten),
|
||||||
|
'heute' => $this->heutigerTag(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktueller Wochentag (Europe/Berlin) als `Wochentag`-Name (z. B. "Di"). */
|
||||||
|
private function heutigerTag(): string
|
||||||
|
{
|
||||||
|
$iso = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('N');
|
||||||
|
|
||||||
|
return Wochentag::cases()[$iso - 1]->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /mahlzeiten/reorder — neue Reihenfolge der Planungsliste (Drag & Drop)
|
||||||
|
* persistieren. Erwartet JSON `{"ids": [3, 1, 2]}` oder das Formularfeld
|
||||||
|
* `ids` (Array oder kommagetrennte Liste). Antwort: 204 No Content.
|
||||||
|
* Wirft 404 bei unbekannten IDs.
|
||||||
|
*/
|
||||||
|
public function reorder(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->reorder->execute(new MahlzeitReorderRequest($this->bindeReihenfolge($request)));
|
||||||
|
|
||||||
|
return new Response(null, Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /inspiration — Vorschläge aus dem Archiv (ADR-006).
|
||||||
|
* `?ausschluss=1,2,3` (kommagetrennte Repräsentanten-IDs) liefert "Weitere 5".
|
||||||
|
*/
|
||||||
|
public function inspiration(Request $request): Response
|
||||||
|
{
|
||||||
|
$ausschluss = $this->bindeAusschluss($request->query->all()['ausschluss'] ?? null);
|
||||||
|
|
||||||
|
// Proben mit 6: so wissen wir, ob hinter den 5 gezeigten noch mehr liegen.
|
||||||
|
$vorschlaege = $this->inspiration->execute(6, $ausschluss);
|
||||||
|
$mehrVerfuegbar = \count($vorschlaege) > 5;
|
||||||
|
$vorschlaege = array_slice($vorschlaege, 0, 5);
|
||||||
|
|
||||||
|
return $this->render('mahlzeiten/inspiration.html.twig', [
|
||||||
|
'vorschlaege' => $vorschlaege,
|
||||||
|
'ausschluss' => $ausschluss,
|
||||||
|
'gezeigte' => array_map(static fn (MahlzeitVorschlagResponse $v): int => $v->id, $vorschlaege),
|
||||||
|
'mehrVerfuegbar' => $mehrVerfuegbar,
|
||||||
|
'total' => \count($vorschlaege),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /mahlzeiten/{id}/adopt — Gericht als NEUE geplante Mahlzeit übernehmen
|
||||||
|
* (Kopie, s. `MahlzeitAdoptUseCase`). Redirect zurück zur Inspiration,
|
||||||
|
* dabei die bisherige Ausschluss-Liste (für "Weitere 5") mitnehmen.
|
||||||
|
*/
|
||||||
|
public function adopt(Request $request, int $id): Response
|
||||||
|
{
|
||||||
|
$ergebnis = $this->adopt->execute($id);
|
||||||
|
$this->addFlash('notice', sprintf('Gericht „%s“ in die Planungsliste übernommen.', $ergebnis->name));
|
||||||
|
|
||||||
|
$params = [];
|
||||||
|
$ausschluss = $this->bindeAusschluss($request->request->all()['ausschluss'] ?? null);
|
||||||
|
if ($ausschluss !== []) {
|
||||||
|
$params['ausschluss'] = implode(',', $ausschluss);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('mahlzeiten_inspiration', $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /mahlzeiten/archiv — Archiv: gegessene Mahlzeiten. */
|
||||||
|
public function archiv(): Response
|
||||||
|
{
|
||||||
|
$mahlzeiten = $this->list->execute(new MahlzeitListRequest(status: MahlzeitStatus::Gegessen))->items;
|
||||||
|
|
||||||
|
return $this->render('mahlzeiten/archiv.html.twig', [
|
||||||
|
'mahlzeiten' => $mahlzeiten,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /mahlzeiten/neu — Anlegen-Formular. */
|
||||||
|
public function neu(): Response
|
||||||
|
{
|
||||||
|
return $this->render('mahlzeiten/form.html.twig', $this->formKontext(null, null, [
|
||||||
|
'name' => '',
|
||||||
|
'notiz' => '',
|
||||||
|
'wochentage' => [],
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /mahlzeiten — Mahlzeit anlegen. */
|
||||||
|
public function speichern(Request $request): Response
|
||||||
|
{
|
||||||
|
[$name, $notiz, $wochentage] = $this->binde($request);
|
||||||
|
|
||||||
|
if ($name === '') {
|
||||||
|
return $this->render('mahlzeiten/form.html.twig', $this->formKontext(
|
||||||
|
null,
|
||||||
|
'Name ist ein Pflichtfeld.',
|
||||||
|
['name' => '', 'notiz' => $notiz, 'wochentage' => $wochentage],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->create->execute(new MahlzeitCreateRequest(name: $name, notiz: $notiz, wochentage: $wochentage));
|
||||||
|
$this->addFlash('notice', sprintf('Mahlzeit „%s“ angelegt.', $name));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('mahlzeiten_index');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /mahlzeiten/{id}/bearbeiten — Bearbeiten-Formular. */
|
||||||
|
public function bearbeiten(int $id): Response
|
||||||
|
{
|
||||||
|
$mahlzeit = $this->mahlzeiten->find($id);
|
||||||
|
|
||||||
|
return $this->render('mahlzeiten/form.html.twig', $this->formKontext($mahlzeit, null, [
|
||||||
|
'name' => $mahlzeit->name,
|
||||||
|
'notiz' => $mahlzeit->notiz,
|
||||||
|
'wochentage' => $mahlzeit->wochentage,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /mahlzeiten/{id} — Mahlzeit bearbeiten. */
|
||||||
|
public function aktualisieren(Request $request, int $id): Response
|
||||||
|
{
|
||||||
|
$mahlzeit = $this->mahlzeiten->find($id);
|
||||||
|
[$name, $notiz, $wochentage] = $this->binde($request);
|
||||||
|
|
||||||
|
if ($name === '') {
|
||||||
|
return $this->render('mahlzeiten/form.html.twig', $this->formKontext(
|
||||||
|
$mahlzeit,
|
||||||
|
'Name ist ein Pflichtfeld.',
|
||||||
|
['name' => '', 'notiz' => $notiz, 'wochentage' => $wochentage],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->update->execute($id, new MahlzeitUpdateRequest(name: $name, notiz: $notiz, wochentage: $wochentage));
|
||||||
|
$this->addFlash('notice', sprintf('Mahlzeit „%s“ aktualisiert.', $name));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('mahlzeiten_index');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /mahlzeiten/{id}/loeschen — Mahlzeit hart löschen. */
|
||||||
|
public function loeschen(Request $request, int $id): Response
|
||||||
|
{
|
||||||
|
$mahlzeit = $this->mahlzeiten->find($id);
|
||||||
|
$this->delete->execute($id);
|
||||||
|
$this->addFlash('notice', sprintf('Mahlzeit „%s“ gelöscht.', $mahlzeit->name));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('mahlzeiten_index');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /mahlzeiten/{id}/checkoff — htmx-Partial: abhaken (geplant → gegessen).
|
||||||
|
*
|
||||||
|
* Liefert nur das aktualisierte Mahlzeiten-Partial zurück (Fragment), das
|
||||||
|
* htmx an der Stelle des Klicks austauscht. Wirft 404 bei unbekannter ID.
|
||||||
|
*/
|
||||||
|
public function checkoff(Request $request, int $id): Response
|
||||||
|
{
|
||||||
|
$mahlzeit = $this->checkOff->execute($id);
|
||||||
|
|
||||||
|
return $this->render('mahlzeiten/_mahlzeit.html.twig', ['m' => $mahlzeit]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /mahlzeiten/{id}/uncheck — htmx-Partial: aufheben (gegessen → geplant).
|
||||||
|
*
|
||||||
|
* Liefert nur das aktualisierte Mahlzeiten-Partial zurück (Fragment).
|
||||||
|
*/
|
||||||
|
public function uncheck(Request $request, int $id): Response
|
||||||
|
{
|
||||||
|
$mahlzeit = $this->uncheck->execute($id);
|
||||||
|
|
||||||
|
return $this->render('mahlzeiten/_mahlzeit.html.twig', ['m' => $mahlzeit]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bindet die Formularfelder (Name/Notiz/Wochentage) aus dem Request.
|
||||||
|
*
|
||||||
|
* @return array{0: string, 1: string, 2: list<Wochentag>}
|
||||||
|
*/
|
||||||
|
private function binde(Request $request): array
|
||||||
|
{
|
||||||
|
$name = trim((string) $request->request->get('name', ''));
|
||||||
|
$notiz = (string) $request->request->get('notiz', '');
|
||||||
|
$wochentage = Wochentag::ausWerten((array) $request->request->all('wochentage'));
|
||||||
|
|
||||||
|
return [$name, $notiz, $wochentage];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bindet die neue ID-Reihenfolge (Drag & Drop) aus dem Request.
|
||||||
|
* Unterstützt JSON `{"ids": [...]}`, das Formularfeld `ids[]`
|
||||||
|
* und kommagetrennte `ids=1,2,3`. Ungültige Werte werden verworfen.
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function bindeReihenfolge(Request $request): array
|
||||||
|
{
|
||||||
|
$roh = null;
|
||||||
|
$json = json_decode((string) $request->getContent(), true);
|
||||||
|
if (\is_array($json) && \is_array($json['ids'] ?? null)) {
|
||||||
|
$roh = $json['ids'];
|
||||||
|
} else {
|
||||||
|
// Raw-Bag-Zugriff: `get()`/`all(key)` werfen je auf Array/Skalar.
|
||||||
|
$roh = $request->request->all()['ids'] ?? null;
|
||||||
|
if (\is_string($roh)) {
|
||||||
|
$roh = array_filter(explode(',', $roh), static fn (string $teil): bool => trim($teil) !== '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!\is_array($roh)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = [];
|
||||||
|
foreach ($roh as $wert) {
|
||||||
|
if ((\is_int($wert) || \is_string($wert)) && preg_match('/^\d+$/', trim((string) $wert)) === 1) {
|
||||||
|
$ids[] = (int) $wert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bindet die Ausschluss-Liste bereits gezeigter Vorschläge ("Weitere 5").
|
||||||
|
* Akzeptiert ein String-Feld (`?ausschluss=1,2,3`) oder ein Array-Feld
|
||||||
|
* (`ausschluss[]`); rawer Bag-Zugriff, da `ParameterBag::get()`/`all(key)`
|
||||||
|
* je auf Skalar/Array werfen.
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function bindeAusschluss(array|string|null $werte): array
|
||||||
|
{
|
||||||
|
if ($werte === null || $werte === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (\is_string($werte)) {
|
||||||
|
$werte = array_filter(explode(',', $werte), static fn (string $teil): bool => trim($teil) !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = [];
|
||||||
|
foreach ($werte as $wert) {
|
||||||
|
if (\is_scalar($wert) && preg_match('/^\d+$/', trim((string) $wert)) === 1) {
|
||||||
|
$ids[] = (int) trim((string) $wert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{name: string, notiz: string, wochentage: list<Wochentag>} $werte
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function formKontext(?Mahlzeit $mahlzeit, ?string $fehler, array $werte): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'mahlzeit' => $mahlzeit,
|
||||||
|
'fehler' => $fehler,
|
||||||
|
'werte' => $werte,
|
||||||
|
'wochentage_choices' => Wochentag::alle(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gruppiert Mahlzeiten nach ihren Wochentagen (Mo…So).
|
||||||
|
*
|
||||||
|
* Eine Mahlzeit mit mehreren Wochentagen erscheint unter jedem dieser Tage.
|
||||||
|
* Mahlzeiten ohne Wochentag landen in der Gruppe "Ohne Tag". Leere Gruppen
|
||||||
|
* werden herausgefiltert; die Reihenfolge folgt Mo…So, danach "Ohne Tag".
|
||||||
|
*
|
||||||
|
* @param list<MahlzeitResponse> $mahlzeiten
|
||||||
|
* @return array<string, list<MahlzeitResponse>>
|
||||||
|
*/
|
||||||
|
private function gruppierenNachTag(array $mahlzeiten): array
|
||||||
|
{
|
||||||
|
$tage = [];
|
||||||
|
foreach (Wochentag::alle() as $tag) {
|
||||||
|
$tage[$tag->name] = [];
|
||||||
|
}
|
||||||
|
$tage['Ohne Tag'] = [];
|
||||||
|
|
||||||
|
foreach ($mahlzeiten as $m) {
|
||||||
|
if ($m->wochentage === []) {
|
||||||
|
$tage['Ohne Tag'][] = $m;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($m->wochentage as $tag) {
|
||||||
|
$tage[$tag->name][] = $m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return \array_filter($tage, static fn (array $liste) => $liste !== []);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ final class ExceptionSubscriber implements EventSubscriberInterface
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($e instanceof HttpExceptionInterface && $api) {
|
if ($e instanceof HttpExceptionInterface) {
|
||||||
$event->setResponse(
|
$event->setResponse(
|
||||||
$this->render($request, $e->getStatusCode(), $this->shortName(get_class($e)), $e->getMessage(), $wantsJson)
|
$this->render($request, $e->getStatusCode(), $this->shortName(get_class($e)), $e->getMessage(), $wantsJson)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,11 +3,15 @@
|
|||||||
{% block title %}Start · svc-essensplanung{% endblock %}
|
{% block title %}Start · svc-essensplanung{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<h1>{{ name }}</h1>
|
<h1>Willkommen, {{ name }}!</h1>
|
||||||
|
<p class="lead" style="margin-top: .4rem;">Deine Woche auf einen Blick – was steht an und was schmeckt mal wieder?</p>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p>Willkommen zur Essensplanung! (M0-Skelett)</p>
|
<div class="actions">
|
||||||
<p><strong>E-Mail:</strong> {{ email }}</p>
|
<a class="btn" href="{{ path('mahlzeiten_index') }}">Zur Planungsliste</a>
|
||||||
<p><strong>Redirect-Pfad:</strong> <code>{{ redirect }}</code></p>
|
<a class="btn btn-link" href="{{ path('mahlzeiten_inspiration') }}">Inspiration</a>
|
||||||
<p>M1: CRUD-Slice (Dish-Create/Update/Delete/List) folgt.</p>
|
<a class="btn btn-link" href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="margin: 1.1rem 0 0;">Angemeldet als {{ email }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+401
-24
@@ -2,62 +2,439 @@
|
|||||||
<html lang="de" dir="ltr">
|
<html lang="de" dir="ltr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>{% block title %}svc-essensplanung{% endblock %}</title>
|
<title>{% block title %}svc-essensplanung{% endblock %}</title>
|
||||||
|
<link rel="manifest" href="{{ asset('manifest.webmanifest') }}">
|
||||||
|
<meta name="theme-color" content="#3f6d4e">
|
||||||
|
<link rel="icon" href="{{ asset('icons/icon-192.png') }}" type="image/png">
|
||||||
|
<link rel="apple-touch-icon" href="{{ asset('icons/apple-touch-icon.png') }}">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Essen">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #f4f4f4;
|
--bg: #f4eee1;
|
||||||
--fg: #112233;
|
--bg-soft: #ece4d2;
|
||||||
--card: #fff;
|
--card: #fffdf8;
|
||||||
--accent: #0a7bff;
|
--fg: #2e2a22;
|
||||||
--muted: #667;
|
--muted: #877d69;
|
||||||
--warn: #b45309;
|
--border: #e6ddc9;
|
||||||
|
--border-strong: #d8cdb4;
|
||||||
|
--accent: #3f6d4e;
|
||||||
|
--accent-strong: #315a3e;
|
||||||
|
--accent-soft: #e2ecdf;
|
||||||
|
--danger: #a8442a;
|
||||||
|
--danger-strong: #8f3820;
|
||||||
|
--danger-soft: #f6e6de;
|
||||||
|
--ring: rgba(63, 109, 78, .22);
|
||||||
|
--shadow: 0 1px 2px rgba(76, 60, 32, .05), 0 5px 18px rgba(76, 60, 32, .08);
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
font: 16px/1.55 ui-rounded, "SF Pro Rounded", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
|
background: linear-gradient(180deg, #f7f2e7 0%, var(--bg) 320px) fixed;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
a { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ---------- Header ---------- */
|
||||||
header {
|
header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.75rem 1rem;
|
gap: .5rem 1rem;
|
||||||
background: var(--card);
|
flex-wrap: wrap;
|
||||||
box-shadow: 0 1px 0 #ddd;
|
padding: calc(.65rem + env(safe-area-inset-top, 0px)) 1.25rem .65rem;
|
||||||
|
background: rgba(255, 253, 248, .94);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
header a {
|
.brand {
|
||||||
color: var(--accent);
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
color: var(--fg);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin-left: 1rem;
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
letter-spacing: -.01em;
|
||||||
}
|
}
|
||||||
main {
|
.brand svg { color: var(--accent); flex: none; }
|
||||||
max-width: 40rem;
|
nav { display: flex; align-items: center; gap: .15rem; flex-wrap: wrap; }
|
||||||
margin: 2rem auto;
|
nav a {
|
||||||
padding: 0 1rem;
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 550;
|
||||||
|
font-size: .95rem;
|
||||||
|
padding: .35rem .8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
nav a:hover { color: var(--fg); background: var(--bg-soft); }
|
||||||
|
nav a.nav-logout { margin-left: .5rem; color: var(--muted); font-size: .88rem; }
|
||||||
|
nav a.nav-logout:hover { color: var(--danger); background: var(--danger-soft); }
|
||||||
|
|
||||||
|
/* ---------- Layout ---------- */
|
||||||
|
main { max-width: 44rem; margin: 1.75rem auto 3rem; padding: 0 1rem; }
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.55rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1.12rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
margin: 1.6rem 0 .55rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
.subtitle { color: var(--muted); font-size: .95rem; margin-top: .25rem; }
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
padding: 1.25rem;
|
padding: 1.4rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 1rem;
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,.06);
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.muted { color: var(--muted); font-size: .9rem; }
|
||||||
|
.lead { font-size: 1.02rem; color: var(--fg); margin: .2rem 0 1rem; }
|
||||||
|
.row-between { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||||||
|
.empty { text-align: center; color: var(--muted); margin: 1.5rem 0 .5rem; padding: 1.25rem 1rem; background: var(--bg-soft); border-radius: .8rem; }
|
||||||
|
|
||||||
|
/* ---------- Buttons ---------- */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: .45rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
padding: .55rem 1.15rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 1px 2px rgba(49, 90, 62, .28);
|
||||||
|
transition: background .15s, box-shadow .15s, transform .06s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--accent-strong); box-shadow: 0 2px 6px rgba(49, 90, 62, .3); }
|
||||||
|
.btn:active { transform: translateY(1px); }
|
||||||
|
.btn svg { width: 1.05em; height: 1.05em; flex: none; }
|
||||||
|
.btn-link {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
box-shadow: none;
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
.btn-link:hover { color: var(--fg); background: var(--bg-soft); }
|
||||||
|
.btn-danger {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--danger);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: var(--danger-soft); color: var(--danger-strong); }
|
||||||
|
.btn:focus-visible, nav a:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px var(--ring);
|
||||||
|
}
|
||||||
|
.actions { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
|
||||||
|
.action-haupt { display: flex; }
|
||||||
|
.action-neben {
|
||||||
|
display: flex;
|
||||||
|
gap: .4rem;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: .65rem;
|
||||||
|
margin-left: .1rem;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.action-neben .btn { font-size: .87rem; padding: .5rem .75rem; }
|
||||||
|
.action-neben form { display: inline-flex; }
|
||||||
|
|
||||||
|
/* ---------- Liste ---------- */
|
||||||
|
.list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .6rem; }
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: .75rem 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: .9rem 1.05rem;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: .85rem;
|
||||||
|
transition: box-shadow .15s, border-color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.item:hover { box-shadow: var(--shadow); border-color: var(--border-strong); }
|
||||||
|
.item-main { display: flex; align-items: flex-start; gap: .15rem; min-width: 0; }
|
||||||
|
.item-name { min-width: 0; }
|
||||||
|
.item-name strong, .item-name .name {
|
||||||
|
font-size: 1.07rem;
|
||||||
|
font-weight: 680;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.notiz { color: var(--muted); font-size: .9rem; margin-top: .1rem; }
|
||||||
|
.drag-handle {
|
||||||
|
flex: none;
|
||||||
|
align-self: center;
|
||||||
|
cursor: grab;
|
||||||
|
color: #b6ab90;
|
||||||
|
margin-right: .6rem;
|
||||||
|
padding: .45rem .3rem;
|
||||||
|
border-radius: .45rem;
|
||||||
|
line-height: 0;
|
||||||
|
user-select: none;
|
||||||
|
transition: color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.drag-handle:hover { color: var(--muted); background: var(--bg-soft); }
|
||||||
|
.updown { display: none; }
|
||||||
|
.updown button {
|
||||||
|
flex: none;
|
||||||
|
width: 2.2rem;
|
||||||
|
height: 2.2rem;
|
||||||
|
margin: .1rem 0;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: .55rem;
|
||||||
|
line-height: 0;
|
||||||
|
color: #b6ab90;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.updown button:active { color: var(--accent-strong); background: var(--accent-soft); border-color: #cfe0cd; }
|
||||||
|
.updown button:disabled { opacity: .35; pointer-events: none; }
|
||||||
|
.item-drag { opacity: .35; }
|
||||||
|
.tags { display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .5rem; }
|
||||||
|
.tag {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
padding: .13rem .6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
}
|
||||||
|
.tag-mo { background: #e3efe2; color: #2f5d39; }
|
||||||
|
.tag-di { background: #ddefed; color: #2a5f56; }
|
||||||
|
.tag-mi { background: #e1ebf6; color: #2f5578; }
|
||||||
|
.tag-do { background: #ebe5f4; color: #54437a; }
|
||||||
|
.tag-fr { background: #fcedda; color: #94621c; }
|
||||||
|
.tag-sa { background: #fae6e9; color: #93404d; }
|
||||||
|
.tag-so { background: #f9f2d4; color: #7d6414; }
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
margin-top: .55rem;
|
||||||
|
padding: .16rem .65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: .79rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--bg-soft);
|
||||||
|
}
|
||||||
|
.status::before {
|
||||||
|
content: "";
|
||||||
|
width: .45rem;
|
||||||
|
height: .45rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #c9bfa6;
|
||||||
|
}
|
||||||
|
.status-gegessen { color: var(--accent-strong); background: var(--accent-soft); }
|
||||||
|
.status-gegessen::before { background: var(--accent); }
|
||||||
|
.item-gegessen { background: #faf6ec; }
|
||||||
|
.item-gegessen .item-name strong, .item-gegessen .item-name .name {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-color: #b6ab90;
|
||||||
|
text-decoration-thickness: 1.5px;
|
||||||
|
}
|
||||||
|
.item .actions { margin-left: auto; }
|
||||||
|
|
||||||
|
/* ---------- Formular ---------- */
|
||||||
|
.field { margin-bottom: 1.15rem; }
|
||||||
|
.field > label { display: block; font-weight: 650; margin-bottom: .35rem; font-size: .97rem; }
|
||||||
|
.field input[type="text"], .field textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: .6rem .85rem;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: .6rem;
|
||||||
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--fg);
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.field input[type="text"]:focus, .field textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--ring);
|
||||||
|
}
|
||||||
|
.field textarea { resize: vertical; min-height: 4.2rem; }
|
||||||
|
.req { color: var(--danger); }
|
||||||
|
.checkboxes { display: flex; flex-wrap: wrap; gap: .55rem 1rem; margin-top: .4rem; }
|
||||||
|
.checkbox {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .45rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: .3rem .7rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
transition: border-color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.checkbox:hover { border-color: var(--border-strong); }
|
||||||
|
.checkbox input { accent-color: var(--accent); width: 1rem; height: 1rem; margin: 0; }
|
||||||
|
.checkbox:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-strong); }
|
||||||
|
|
||||||
|
/* ---------- Flash ---------- */
|
||||||
|
.flash {
|
||||||
|
padding: .7rem 1rem;
|
||||||
|
border-radius: .75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 550;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.flash-notice { background: var(--accent-soft); color: var(--accent-strong); border-color: #cfe0cd; }
|
||||||
|
.flash-error { background: var(--danger-soft); color: var(--danger-strong); border-color: #eccfc4; }
|
||||||
|
|
||||||
|
/* ---------- Inspiration ---------- */
|
||||||
|
.vorschlag-meta { color: var(--muted); font-size: .85rem; margin-top: .3rem; }
|
||||||
|
.vorschlag-meta b { color: var(--accent-strong); font-weight: 650; }
|
||||||
|
|
||||||
|
/* ---------- Tagesansicht ---------- */
|
||||||
|
h2[data-count]::after {
|
||||||
|
content: attr(data-count);
|
||||||
|
font-size: .76rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--bg-soft);
|
||||||
|
padding: .08rem .55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
h2[data-heute] { color: var(--accent-strong); }
|
||||||
|
h2[data-heute]::after {
|
||||||
|
content: attr(data-count) " · heute";
|
||||||
|
color: var(--accent-strong);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
h2[data-heute] + .list .item { border-color: #cfe0cd; }
|
||||||
|
|
||||||
|
/* ---------- FAB ---------- */
|
||||||
|
.fab {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 20;
|
||||||
|
right: calc(1rem + env(safe-area-inset-right, 0px));
|
||||||
|
bottom: calc(1.25rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 14px rgba(49, 90, 62, .4);
|
||||||
|
}
|
||||||
|
.fab:active { background: var(--accent-strong); transform: translateY(1px); }
|
||||||
|
.fab svg { width: 1.5rem; height: 1.5rem; }
|
||||||
|
|
||||||
|
/* ---------- Mobil ---------- */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
main { margin-top: 1.1rem; padding: 0 .8rem; padding-bottom: 6rem; }
|
||||||
|
.card { padding: 1.1rem; }
|
||||||
|
.btn { min-height: 2.75rem; }
|
||||||
|
.fab { display: flex; }
|
||||||
|
.drag-handle { display: none; }
|
||||||
|
.updown { display: inline-flex; flex-direction: column; align-self: center; margin-right: .6rem; }
|
||||||
|
.item { flex-direction: column; align-items: stretch; }
|
||||||
|
.item .actions { margin-left: 0; flex-direction: column; align-items: stretch; }
|
||||||
|
.item .action-haupt { width: 100%; }
|
||||||
|
.item .action-haupt .btn { width: 100%; }
|
||||||
|
.item .action-neben {
|
||||||
|
width: 100%;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-top: .5rem;
|
||||||
|
margin-top: .15rem;
|
||||||
|
}
|
||||||
|
.item .action-neben .btn,
|
||||||
|
.item .action-neben form { flex: 1; }
|
||||||
|
.item .action-neben .btn { width: auto; }
|
||||||
|
.item .action-neben form .btn { width: 100%; }
|
||||||
|
.page-head { flex-direction: column; align-items: stretch; }
|
||||||
|
.page-head .actions { width: 100%; }
|
||||||
|
.page-head .actions .btn { flex: 1; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% block stylesheets %}{% endblock %}
|
{% block stylesheets %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<strong>essen.ljh31.de</strong>
|
<a class="brand" href="{{ path('home') }}">
|
||||||
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<path d="M4 12h16a8 8 0 0 1-16 0Z"/>
|
||||||
|
<path d="M9.2 8.2c-.6-1.4.5-2-.1-3.6"/>
|
||||||
|
<path d="M13.8 8.2c-.6-1.4.5-2-.1-3.6"/>
|
||||||
|
</svg>
|
||||||
|
Essensplanung
|
||||||
|
</a>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="logout">Abmelden</a>
|
<a href="{{ path('mahlzeiten_index') }}">Mahlzeiten</a>
|
||||||
|
<a href="{{ path('mahlzeiten_tage') }}">Tage</a>
|
||||||
|
<a href="{{ path('mahlzeiten_inspiration') }}">Inspiration</a>
|
||||||
|
<a href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
|
||||||
|
<a class="nav-logout" href="{{ path('logout') }}">Abmelden</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
{% block fab %}
|
||||||
|
<a class="fab" href="{{ path('mahlzeiten_neu') }}" aria-label="Neue Mahlzeit">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<path d="M12 5v14M5 12h14"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
<main>
|
<main>
|
||||||
|
{% for label, messages in app.flashes %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="flash flash-{{ label }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
{% block body %}{% endblock %}
|
{% block body %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
<script src="{{ asset('js/htmx.min.js') }}"></script>
|
<script src="{{ asset('js/htmx.min.js') }}"></script>
|
||||||
{% block javascripts %}{% endblock %}
|
{% block javascripts %}{% endblock %}
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', function () {
|
||||||
|
navigator.serviceWorker.register('{{ asset('sw.js') }}').catch(function () {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
{#
|
||||||
|
Mahlzeiten-Partial (eine Zeile). Wird in Planungsliste/Tagesansicht/Archiv
|
||||||
|
eingebunden und als htmx-Fragment von den CheckOff/Uncheck-Endpunkten
|
||||||
|
zurückgeliefert (htmx ersetzt per hx-swap="outerHTML" genau diese <li>).
|
||||||
|
|
||||||
|
@param MahlzeitResponse m
|
||||||
|
@param bool sortierbar optional: zeigt Drag-Handle + Auf/Ab-Buttons + data-id (Planungsliste)
|
||||||
|
#}
|
||||||
|
<li class="item {{ m.status.value == 'gegessen' ? 'item-gegessen' : '' }}" id="mahlzeit-{{ m.id }}"
|
||||||
|
{% if sortierbar is defined and sortierbar %}data-id="{{ m.id }}"{% endif %}>
|
||||||
|
<div class="item-main">
|
||||||
|
{% if sortierbar is defined and sortierbar %}
|
||||||
|
<span class="drag-handle" title="Ziehen zum Sortieren" aria-hidden="true">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<circle cx="5.5" cy="3" r="1.4"/><circle cx="10.5" cy="3" r="1.4"/>
|
||||||
|
<circle cx="5.5" cy="8" r="1.4"/><circle cx="10.5" cy="8" r="1.4"/>
|
||||||
|
<circle cx="5.5" cy="13" r="1.4"/><circle cx="10.5" cy="13" r="1.4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="updown">
|
||||||
|
<button type="button" class="updown-btn" data-dir="-1" aria-label="Nach oben verschieben">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="m6 15 6-6 6 6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="updown-btn" data-dir="1" aria-label="Nach unten verschieben">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="m6 9 6 6 6-6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="item-name">
|
||||||
|
<strong>{{ m.name }}</strong>
|
||||||
|
{% if m.notiz != '' %}
|
||||||
|
<div class="notiz">{{ m.notiz }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if m.wochentage|length > 0 %}
|
||||||
|
<div class="tags">
|
||||||
|
{% for tag in m.wochentage %}
|
||||||
|
<span class="tag tag-{{ tag.value }}">{{ tag.name }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<span class="status {{ m.status.value == 'gegessen' ? 'status-gegessen' : '' }}">
|
||||||
|
{{ m.status.value == 'gegessen' ? 'gegessen' : 'geplant' }}
|
||||||
|
{% if m.status.value == 'gegessen' and m.gegessenAm %}
|
||||||
|
· {{ m.gegessenAm|date('d.m.Y H:i') }}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<div class="action-haupt">
|
||||||
|
{% if m.status.value == 'geplant' %}
|
||||||
|
<button type="button" class="btn"
|
||||||
|
hx-post="{{ path('mahlzeiten_checkoff', { id: m.id }) }}"
|
||||||
|
hx-target="#mahlzeit-{{ m.id }}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M4.5 12.5 9.5 17.5 19.5 6.5"/>
|
||||||
|
</svg>
|
||||||
|
Abhaken
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="button" class="btn btn-link"
|
||||||
|
hx-post="{{ path('mahlzeiten_uncheck', { id: m.id }) }}"
|
||||||
|
hx-target="#mahlzeit-{{ m.id }}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M9 14 4 9l5-5"/>
|
||||||
|
<path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>
|
||||||
|
</svg>
|
||||||
|
Aufheben
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="action-neben">
|
||||||
|
<a class="btn btn-link" href="{{ path('mahlzeiten_bearbeiten', { id: m.id }) }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M12 20h9"/>
|
||||||
|
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>
|
||||||
|
</svg>
|
||||||
|
Bearbeiten
|
||||||
|
</a>
|
||||||
|
<form method="post" action="{{ path('mahlzeiten_loeschen', { id: m.id }) }}"
|
||||||
|
onsubmit="return confirm('Mahlzeit „{{ m.name }}“ wirklich löschen?');">
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M3 6h18"/>
|
||||||
|
<path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>
|
||||||
|
<path d="M10 11v6M14 11v6"/>
|
||||||
|
</svg>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends 'layout/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Archiv · svc-essensplanung{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>Archiv</h1>
|
||||||
|
<div class="subtitle">Alle abgehakten (gegessenen) Mahlzeiten. „Aufheben" stellt sie wieder auf „geplant" um.</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
|
||||||
|
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if mahlzeiten|length == 0 %}
|
||||||
|
<p class="empty">Noch nichts gegessen – das Archiv ist leer.</p>
|
||||||
|
{% else %}
|
||||||
|
<ul class="list">
|
||||||
|
{% for m in mahlzeiten %}
|
||||||
|
{{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{% extends 'layout/base.html.twig' %}
|
||||||
|
|
||||||
|
{% set titel = mahlzeit ? 'Mahlzeit bearbeiten' : 'Neue Mahlzeit' %}
|
||||||
|
|
||||||
|
{% block title %}{{ titel }} · svc-essensplanung{% endblock %}
|
||||||
|
|
||||||
|
{% block fab %}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>{{ titel }}</h1>
|
||||||
|
<div class="subtitle">{{ mahlzeit ? 'Ändere Name, Notiz oder Wochentage.' : 'Ein Gericht, das wieder auf den Plan soll.' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if fehler %}
|
||||||
|
<div class="flash flash-error">{{ fehler }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post"
|
||||||
|
action="{{ mahlzeit ? path('mahlzeiten_aktualisieren', { id: mahlzeit.id }) : path('mahlzeiten_speichern') }}">
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="name">Name <span class="req" title="Pflichtfeld">*</span></label>
|
||||||
|
<input type="text" id="name" name="name" value="{{ werte.name }}" required
|
||||||
|
placeholder="z. B. Spaghetti Bolognese" autofocus
|
||||||
|
autocomplete="off" enterkeyhint="next">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="notiz">Notiz</label>
|
||||||
|
<textarea id="notiz" name="notiz" rows="3" placeholder="z. B. mit Basilikum, scharf …" enterkeyhint="done">{{ werte.notiz }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Wochentage</label>
|
||||||
|
{% set gewaehlte = werte.wochentage|map(t => t.value) %}
|
||||||
|
<div class="checkboxes">
|
||||||
|
{% for tag in wochentage_choices %}
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="wochentage[]" value="{{ tag.value }}"
|
||||||
|
{{ tag.value in gewaehlte ? 'checked' : '' }}>
|
||||||
|
{{ tag.name }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions" style="margin-top: 1.4rem;">
|
||||||
|
<button type="submit" class="btn">{{ mahlzeit ? 'Speichern' : 'Anlegen' }}</button>
|
||||||
|
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">Abbrechen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{% extends 'layout/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Mahlzeiten · svc-essensplanung{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>Mahlzeiten</h1>
|
||||||
|
<div class="subtitle">Planungsliste – per Drag & Drop sortieren</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn" href="{{ path('mahlzeiten_neu') }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<path d="M12 5v14M5 12h14"/>
|
||||||
|
</svg>
|
||||||
|
Neue Mahlzeit
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if total == 0 %}
|
||||||
|
<p class="empty">Noch keine Mahlzeiten in der Planung.</p>
|
||||||
|
{% else %}
|
||||||
|
<ul class="list sortierbar">
|
||||||
|
{% for m in mahlzeiten %}
|
||||||
|
{{ include('mahlzeiten/_mahlzeit.html.twig', { m: m, sortierbar: true }) }}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block javascripts %}
|
||||||
|
<script src="{{ asset('js/sortable.min.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const liste = document.querySelector('ul.sortierbar');
|
||||||
|
if (!liste) { return; }
|
||||||
|
const url = '{{ path("mahlzeiten_reorder") }}';
|
||||||
|
|
||||||
|
function speichern() {
|
||||||
|
const ids = Array.prototype.map.call(
|
||||||
|
liste.querySelectorAll('li[data-id]'),
|
||||||
|
function (li) { return Number(li.getAttribute('data-id')); }
|
||||||
|
);
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ids: ids }),
|
||||||
|
}).then(function (antwort) {
|
||||||
|
if (!antwort.ok) { window.location.reload(); }
|
||||||
|
}).catch(function () { window.location.reload(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auf/Ab-Buttons (mobil): Element mit Nachbar tauschen, dann speichern.
|
||||||
|
liste.addEventListener('click', function (ereignis) {
|
||||||
|
const knopf = ereignis.target.closest('.updown-btn');
|
||||||
|
if (!knopf) { return; }
|
||||||
|
const li = knopf.closest('li[data-id]');
|
||||||
|
if (!li) { return; }
|
||||||
|
const ziel = knopf.getAttribute('data-dir') === '1'
|
||||||
|
? li.nextElementSibling
|
||||||
|
: li.previousElementSibling;
|
||||||
|
if (!ziel || !ziel.matches('li[data-id]')) { return; }
|
||||||
|
if (knopf.getAttribute('data-dir') === '1') { ziel.after(li); } else { ziel.before(li); }
|
||||||
|
speichern();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag & Drop (Desktop)
|
||||||
|
if (typeof Sortable !== 'undefined') {
|
||||||
|
Sortable.create(liste, {
|
||||||
|
handle: '.drag-handle',
|
||||||
|
animation: 150,
|
||||||
|
ghostClass: 'item-drag',
|
||||||
|
onEnd: speichern,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{% extends 'layout/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Inspiration · svc-essensplanung{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>Inspiration</h1>
|
||||||
|
<div class="subtitle">Gerichte aus dem Archiv – was kochst du mal wieder?</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Für "Weitere 5" / Adopt-Kontext: bisheriger Ausschluss + die IDs dieser Seite #}
|
||||||
|
{% set alleAusschluss = ausschluss + gezeigte %}
|
||||||
|
|
||||||
|
{% if total == 0 %}
|
||||||
|
<p class="empty">
|
||||||
|
Noch keine Inspiration: Hake Mahlzeiten ab – dann erscheinen sie hier als Vorschlag.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<ul class="list">
|
||||||
|
{% for v in vorschlaege %}
|
||||||
|
<li class="item" id="vorschlag-{{ v.id }}">
|
||||||
|
<div class="item-name">
|
||||||
|
<strong>{{ v.name }}</strong>
|
||||||
|
{% if v.notiz != '' %}
|
||||||
|
<div class="notiz">{{ v.notiz }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="vorschlag-meta">
|
||||||
|
{% if v.gegessenAm %}
|
||||||
|
zuletzt gegessen · {{ v.gegessenAm|date('d.m.Y') }}
|
||||||
|
{% else %}
|
||||||
|
aus dem Archiv
|
||||||
|
{% endif %}
|
||||||
|
· <b>{{ v.auftraete }}</b>× in der Küche
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<form method="post" action="{{ path('mahlzeiten_adopt', { id: v.id }) }}">
|
||||||
|
{% for id in alleAusschluss %}
|
||||||
|
<input type="hidden" name="ausschluss[]" value="{{ id }}">
|
||||||
|
{% endfor %}
|
||||||
|
<button type="submit" class="btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M20 6 9 17l-5-5"/>
|
||||||
|
</svg>
|
||||||
|
Übernehmen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% if mehrVerfuegbar %}
|
||||||
|
<p class="muted" style="margin: 1.1rem 0 0; text-align: center;">
|
||||||
|
<a class="btn btn-link"
|
||||||
|
href="{{ path('mahlzeiten_inspiration', { ausschluss: alleAusschluss|join(',') }) }}">Weitere 5 Vorschläge</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends 'layout/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Tagesansicht · svc-essensplanung{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>Tagesansicht</h1>
|
||||||
|
<div class="subtitle">Alle Mahlzeiten nach Wochentag</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
|
||||||
|
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if total == 0 %}
|
||||||
|
<p class="empty">Noch keine Mahlzeiten angelegt.</p>
|
||||||
|
{% else %}
|
||||||
|
{% for tag, mahlzeiten in tage %}
|
||||||
|
<h2 data-count="{{ mahlzeiten|length }}" {% if tag == heute %}data-heute="1"{% endif %}>{{ tag }}</h2>
|
||||||
|
<ul class="list">
|
||||||
|
{% for m in mahlzeiten %}
|
||||||
|
{{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\UI\Essen;
|
||||||
|
|
||||||
|
use App\Data\Essen\Entity\MahlzeitEntity;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\Tools\SchemaTool;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\BrowserKit\Cookie;
|
||||||
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M1 Schritt 2: CRUD-Slice "Mahlzeit" über die Web-UI (eingeloggte Session).
|
||||||
|
*
|
||||||
|
* `createClient()` darf pro Test nur einmal den Kernel booten, daher wird
|
||||||
|
* Client + Schema + Login je Test per Helper vorbereitet (Pattern wie SmokeTest).
|
||||||
|
* Test-DB ist file-basiert (var/test.db), s. doctrine.yaml when@test.
|
||||||
|
*/
|
||||||
|
final class MahlzeitControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private const AUTH_KEYS = [
|
||||||
|
'auth_sub' => 'test-sub',
|
||||||
|
'auth_name' => 'Jens',
|
||||||
|
'auth_email' => 'jens@ljh31.de',
|
||||||
|
];
|
||||||
|
|
||||||
|
private KernelBrowser $client;
|
||||||
|
|
||||||
|
public function testIndexRedirectsToLoginWithoutSession(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten(false);
|
||||||
|
$client->request('GET', '/mahlzeiten');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(302);
|
||||||
|
self::assertResponseRedirects('/login?redirect=/mahlzeiten');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIndexRendersEmptyListForAuthedUser(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$client->request('GET', '/mahlzeiten');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorTextContains('h1', 'Mahlzeiten');
|
||||||
|
self::assertSelectorTextContains('p', 'Noch keine Mahlzeiten');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNeuRendersEmptyForm(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$client->request('GET', '/mahlzeiten/neu');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorExists('input[name="name"]');
|
||||||
|
self::assertSelectorExists('textarea[name="notiz"]');
|
||||||
|
self::assertSelectorExists('input[name="wochentage[]"][value="mo"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRedirectsAndPersists(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$client->request('POST', '/mahlzeiten', [
|
||||||
|
'name' => 'Bolognese',
|
||||||
|
'notiz' => 'mit Basilikum',
|
||||||
|
'wochentage' => ['mo', 'mi'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/mahlzeiten');
|
||||||
|
|
||||||
|
$entity = $this->em()->getRepository(MahlzeitEntity::class)->findOneBy(['name' => 'Bolognese']);
|
||||||
|
self::assertNotNull($entity);
|
||||||
|
self::assertSame('mit Basilikum', $entity->getNotiz());
|
||||||
|
self::assertSame('mo,mi', $entity->getWochentage());
|
||||||
|
self::assertSame('geplant', $entity->getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateWithEmptyNameRendersFormWithError(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$client->request('POST', '/mahlzeiten', [
|
||||||
|
'name' => ' ',
|
||||||
|
'notiz' => 'x',
|
||||||
|
'wochentage' => ['di'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorTextContains('div', 'Name ist ein Pflichtfeld');
|
||||||
|
|
||||||
|
self::assertCount(0, $this->em()->getRepository(MahlzeitEntity::class)->findAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdatePersistsChanges(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$id = $this->anlegen($client, 'Curry', '', []);
|
||||||
|
|
||||||
|
$client->request('POST', sprintf('/mahlzeiten/%d', $id), [
|
||||||
|
'name' => 'Currywurst',
|
||||||
|
'notiz' => 'scharf',
|
||||||
|
'wochentage' => ['do'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/mahlzeiten');
|
||||||
|
|
||||||
|
$this->em()->clear();
|
||||||
|
$entity = $this->em()->getRepository(MahlzeitEntity::class)->find($id);
|
||||||
|
self::assertSame('Currywurst', $entity->getName());
|
||||||
|
self::assertSame('scharf', $entity->getNotiz());
|
||||||
|
self::assertSame('do', $entity->getWochentage());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteRemovesMeal(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$id = $this->anlegen($client, 'Pizza', '', []);
|
||||||
|
|
||||||
|
$client->request('POST', sprintf('/mahlzeiten/%d/loeschen', $id));
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/mahlzeiten');
|
||||||
|
|
||||||
|
$this->em()->clear();
|
||||||
|
self::assertNull($this->em()->getRepository(MahlzeitEntity::class)->find($id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBearbeitenUnknownIdReturns404(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$client->request('GET', '/mahlzeiten/99999/bearbeiten');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIndexShowsPersistedMealWithActions(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$this->anlegen($client, 'Lasagne', 'Samstag', ['sa']);
|
||||||
|
|
||||||
|
$client->request('GET', '/mahlzeiten');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorTextContains('strong', 'Lasagne');
|
||||||
|
self::assertSelectorTextContains('span.tag', 'Sa');
|
||||||
|
self::assertSelectorExists('a[href$="/bearbeiten"]');
|
||||||
|
self::assertSelectorExists('form[action$="/loeschen"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- M2 Schritt 2: Status (Check-Off/Toggle), Tagesansicht, Archiv ----
|
||||||
|
|
||||||
|
public function testTageViewGroupsMealsByWeekday(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$this->anlegen($client, 'Curry', '', ['di']);
|
||||||
|
$this->anlegen($client, 'Pizza', '', ['sa']);
|
||||||
|
$this->anlegen($client, 'Bolognese', '', ['mo', 'mi']);
|
||||||
|
|
||||||
|
$client->request('GET', '/mahlzeiten/tage');
|
||||||
|
$crawler = $client->getCrawler();
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
// Curry exakt unter "Di", nicht unter "Sa".
|
||||||
|
$diUl = $this->listeNachHeading($crawler, 'Di');
|
||||||
|
self::assertStringContainsString('Curry', $diUl->html());
|
||||||
|
self::assertStringNotContainsString('Pizza', $diUl->html());
|
||||||
|
|
||||||
|
// Pizza exakt unter "Sa".
|
||||||
|
$saUl = $this->listeNachHeading($crawler, 'Sa');
|
||||||
|
self::assertStringContainsString('Pizza', $saUl->html());
|
||||||
|
self::assertStringNotContainsString('Curry', $saUl->html());
|
||||||
|
|
||||||
|
// Bolognese hat zwei Wochentage → erscheint unter "Mo" UND "Mi".
|
||||||
|
self::assertStringContainsString('Bolognese', $this->listeNachHeading($crawler, 'Mo')->html());
|
||||||
|
self::assertStringContainsString('Bolognese', $this->listeNachHeading($crawler, 'Mi')->html());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTageViewGroupsMealWithoutWeekdayUnderOhneTag(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$this->anlegen($client, 'Reis', '', []);
|
||||||
|
|
||||||
|
$client->request('GET', '/mahlzeiten/tage');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertStringContainsString('Reis', $this->listeNachHeading($client->getCrawler(), 'Ohne Tag')->html());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCheckOffReturnsGegessenPartial(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$id = $this->anlegen($client, 'Pasta', '', ['mo']);
|
||||||
|
|
||||||
|
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $id));
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
// htmx-Fragment: genau das Mahlzeiten-Partial, keine komplette Seite.
|
||||||
|
self::assertSelectorExists('li#mahlzeit-' . $id);
|
||||||
|
self::assertSelectorTextContains('span.status', 'gegessen');
|
||||||
|
$content = (string) $client->getResponse()->getContent();
|
||||||
|
self::assertStringNotContainsString('<header', $content);
|
||||||
|
self::assertStringNotContainsString('<!DOCTYPE', $content);
|
||||||
|
|
||||||
|
// Persistenz: Status auf "gegessen" + gegessenAm gesetzt.
|
||||||
|
$this->em()->clear();
|
||||||
|
$entity = $this->em()->getRepository(MahlzeitEntity::class)->find($id);
|
||||||
|
self::assertSame('gegessen', $entity->getStatus());
|
||||||
|
self::assertNotNull($entity->getGegessenAm());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUncheckRestoresGeplantStatus(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$id = $this->anlegen($client, 'Suppe', '', []);
|
||||||
|
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $id));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$client->request('POST', sprintf('/mahlzeiten/%d/uncheck', $id));
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorExists('li#mahlzeit-' . $id);
|
||||||
|
self::assertSelectorTextContains('span.status', 'geplant');
|
||||||
|
|
||||||
|
// Persistenz: Status zurück auf "geplant" + gegessenAm zurückgesetzt.
|
||||||
|
$this->em()->clear();
|
||||||
|
$entity = $this->em()->getRepository(MahlzeitEntity::class)->find($id);
|
||||||
|
self::assertSame('geplant', $entity->getStatus());
|
||||||
|
self::assertNull($entity->getGegessenAm());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testArchivShowsOnlyGegessenMeals(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$this->anlegen($client, 'GeplanteMahlzeit', '', ['mo']);
|
||||||
|
$gegessenId = $this->anlegen($client, 'GegesseneMahlzeit', '', ['di']);
|
||||||
|
|
||||||
|
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $gegessenId));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$client->request('GET', '/mahlzeiten/archiv');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorTextContains('h1', 'Archiv');
|
||||||
|
|
||||||
|
// Nur die Ergebnisliste prüfen (Flash-Meldungen der Anleg-Requests ausblenden).
|
||||||
|
$liste = $client->getCrawler()->filter('ul')->html();
|
||||||
|
self::assertStringContainsString('GegesseneMahlzeit', $liste);
|
||||||
|
self::assertStringNotContainsString('GeplanteMahlzeit', $liste);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCheckOffUnknownIdReturns404(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$client->request('POST', '/mahlzeiten/99999/checkoff');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUncheckUnknownIdReturns404(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$client->request('POST', '/mahlzeiten/99999/uncheck');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- M3: Planungsliste (flach/sortierbar), Reorder, Inspiration, Adopt ----
|
||||||
|
|
||||||
|
public function testIndexRendersFlatPlannedListOnly(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$geplantId = $this->anlegen($client, 'GeplanteMahlzeit', '', []);
|
||||||
|
$gegessenId = $this->anlegen($client, 'GegesseneMahlzeit', '', []);
|
||||||
|
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $gegessenId));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$client->request('GET', '/mahlzeiten');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
// Sortierbare Planungsliste mit Drag-Handle + data-id; gegessene Mahlzeit fehlt.
|
||||||
|
self::assertSelectorExists('ul.sortierbar');
|
||||||
|
self::assertSelectorExists('li[data-id="' . $geplantId . '"]');
|
||||||
|
self::assertSelectorExists('li[data-id="' . $geplantId . '"] .drag-handle');
|
||||||
|
self::assertSelectorNotExists('li[data-id="' . $gegessenId . '"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIndexKeepsPersistedSortOrder(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$a = $this->anlegen($client, 'Erste', '', []);
|
||||||
|
$b = $this->anlegen($client, 'Zweite', '', []);
|
||||||
|
$c = $this->anlegen($client, 'Dritte', '', []);
|
||||||
|
|
||||||
|
// Anordnung ändern: C, A, B (Formularfeld `ids[]`).
|
||||||
|
$client->request('POST', '/mahlzeiten/reorder', ['ids' => [$c, $a, $b]]);
|
||||||
|
self::assertResponseStatusCodeSame(204);
|
||||||
|
|
||||||
|
// Persistenz: sortOrder neu nummeriert.
|
||||||
|
$this->em()->clear();
|
||||||
|
$repo = $this->em()->getRepository(MahlzeitEntity::class);
|
||||||
|
self::assertSame(1, $repo->find($c)->getSortOrder());
|
||||||
|
self::assertSame(2, $repo->find($a)->getSortOrder());
|
||||||
|
self::assertSame(3, $repo->find($b)->getSortOrder());
|
||||||
|
|
||||||
|
// Index rendert die neue Reihenfolge.
|
||||||
|
$client->request('GET', '/mahlzeiten');
|
||||||
|
$reihenfolge = $client->getCrawler()
|
||||||
|
->filter('li[data-id]')
|
||||||
|
->each(static fn (Crawler $node): int => (int) $node->attr('data-id'));
|
||||||
|
self::assertSame([$c, $a, $b], $reihenfolge);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReorderUnbekannteIdLiefert404(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$client->request('POST', '/mahlzeiten/reorder', ['ids' => [99999]]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReorderAkzeptiertJsonIds(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$a = $this->anlegen($client, 'Erste', '', []);
|
||||||
|
$b = $this->anlegen($client, 'Zweite', '', []);
|
||||||
|
|
||||||
|
// SortableJS schickt JSON: {"ids": [...]} – hier als roher Body.
|
||||||
|
$client->request('POST', '/mahlzeiten/reorder', [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
'HTTP_ACCEPT' => 'application/json',
|
||||||
|
], json_encode(['ids' => [$b, $a]]));
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(204);
|
||||||
|
|
||||||
|
$this->em()->clear();
|
||||||
|
$repo = $this->em()->getRepository(MahlzeitEntity::class);
|
||||||
|
self::assertSame(1, $repo->find($b)->getSortOrder());
|
||||||
|
self::assertSame(2, $repo->find($a)->getSortOrder());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInspirationListetArchivGerichteMitAdopt(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$id = $this->anlegen($client, 'Bolognese', '', ['mo']);
|
||||||
|
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $id));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$client->request('GET', '/inspiration');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorTextContains('h1', 'Inspiration');
|
||||||
|
self::assertSelectorTextContains('strong', 'Bolognese');
|
||||||
|
self::assertSelectorExists('form[action$="/adopt"] button[type="submit"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInspirationLeerOhneArchivEintraege(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$this->anlegen($client, 'NurGeplant', '', []);
|
||||||
|
|
||||||
|
$client->request('GET', '/inspiration');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorTextContains('p', 'Noch keine Inspiration');
|
||||||
|
self::assertSelectorNotExists('li[id^="vorschlag-"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInspirationWeitere5ExkludiertBereitsGezeigte(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
foreach (['A', 'B', 'C', 'D', 'E', 'F'] as $buchstabe) {
|
||||||
|
$id = $this->anlegen($client, sprintf('Gericht%s', $buchstabe), '', []);
|
||||||
|
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $id));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
$client->request('GET', '/inspiration');
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$ersteSeite = $client->getCrawler()->filter('li[id^="vorschlag-"]');
|
||||||
|
self::assertCount(5, $ersteSeite);
|
||||||
|
$ersteSeiteNamen = $ersteSeite->each(static fn (Crawler $n): string => $n->filter('strong')->text());
|
||||||
|
|
||||||
|
// "Weitere 5" führt mit Ausschluss-Liste zurück zur Inspiration.
|
||||||
|
$link = $client->getCrawler()->filter('a:contains("Weitere 5 Vorschläge")');
|
||||||
|
self::assertCount(1, $link);
|
||||||
|
self::assertStringContainsString('ausschluss=', (string) $link->link()->getUri());
|
||||||
|
|
||||||
|
$client->request('GET', (string) $link->link()->getUri());
|
||||||
|
$zweiteSeite = $client->getCrawler()->filter('li[id^="vorschlag-"]');
|
||||||
|
self::assertCount(1, $zweiteSeite);
|
||||||
|
self::assertNotContains($zweiteSeite->filter('strong')->text(), $ersteSeiteNamen);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdoptErstelltNeuenGeplantEintrag(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$quellId = $this->anlegen($client, 'Pasta', 'mit Knoblauch', ['di']);
|
||||||
|
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $quellId));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$client->request('POST', sprintf('/mahlzeiten/%d/adopt', $quellId));
|
||||||
|
self::assertResponseRedirects('/inspiration');
|
||||||
|
|
||||||
|
$this->em()->clear();
|
||||||
|
$repo = $this->em()->getRepository(MahlzeitEntity::class);
|
||||||
|
|
||||||
|
// Quelle bleibt unverändert im Archiv.
|
||||||
|
self::assertSame('gegessen', $repo->find($quellId)->getStatus());
|
||||||
|
|
||||||
|
// Neue Kopie: gleicher Name/Notiz/Wochentag, aber geplant + ohne gegessenAm.
|
||||||
|
$kopien = $repo->findBy(['name' => 'Pasta']);
|
||||||
|
self::assertCount(2, $kopien);
|
||||||
|
$neue = null;
|
||||||
|
foreach ($kopien as $kandidat) {
|
||||||
|
if ((int) $kandidat->getId() !== $quellId) {
|
||||||
|
$neue = $kandidat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::assertNotNull($neue);
|
||||||
|
self::assertSame('geplant', $neue->getStatus());
|
||||||
|
self::assertNull($neue->getGegessenAm());
|
||||||
|
self::assertSame('mit Knoblauch', $neue->getNotiz());
|
||||||
|
self::assertSame('di', $neue->getWochentage());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdoptUnbekannteIdLiefert404(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$client->request('POST', '/mahlzeiten/99999/adopt');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die <ul>-Liste, die direkt unter der angegebenen <h2>-Überschrift
|
||||||
|
* (z. B. "Di", "Ohne Tag") in der Tagesansicht liegt.
|
||||||
|
*/
|
||||||
|
private function listeNachHeading(Crawler $crawler, string $heading): Crawler
|
||||||
|
{
|
||||||
|
$h2 = $crawler->filterXPath(sprintf('//h2[normalize-space()="%s"]', $heading));
|
||||||
|
self::assertCount(1, $h2, sprintf('Genau ein <h2> "%s" erwartet.', $heading));
|
||||||
|
|
||||||
|
// Das <ul> ist der erste nächste Geschwister-Knoten der Überschrift.
|
||||||
|
return $h2->nextAll()->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client + sauberes Schema (+ optional eingeloggte Session) bereitstellen.
|
||||||
|
*/
|
||||||
|
private function vorbereiten(bool $auth = true): KernelBrowser
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$client->followRedirects(false);
|
||||||
|
$this->client = $client;
|
||||||
|
|
||||||
|
$em = $this->em();
|
||||||
|
$schemaTool = new SchemaTool($em);
|
||||||
|
$schemaTool->dropSchema($em->getMetadataFactory()->getAllMetadata());
|
||||||
|
$schemaTool->createSchema($em->getMetadataFactory()->getAllMetadata());
|
||||||
|
|
||||||
|
if ($auth) {
|
||||||
|
$session = self::getContainer()->get('session.factory')->createSession();
|
||||||
|
$session->start();
|
||||||
|
foreach (self::AUTH_KEYS as $k => $v) {
|
||||||
|
$session->set($k, $v);
|
||||||
|
}
|
||||||
|
$session->save();
|
||||||
|
$client->getCookieJar()->set(new Cookie($session->getName(), $session->getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt eine Mahlzeit über die UI an und liefert die neue DB-ID.
|
||||||
|
*/
|
||||||
|
private function anlegen(KernelBrowser $client, string $name, string $notiz, array $wochentage): int
|
||||||
|
{
|
||||||
|
$client->request('POST', '/mahlzeiten', [
|
||||||
|
'name' => $name,
|
||||||
|
'notiz' => $notiz,
|
||||||
|
'wochentage' => $wochentage,
|
||||||
|
]);
|
||||||
|
self::assertResponseRedirects('/mahlzeiten');
|
||||||
|
|
||||||
|
$em = $this->em();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
$entity = $em->getRepository(MahlzeitEntity::class)->findOneBy(['name' => $name]);
|
||||||
|
self::assertNotNull($entity);
|
||||||
|
|
||||||
|
return (int) $entity->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function em(): EntityManagerInterface
|
||||||
|
{
|
||||||
|
return self::getContainer()->get('doctrine')->getManager();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\UI\Essen;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Tools\SchemaTool;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\BrowserKit\Cookie;
|
||||||
|
|
||||||
|
final class PwaSmokeTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private static function authedClient(): KernelBrowser
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$client->followRedirects(false);
|
||||||
|
$em = self::getContainer()->get('doctrine')->getManager();
|
||||||
|
$schemaTool = new SchemaTool($em);
|
||||||
|
$schemaTool->dropSchema($em->getMetadataFactory()->getAllMetadata());
|
||||||
|
$schemaTool->createSchema($em->getMetadataFactory()->getAllMetadata());
|
||||||
|
$session = self::getContainer()->get('session.factory')->createSession();
|
||||||
|
$session->start();
|
||||||
|
foreach (['auth_sub' => 'test-sub', 'auth_name' => 'Jens', 'auth_email' => 'jens@ljh31.de'] as $k => $v) {
|
||||||
|
$session->set($k, $v);
|
||||||
|
}
|
||||||
|
$session->save();
|
||||||
|
$client->getCookieJar()->set(new Cookie($session->getName(), $session->getId()));
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIndexHasPwaAndMobileMarkers(): void
|
||||||
|
{
|
||||||
|
$client = self::authedClient();
|
||||||
|
$client->request('GET', '/mahlzeiten');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$html = (string) $client->getResponse()->getContent();
|
||||||
|
self::assertStringContainsString('manifest.webmanifest', $html);
|
||||||
|
self::assertStringContainsString('class="fab"', $html);
|
||||||
|
self::assertStringContainsString("register('/sw.js')", $html);
|
||||||
|
self::assertStringContainsString('href="/mahlzeiten/tage"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTageHighlightsHeutigerTag(): void
|
||||||
|
{
|
||||||
|
$client = self::authedClient();
|
||||||
|
$client->request('POST', '/mahlzeiten', [
|
||||||
|
'name' => 'Heutiges Gericht',
|
||||||
|
'notiz' => '',
|
||||||
|
'wochentage' => [self::heuteWert()],
|
||||||
|
]);
|
||||||
|
$client->request('GET', '/mahlzeiten/tage');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$h2 = $client->getCrawler()->filterXPath('//h2[@data-heute="1"]');
|
||||||
|
self::assertCount(1, $h2, 'Genau ein <h2> trägt das data-heute-Attribut.');
|
||||||
|
self::assertSame(self::heuteName(), trim($h2->text()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function heuteName(): string
|
||||||
|
{
|
||||||
|
$iso = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('N');
|
||||||
|
|
||||||
|
return ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'][$iso - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function heuteWert(): string
|
||||||
|
{
|
||||||
|
$iso = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('N');
|
||||||
|
|
||||||
|
return ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'][$iso - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,125 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSavePersistiertAbhakenAlsGegessen(): void
|
||||||
|
{
|
||||||
|
$jetzt = $this->jetzt();
|
||||||
|
$created = $this->processor()->create(Mahlzeit::anlegen('Bolognese', '', [Wochentag::Mo], 1, $jetzt));
|
||||||
|
|
||||||
|
$gegessen = $created->markAsGegessen($jetzt);
|
||||||
|
$this->processor()->save($gegessen);
|
||||||
|
|
||||||
|
$neu = (new MahlzeitProvider($this->em, new MahlzeitMapper()))->find($created->id);
|
||||||
|
self::assertNotNull($neu);
|
||||||
|
self::assertSame(MahlzeitStatus::Gegessen, $neu->status);
|
||||||
|
self::assertSame($jetzt, $neu->gegessenAm);
|
||||||
|
self::assertTrue($neu->istGegessen());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSavePersistiertAufhebenAlsGeplant(): void
|
||||||
|
{
|
||||||
|
$jetzt = $this->jetzt();
|
||||||
|
$created = $this->processor()->create(Mahlzeit::anlegen('Bolognese', '', [Wochentag::Mo], 1, $jetzt)->markAsGegessen($jetzt));
|
||||||
|
|
||||||
|
$geplant = $created->markAsGeplant($jetzt);
|
||||||
|
$this->processor()->save($geplant);
|
||||||
|
|
||||||
|
$neu = (new MahlzeitProvider($this->em, new MahlzeitMapper()))->find($created->id);
|
||||||
|
self::assertNotNull($neu);
|
||||||
|
self::assertSame(MahlzeitStatus::Geplant, $neu->status);
|
||||||
|
self::assertNull($neu->gegessenAm);
|
||||||
|
self::assertTrue($neu->istGeplant());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Logic\Essen\Query;
|
||||||
|
|
||||||
|
use App\Logic\Common\ClockInterface;
|
||||||
|
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
|
||||||
|
use App\Logic\Essen\Model\Mahlzeit;
|
||||||
|
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||||
|
use App\Logic\Essen\Model\Wochentag;
|
||||||
|
use App\Logic\Essen\Query\MahlzeitInspirationQuery;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class MahlzeitInspirationQueryTest extends TestCase
|
||||||
|
{
|
||||||
|
private \DateTimeImmutable $jetzt;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->jetzt = new \DateTimeImmutable('2026-09-18 00:00:00');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clock(): ClockInterface
|
||||||
|
{
|
||||||
|
$clock = $this->createStub(ClockInterface::class);
|
||||||
|
$clock->method('now')->willReturn($this->jetzt);
|
||||||
|
return $clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<Mahlzeit> $mahlzeiten */
|
||||||
|
private function manager(array $mahlzeiten): MahlzeitManagerInterface
|
||||||
|
{
|
||||||
|
$manager = $this->createMock(MahlzeitManagerInterface::class);
|
||||||
|
$manager->expects(self::once())->method('list')->with(null)->willReturn($mahlzeiten);
|
||||||
|
return $manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function gegessen(int $id, string $name, \DateTimeImmutable $gegessenAm): Mahlzeit
|
||||||
|
{
|
||||||
|
return new Mahlzeit($id, $name, '', [Wochentag::Mo], 1, MahlzeitStatus::Gegessen, $this->jetzt, $this->jetzt, $gegessenAm);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function geplant(int $id, string $name): Mahlzeit
|
||||||
|
{
|
||||||
|
return new Mahlzeit($id, $name, '', [Wochentag::Mo], 1, MahlzeitStatus::Geplant, $this->jetzt, $this->jetzt, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteLeereListeLiefertKeineVorschlaege(): void
|
||||||
|
{
|
||||||
|
$query = new MahlzeitInspirationQuery($this->manager([]), $this->clock());
|
||||||
|
|
||||||
|
self::assertSame([], $query->execute());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteOhneArchivEintraegeKeineVorschlaege(): void
|
||||||
|
{
|
||||||
|
// Nur `geplant` – kein Gericht im Archiv.
|
||||||
|
$query = new MahlzeitInspirationQuery($this->manager([
|
||||||
|
$this->geplant(1, 'Bolognese'),
|
||||||
|
$this->geplant(2, 'Curry'),
|
||||||
|
]), $this->clock());
|
||||||
|
|
||||||
|
self::assertSame([], $query->execute());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteSortiertAbsteigendNachScoreUndWaehltRepraesentanten(): void
|
||||||
|
{
|
||||||
|
// "Bolognese": gegessen vor 10 Tagen (id 1) + 1 geplanter Eintrag → auftraete 2,
|
||||||
|
// repraesentant = id 1.
|
||||||
|
// "Curry": gegessen vor 3 Tagen (id 3), auftraete 1.
|
||||||
|
$query = new MahlzeitInspirationQuery($this->manager([
|
||||||
|
$this->gegessen(1, 'Bolognese', new \DateTimeImmutable('2026-09-08 00:00:00')),
|
||||||
|
$this->geplant(2, 'Bolognese'),
|
||||||
|
$this->gegessen(3, 'Curry', new \DateTimeImmutable('2026-09-15 00:00:00')),
|
||||||
|
]), $this->clock());
|
||||||
|
|
||||||
|
$vorschlaege = $query->execute();
|
||||||
|
|
||||||
|
self::assertCount(2, $vorschlaege);
|
||||||
|
self::assertSame(1, $vorschlaege[0]->id);
|
||||||
|
self::assertSame('Bolognese', $vorschlaege[0]->name);
|
||||||
|
self::assertSame(2, $vorschlaege[0]->auftraete);
|
||||||
|
self::assertSame(3, $vorschlaege[1]->id);
|
||||||
|
self::assertSame('Curry', $vorschlaege[1]->name);
|
||||||
|
self::assertSame(1, $vorschlaege[1]->auftraete);
|
||||||
|
// Staleness dominiert: das länger nicht gekochte Gericht hat den höheren Score.
|
||||||
|
self::assertGreaterThan($vorschlaege[1]->score, $vorschlaege[0]->score);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteWaehltNeuestenGegessenenEintragAlsRepraesentanten(): void
|
||||||
|
{
|
||||||
|
$query = new MahlzeitInspirationQuery($this->manager([
|
||||||
|
$this->gegessen(1, 'Bolognese', new \DateTimeImmutable('2026-09-01 00:00:00')),
|
||||||
|
$this->gegessen(4, 'Bolognese', new \DateTimeImmutable('2026-09-08 00:00:00')),
|
||||||
|
]), $this->clock());
|
||||||
|
|
||||||
|
$vorschlaege = $query->execute();
|
||||||
|
|
||||||
|
self::assertCount(1, $vorschlaege);
|
||||||
|
self::assertSame(4, $vorschlaege[0]->id);
|
||||||
|
self::assertEquals(new \DateTimeImmutable('2026-09-08 00:00:00'), $vorschlaege[0]->gegessenAm);
|
||||||
|
self::assertSame(2, $vorschlaege[0]->auftraete);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteAusschlussSpruengtBereitsVorgeschlageneGerichte(): void
|
||||||
|
{
|
||||||
|
$query = new MahlzeitInspirationQuery($this->manager([
|
||||||
|
$this->gegessen(1, 'Bolognese', new \DateTimeImmutable('2026-09-08 00:00:00')),
|
||||||
|
$this->gegessen(3, 'Curry', new \DateTimeImmutable('2026-09-15 00:00:00')),
|
||||||
|
]), $this->clock());
|
||||||
|
|
||||||
|
// "Weitere 5": Bolognese (id 1) wurde bereits vorgeschlagen.
|
||||||
|
$vorschlaege = $query->execute(5, [1]);
|
||||||
|
|
||||||
|
self::assertCount(1, $vorschlaege);
|
||||||
|
self::assertSame('Curry', $vorschlaege[0]->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteBemaessErgebnisAnLimit(): void
|
||||||
|
{
|
||||||
|
$query = new MahlzeitInspirationQuery($this->manager([
|
||||||
|
$this->gegessen(1, 'Bolognese', new \DateTimeImmutable('2026-09-08 00:00:00')),
|
||||||
|
$this->gegessen(2, 'Curry', new \DateTimeImmutable('2026-09-12 00:00:00')),
|
||||||
|
$this->gegessen(3, 'Pasta', new \DateTimeImmutable('2026-09-15 00:00:00')),
|
||||||
|
]), $this->clock());
|
||||||
|
|
||||||
|
$vorschlaege = $query->execute(2);
|
||||||
|
|
||||||
|
self::assertCount(2, $vorschlaege);
|
||||||
|
self::assertSame(['Bolognese', 'Curry'], array_map(static fn ($v) => $v->name, $vorschlaege));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteGleichstandEntscheidetAlphabetisch(): void
|
||||||
|
{
|
||||||
|
// Beide Gerichte: gleiche Pause, gleiche Auftritte → Score-Gleichstand.
|
||||||
|
$query = new MahlzeitInspirationQuery($this->manager([
|
||||||
|
$this->gegessen(1, 'Zwiebelsuppe', new \DateTimeImmutable('2026-09-13 00:00:00')),
|
||||||
|
$this->gegessen(2, 'Apfelkuchen', new \DateTimeImmutable('2026-09-13 00:00:00')),
|
||||||
|
]), $this->clock());
|
||||||
|
|
||||||
|
$vorschlaege = $query->execute();
|
||||||
|
|
||||||
|
self::assertSame(['Apfelkuchen', 'Zwiebelsuppe'], array_map(static fn ($v) => $v->name, $vorschlaege));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?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\Event\MahlzeitAdoptedEvent;
|
||||||
|
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\MahlzeitAdoptUseCase;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class MahlzeitAdoptUseCaseTest extends TestCase
|
||||||
|
{
|
||||||
|
private function tx(): TransactionManagerInterface
|
||||||
|
{
|
||||||
|
$tx = $this->createStub(TransactionManagerInterface::class);
|
||||||
|
$tx->method('execute')->willReturnCallback(static fn (\Closure $work): mixed => $work());
|
||||||
|
return $tx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clock(): ClockInterface
|
||||||
|
{
|
||||||
|
$clock = $this->createStub(ClockInterface::class);
|
||||||
|
$clock->method('now')->willReturn(new \DateTimeImmutable('2026-09-18 12:00:00'));
|
||||||
|
return $clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteKopiertQuelleAlsNeuenGeplantEintrag(): void
|
||||||
|
{
|
||||||
|
$jetzt = new \DateTimeImmutable('2026-09-18 12:00:00');
|
||||||
|
$quelle = new Mahlzeit(7, 'Bolognese', 'mit Parmesan', [Wochentag::Mo], 3, MahlzeitStatus::Gegessen, $jetzt, $jetzt, $jetzt);
|
||||||
|
|
||||||
|
$manager = $this->createMock(MahlzeitManagerInterface::class);
|
||||||
|
$manager->expects(self::once())->method('find')->with(7)->willReturn($quelle);
|
||||||
|
$manager->expects(self::once())->method('create')->willReturnCallback(function (Mahlzeit $m) {
|
||||||
|
// Neue, geplante Kopie: gleiche Inhalte, aber ohne gegessenAm und ohne ID.
|
||||||
|
self::assertSame('Bolognese', $m->name);
|
||||||
|
self::assertSame('mit Parmesan', $m->notiz);
|
||||||
|
self::assertSame([Wochentag::Mo], $m->wochentage);
|
||||||
|
self::assertSame(MahlzeitStatus::Geplant, $m->status);
|
||||||
|
self::assertNull($m->gegessenAm);
|
||||||
|
self::assertSame(0, $m->sortOrder);
|
||||||
|
self::assertSame(0, $m->id);
|
||||||
|
// Persistenz vergibt die neue ID.
|
||||||
|
return $m->mitId(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
$events = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
$events->expects(self::once())->method('dispatch')->with(
|
||||||
|
self::equalTo(new MahlzeitAdoptedEvent(id: 42, name: 'Bolognese', quellId: 7)),
|
||||||
|
);
|
||||||
|
|
||||||
|
$useCase = new MahlzeitAdoptUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock());
|
||||||
|
|
||||||
|
$response = $useCase->execute(7);
|
||||||
|
|
||||||
|
self::assertSame(42, $response->id);
|
||||||
|
self::assertSame('Bolognese', $response->name);
|
||||||
|
self::assertSame(MahlzeitStatus::Geplant, $response->status);
|
||||||
|
self::assertNull($response->gegessenAm);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteUnbekannteQuelleWirft404UndLegtNichtsAn(): void
|
||||||
|
{
|
||||||
|
$manager = $this->createMock(MahlzeitManagerInterface::class);
|
||||||
|
$manager->method('find')->willThrowException(new MahlzeitNotFoundException(99));
|
||||||
|
$manager->expects(self::never())->method('create');
|
||||||
|
|
||||||
|
$events = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
$events->expects(self::never())->method('dispatch');
|
||||||
|
|
||||||
|
$useCase = new MahlzeitAdoptUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock());
|
||||||
|
|
||||||
|
$this->expectException(MahlzeitNotFoundException::class);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$useCase->execute(99);
|
||||||
|
} finally {
|
||||||
|
// Erwartete Exception oben.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?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\MahlzeitResponse;
|
||||||
|
use App\Logic\Essen\Event\MahlzeitStatusChangedEvent;
|
||||||
|
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\Model\Wochentag;
|
||||||
|
use App\Logic\Essen\UseCase\MahlzeitCheckOffUseCase;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class MahlzeitCheckOffUseCaseTest 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 geplant(): Mahlzeit
|
||||||
|
{
|
||||||
|
$jetzt = $this->jetzt();
|
||||||
|
return new Mahlzeit(7, 'Bolognese', '', [Wochentag::Mo], 1, MahlzeitStatus::Geplant, $jetzt, $jetzt, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteMarkiertAlsGegessenUndDispatchtEvent(): void
|
||||||
|
{
|
||||||
|
$erfasstesModell = null;
|
||||||
|
$manager = $this->createMock(MahlzeitManagerInterface::class);
|
||||||
|
$manager->expects(self::once())->method('find')->with(7)->willReturn($this->geplant());
|
||||||
|
$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 MahlzeitStatusChangedEvent(
|
||||||
|
id: 7,
|
||||||
|
name: 'Bolognese',
|
||||||
|
status: MahlzeitStatus::Gegessen,
|
||||||
|
gegessenAm: $this->jetzt(),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
$useCase = new MahlzeitCheckOffUseCase($this->tx(), $manager, $events, $this->clock());
|
||||||
|
|
||||||
|
$response = $useCase->execute(7);
|
||||||
|
|
||||||
|
self::assertSame(MahlzeitStatus::Gegessen, $erfasstesModell->status);
|
||||||
|
self::assertNotNull($erfasstesModell->gegessenAm);
|
||||||
|
self::assertEquals($this->jetzt(), $erfasstesModell->aktualisiertAm);
|
||||||
|
self::assertInstanceOf(MahlzeitResponse::class, $response);
|
||||||
|
self::assertSame(7, $response->id);
|
||||||
|
self::assertSame(MahlzeitStatus::Gegessen, $response->status);
|
||||||
|
self::assertNotNull($response->gegessenAm);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteIstIdempotentFuerBereitsGegessene(): void
|
||||||
|
{
|
||||||
|
$jetzt = $this->jetzt();
|
||||||
|
$bereitsGegessen = new Mahlzeit(7, 'Bolognese', '', [Wochentag::Mo], 1, MahlzeitStatus::Gegessen, $jetzt, $jetzt, $jetzt);
|
||||||
|
|
||||||
|
$manager = $this->createMock(MahlzeitManagerInterface::class);
|
||||||
|
$manager->expects(self::once())->method('find')->with(7)->willReturn($bereitsGegessen);
|
||||||
|
$manager->expects(self::once())->method('update')->willReturnArgument(0);
|
||||||
|
|
||||||
|
$events = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
$events->expects(self::once())->method('dispatch');
|
||||||
|
|
||||||
|
$useCase = new MahlzeitCheckOffUseCase($this->tx(), $manager, $events, $this->clock());
|
||||||
|
|
||||||
|
$response = $useCase->execute(7);
|
||||||
|
|
||||||
|
// Ursprünglicher gegessenAm-Zeitpunkt bleibt erhalten (markAsGegessen ist idempotent).
|
||||||
|
self::assertSame($jetzt, $response->gegessenAm);
|
||||||
|
self::assertSame(MahlzeitStatus::Gegessen, $response->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteWirft404UndDispatchtKeinEvent(): 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 MahlzeitCheckOffUseCase($this->tx(), $manager, $events, $this->clock());
|
||||||
|
|
||||||
|
$this->expectException(MahlzeitNotFoundException::class);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$useCase->execute(99);
|
||||||
|
} finally {
|
||||||
|
// Erwartete Exception oben.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,107 @@
|
|||||||
|
<?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\Dto\MahlzeitReorderRequest;
|
||||||
|
use App\Logic\Essen\Event\MahlzeitReorderedEvent;
|
||||||
|
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\MahlzeitReorderUseCase;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class MahlzeitReorderUseCaseTest extends TestCase
|
||||||
|
{
|
||||||
|
private function tx(): TransactionManagerInterface
|
||||||
|
{
|
||||||
|
$tx = $this->createStub(TransactionManagerInterface::class);
|
||||||
|
$tx->method('execute')->willReturnCallback(static fn (\Closure $work): mixed => $work());
|
||||||
|
return $tx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mahlzeit(int $id, int $sortOrder): Mahlzeit
|
||||||
|
{
|
||||||
|
$jetzt = new \DateTimeImmutable('2026-09-18 12:00:00');
|
||||||
|
return new Mahlzeit($id, 'Gericht' . $id, '', [], $sortOrder, MahlzeitStatus::Geplant, $jetzt, $jetzt, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteRenommertSortOrderUndDispatchtEvent(): void
|
||||||
|
{
|
||||||
|
$manager = $this->createMock(MahlzeitManagerInterface::class);
|
||||||
|
$manager->method('find')->willReturnMap([
|
||||||
|
[2, $this->mahlzeit(2, 2)],
|
||||||
|
[1, $this->mahlzeit(1, 1)],
|
||||||
|
[3, $this->mahlzeit(3, 3)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$gespeicherte = [];
|
||||||
|
$manager->expects(self::exactly(2))->method('update')->willReturnCallback(function (Mahlzeit $m) use (&$gespeicherte) {
|
||||||
|
$gespeicherte[$m->id] = $m->sortOrder;
|
||||||
|
return $m;
|
||||||
|
});
|
||||||
|
|
||||||
|
$events = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
$events->expects(self::once())->method('dispatch')->with(
|
||||||
|
self::equalTo(new MahlzeitReorderedEvent([2, 1, 3])),
|
||||||
|
);
|
||||||
|
|
||||||
|
$useCase = new MahlzeitReorderUseCase($this->tx(), $manager, $events);
|
||||||
|
$useCase->execute(new MahlzeitReorderRequest([2, 1, 3]));
|
||||||
|
|
||||||
|
// id 2 → Position 1, id 1 → Position 2; id 3 (Position 3) bleibt unverändert.
|
||||||
|
self::assertSame([2 => 1, 1 => 2], $gespeicherte);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteSpruengtBereitsKorrektePosition(): void
|
||||||
|
{
|
||||||
|
$manager = $this->createMock(MahlzeitManagerInterface::class);
|
||||||
|
$manager->method('find')->willReturnMap([
|
||||||
|
[1, $this->mahlzeit(1, 1)],
|
||||||
|
[2, $this->mahlzeit(2, 2)],
|
||||||
|
]);
|
||||||
|
$manager->expects(self::never())->method('update');
|
||||||
|
|
||||||
|
$events = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
$events->expects(self::once())->method('dispatch');
|
||||||
|
|
||||||
|
$useCase = new MahlzeitReorderUseCase($this->tx(), $manager, $events);
|
||||||
|
$useCase->execute(new MahlzeitReorderRequest([1, 2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteLeereListeIstNoOp(): void
|
||||||
|
{
|
||||||
|
$manager = $this->createMock(MahlzeitManagerInterface::class);
|
||||||
|
$manager->expects(self::never())->method('find');
|
||||||
|
$manager->expects(self::never())->method('update');
|
||||||
|
|
||||||
|
$events = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
$events->expects(self::never())->method('dispatch');
|
||||||
|
|
||||||
|
$useCase = new MahlzeitReorderUseCase($this->tx(), $manager, $events);
|
||||||
|
$useCase->execute(new MahlzeitReorderRequest([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteUnbekannteIdWirft404(): 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 MahlzeitReorderUseCase($this->tx(), $manager, $events);
|
||||||
|
|
||||||
|
$this->expectException(MahlzeitNotFoundException::class);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$useCase->execute(new MahlzeitReorderRequest([99]));
|
||||||
|
} finally {
|
||||||
|
// Erwartete Exception oben.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<?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\MahlzeitResponse;
|
||||||
|
use App\Logic\Essen\Event\MahlzeitStatusChangedEvent;
|
||||||
|
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\Model\Wochentag;
|
||||||
|
use App\Logic\Essen\UseCase\MahlzeitUncheckUseCase;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class MahlzeitUncheckUseCaseTest 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 gegessen(): Mahlzeit
|
||||||
|
{
|
||||||
|
$jetzt = $this->jetzt();
|
||||||
|
return new Mahlzeit(7, 'Bolognese', '', [Wochentag::Mo], 1, MahlzeitStatus::Gegessen, $jetzt, $jetzt, $jetzt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteSetztZurueckAufGeplantUndDispatchtEvent(): void
|
||||||
|
{
|
||||||
|
$erfasstesModell = null;
|
||||||
|
$manager = $this->createMock(MahlzeitManagerInterface::class);
|
||||||
|
$manager->expects(self::once())->method('find')->with(7)->willReturn($this->gegessen());
|
||||||
|
$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 MahlzeitStatusChangedEvent(
|
||||||
|
id: 7,
|
||||||
|
name: 'Bolognese',
|
||||||
|
status: MahlzeitStatus::Geplant,
|
||||||
|
gegessenAm: null,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
$useCase = new MahlzeitUncheckUseCase($this->tx(), $manager, $events, $this->clock());
|
||||||
|
|
||||||
|
$response = $useCase->execute(7);
|
||||||
|
|
||||||
|
self::assertSame(MahlzeitStatus::Geplant, $erfasstesModell->status);
|
||||||
|
self::assertNull($erfasstesModell->gegessenAm, 'gegessenAm wird zurückgesetzt');
|
||||||
|
self::assertInstanceOf(MahlzeitResponse::class, $response);
|
||||||
|
self::assertSame(7, $response->id);
|
||||||
|
self::assertSame(MahlzeitStatus::Geplant, $response->status);
|
||||||
|
self::assertNull($response->gegessenAm);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteIstIdempotentFuerBereitsGeplante(): void
|
||||||
|
{
|
||||||
|
$jetzt = $this->jetzt();
|
||||||
|
$bereitsGeplant = new Mahlzeit(7, 'Bolognese', '', [Wochentag::Mo], 1, MahlzeitStatus::Geplant, $jetzt, $jetzt, null);
|
||||||
|
|
||||||
|
$manager = $this->createMock(MahlzeitManagerInterface::class);
|
||||||
|
$manager->expects(self::once())->method('find')->with(7)->willReturn($bereitsGeplant);
|
||||||
|
$manager->expects(self::once())->method('update')->willReturnArgument(0);
|
||||||
|
|
||||||
|
$events = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
$events->expects(self::once())->method('dispatch');
|
||||||
|
|
||||||
|
$useCase = new MahlzeitUncheckUseCase($this->tx(), $manager, $events, $this->clock());
|
||||||
|
|
||||||
|
$response = $useCase->execute(7);
|
||||||
|
|
||||||
|
self::assertSame(MahlzeitStatus::Geplant, $response->status);
|
||||||
|
self::assertNull($response->gegessenAm);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteWirft404UndDispatchtKeinEvent(): 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 MahlzeitUncheckUseCase($this->tx(), $manager, $events, $this->clock());
|
||||||
|
|
||||||
|
$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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\UI\Http\Auth;
|
||||||
|
|
||||||
|
use App\UI\Http\Auth\OidcClient;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HttpClient\MockHttpClient;
|
||||||
|
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regressions-Test für die RS256-IdToken-Verifizierung:
|
||||||
|
* Signiert ein JWT gegen einen lokal generierten RSA-Schlüssel und prüft
|
||||||
|
* OidcClient::verifyIdToken() inkl. JWKS→PEM-Konvertierung.
|
||||||
|
*/
|
||||||
|
final class OidcClientTest extends TestCase
|
||||||
|
{
|
||||||
|
private const ISSUER = 'https://issuer.test/realms/family';
|
||||||
|
private const CLIENT_ID = 'essen';
|
||||||
|
private const KID = 'test-kid-signing';
|
||||||
|
|
||||||
|
/** @var resource */
|
||||||
|
private $key;
|
||||||
|
/** @var array{n: string, e: string} */
|
||||||
|
private array $jwk;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->key = openssl_pkey_new([
|
||||||
|
'private_key_bits' => 2048,
|
||||||
|
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||||
|
]);
|
||||||
|
if ($this->key === false) {
|
||||||
|
self::fail('konnte RSA-Schlüssel generieren.');
|
||||||
|
}
|
||||||
|
$details = openssl_pkey_get_details($this->key);
|
||||||
|
$n = $details['n'] ?? $details['rsa']['n'];
|
||||||
|
$e = $details['e'] ?? $details['rsa']['e'];
|
||||||
|
$this->jwk = [
|
||||||
|
'n' => self::b64url($n),
|
||||||
|
'e' => self::b64url($e),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyIdTokenAkzeptiertGueltigSigniertesToken(): void
|
||||||
|
{
|
||||||
|
$jwt = $this->signJwt(['sub' => 'jens@ljh31.de', 'email' => 'jens@ljh31.de']);
|
||||||
|
|
||||||
|
$claims = $this->oidcClient()->verifyIdToken($jwt);
|
||||||
|
|
||||||
|
self::assertSame('jens@ljh31.de', $claims['sub']);
|
||||||
|
self::assertSame('jens@ljh31.de', $claims['email']);
|
||||||
|
self::assertSame(self::ISSUER, $claims['iss']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyIdTokenAkzeptiertAudienceAlsArray(): void
|
||||||
|
{
|
||||||
|
$jwt = $this->signJwt(['sub' => 'x'], ['aud' => ['essen', 'anderer-client']]);
|
||||||
|
|
||||||
|
$claims = $this->oidcClient()->verifyIdToken($jwt);
|
||||||
|
|
||||||
|
self::assertSame('x', $claims['sub']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyIdTokenVerwirftManipuliertesPayload(): void
|
||||||
|
{
|
||||||
|
$jwt = $this->signJwt(['sub' => 'jens@ljh31.de']);
|
||||||
|
$tampered = $this->tamper($jwt, 'jens@ljh31.de', 'admin@ljh31.de');
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->oidcClient()->verifyIdToken($tampered);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyIdTokenVerwirftFalscheIssuer(): void
|
||||||
|
{
|
||||||
|
$jwt = $this->signJwt(['sub' => 'x'], ['iss' => 'https://anderer.issuer/realms/family']);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->oidcClient()->verifyIdToken($jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyIdTokenVerwirftFalscheAudience(): void
|
||||||
|
{
|
||||||
|
$jwt = $this->signJwt(['sub' => 'x'], ['aud' => 'falscher-client']);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->oidcClient()->verifyIdToken($jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyIdTokenVerwirftAbgelaufenesToken(): void
|
||||||
|
{
|
||||||
|
$jwt = $this->signJwt(['sub' => 'x'], ['exp' => time() - 3600]);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->oidcClient()->verifyIdToken($jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyIdTokenWähltRichtigenKeyBeiMehrfachJwks(): void
|
||||||
|
{
|
||||||
|
// Zusätzliches RSA-OAEP-Enc-Key (wie in der echten Keycloak-Realm):
|
||||||
|
// muss ignoriert werden, obwohl es denselben Modulus teilt.
|
||||||
|
$jwt = $this->signJwt(['sub' => 'x']);
|
||||||
|
|
||||||
|
$claims = $this->oidcClient(extraKey: [
|
||||||
|
'kty' => 'RSA',
|
||||||
|
'alg' => 'RSA-OAEP',
|
||||||
|
'use' => 'enc',
|
||||||
|
'kid' => 'test-kid-enc',
|
||||||
|
'n' => $this->jwk['n'],
|
||||||
|
'e' => $this->jwk['e'],
|
||||||
|
])->verifyIdToken($jwt);
|
||||||
|
|
||||||
|
self::assertSame('x', $claims['sub']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function signJwt(array $claims, array $overrides = []): string
|
||||||
|
{
|
||||||
|
$header = ['alg' => 'RS256', 'typ' => 'JWT', 'kid' => self::KID];
|
||||||
|
$payload = array_merge([
|
||||||
|
'iss' => self::ISSUER,
|
||||||
|
'aud' => self::CLIENT_ID,
|
||||||
|
'exp' => time() + 300,
|
||||||
|
'iat' => time(),
|
||||||
|
], $overrides, $claims);
|
||||||
|
|
||||||
|
$h64 = self::b64url((string) json_encode($header));
|
||||||
|
$p64 = self::b64url((string) json_encode($payload));
|
||||||
|
if (openssl_sign("$h64.$p64", $sig, $this->key, OPENSSL_ALGO_SHA256) !== true) {
|
||||||
|
self::fail('konnte Test-JWT signieren.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return "$h64.$p64." . self::b64url($sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tamper(string $jwt, string $from, string $to): string
|
||||||
|
{
|
||||||
|
$parts = explode('.', $jwt);
|
||||||
|
$payload = json_decode((string) base64_decode(strtr($parts[1], '-_', '+/')), true);
|
||||||
|
$payload['sub'] = $to;
|
||||||
|
$parts[1] = self::b64url((string) json_encode($payload));
|
||||||
|
return implode('.', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function oidcClient(?array $extraKey = null): OidcClient
|
||||||
|
{
|
||||||
|
$keys = [[
|
||||||
|
'kty' => 'RSA',
|
||||||
|
'alg' => 'RS256',
|
||||||
|
'use' => 'sig',
|
||||||
|
'kid' => self::KID,
|
||||||
|
'n' => $this->jwk['n'],
|
||||||
|
'e' => $this->jwk['e'],
|
||||||
|
]];
|
||||||
|
if ($extraKey !== null) {
|
||||||
|
array_unshift($keys, $extraKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
$httpClient = new MockHttpClient(new MockResponse((string) json_encode(['keys' => $keys])));
|
||||||
|
|
||||||
|
return new OidcClient(
|
||||||
|
httpClient: $httpClient,
|
||||||
|
issuer: self::ISSUER,
|
||||||
|
clientId: self::CLIENT_ID,
|
||||||
|
clientSecret: null,
|
||||||
|
redirectUri: 'https://app.test/auth/callback',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function b64url(string $binary): string
|
||||||
|
{
|
||||||
|
return rtrim(strtr(base64_encode($binary), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user