From 160b89f5a10117987817205eb2650388a46b05c3 Mon Sep 17 00:00:00 2001 From: Jens Date: Sat, 19 Sep 2026 10:17:55 +0200 Subject: [PATCH] =?UTF-8?q?M1:=20CRUD-Slice=20'Mahlzeit'=20Schritt=202=20?= =?UTF-8?q?=E2=80=93=20UI=20(Controller,=20Templates,=20Routen,=20Feature-?= =?UTF-8?q?Tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Doku/architektur.md | 2 +- Doku/plan.md | 6 +- config/packages/doctrine.yaml | 4 +- config/routes.yaml | 37 ++++ .../EntityManagerTransactionManager.php | 2 +- .../Common/TransactionManagerInterface.php | 2 +- src/UI/Http/Essen/MahlzeitController.php | 148 +++++++++++++ templates/layout/base.html.twig | 49 ++++- templates/mahlzeiten/form.html.twig | 48 +++++ templates/mahlzeiten/index.html.twig | 46 ++++ .../UI/Essen/MahlzeitControllerTest.php | 201 ++++++++++++++++++ 11 files changed, 536 insertions(+), 9 deletions(-) create mode 100644 src/UI/Http/Essen/MahlzeitController.php create mode 100644 templates/mahlzeiten/form.html.twig create mode 100644 templates/mahlzeiten/index.html.twig create mode 100644 tests/Functional/UI/Essen/MahlzeitControllerTest.php diff --git a/Doku/architektur.md b/Doku/architektur.md index e5e6fb8..cf99a31 100644 --- a/Doku/architektur.md +++ b/Doku/architektur.md @@ -391,7 +391,7 @@ tests/ │ ├── Essen/UseCase/MahlzeitCreateUseCaseTest.php │ ├── Essen/Query/InspirationQueryTest.php // mit gemocktem Provider │ └── Essen/Mapping/MahlzeitFactoryTest.php -├── Integration/Data/ // Test-Env + SQLite (oder in-memory) +├── Integration/Data/ // Test-Env + file-basierte SQLite (var/test.db) │ ├── Essen/MahlzeitProviderTest.php │ ├── Essen/MahlzeitProcessorTest.php │ └── Essen/MahlzeitMapperTest.php diff --git a/Doku/plan.md b/Doku/plan.md index d9940c4..d0d12e8 100644 --- a/Doku/plan.md +++ b/Doku/plan.md @@ -38,7 +38,7 @@ Status: **final** (2026-09-13, abgestimmt) · Basis: [`anforderungen.md`](anford `MahlzeitManager`, `Mahlzeit{Create,Update,Delete}UseCase`, `MahlzeitListQuery`, `MahlzeitNotFoundException` (→404), Events; `src/Data/Essen` – `MahlzeitEntity` (Tabelle `essens_mahlzeiten`, wochentage als CSV), `MahlzeitMapper/Provider/Processor`, Migration. - Tests: UseCase/Model-Unit + Provider/Processor-Integration (in-mem SQLite). + Tests: UseCase/Model-Unit (Pure PHP, Fakes) + Provider/Processor-Integration (file-basierte SQLite, `var/test.db`). **DoD 1:** `composer test` grün, CRUD vollständig über Logic-API testbar. - **Schritt 2 (UI):** `MahlzeitController` + `templates/mahlzeiten/` + Routen `/mahlzeiten[/{id}]` (POST/Redirect/Flash, Wochentage als Checkboxes) + Controller-Feature-Test (eingeloggte Session). @@ -69,8 +69,8 @@ Status: **final** (2026-09-13, abgestimmt) · Basis: [`anforderungen.md`](anford ## Test-Strategie (quer) -- PHPUnit je Milestone: UseCase-Unit (in-mem SQLite, Logic-Dependencies als - Fakes) + Feature-Tests (HTTP gegen Test-DB) +- PHPUnit je Milestone: UseCase/Model-Unit (Pure PHP, Logic-Dependencies als + Fakes) + Integration-/Feature-Tests (HTTP/DB gegen file-basierte Test-DB `var/test.db`) - Manuell: `docker compose run app composer test` bzw. `vendor/bin/phpunit` - Kein CI (bewusst, s. Prinzipien) diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index ffd9728..2c6f53a 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -16,4 +16,6 @@ doctrine: when@test: doctrine: 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' diff --git a/config/routes.yaml b/config/routes.yaml index 5273a06..b275cc8 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -19,3 +19,40 @@ logout: path: /logout controller: App\UI\Http\Auth\OidcAuthController::logout 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] + +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+ diff --git a/src/Data/Infrastructure/Doctrine/EntityManagerTransactionManager.php b/src/Data/Infrastructure/Doctrine/EntityManagerTransactionManager.php index 292ea43..1daa2e0 100644 --- a/src/Data/Infrastructure/Doctrine/EntityManagerTransactionManager.php +++ b/src/Data/Infrastructure/Doctrine/EntityManagerTransactionManager.php @@ -22,7 +22,7 @@ final class EntityManagerTransactionManager implements TransactionManagerInterfa public function execute(\Closure $work): mixed { try { - return $this->em->transactional(static fn () => $work($work)); + return $this->em->wrapInTransaction($work); } catch (\Throwable $e) { $this->em->clear(); throw $e; diff --git a/src/Logic/Common/TransactionManagerInterface.php b/src/Logic/Common/TransactionManagerInterface.php index d81759f..5e3e929 100644 --- a/src/Logic/Common/TransactionManagerInterface.php +++ b/src/Logic/Common/TransactionManagerInterface.php @@ -11,7 +11,7 @@ interface TransactionManagerInterface * Bei Exception wird zurückgerollt, ansonsten committiert. * * @template T - * @param callable(\Closure|\Throwable): T $work + * @param \Closure(): T $work * @return T * @throws \Throwable */ diff --git a/src/UI/Http/Essen/MahlzeitController.php b/src/UI/Http/Essen/MahlzeitController.php new file mode 100644 index 0000000..9c49b7b --- /dev/null +++ b/src/UI/Http/Essen/MahlzeitController.php @@ -0,0 +1,148 @@ +render('mahlzeiten/index.html.twig', [ + 'mahlzeiten' => $this->list->execute(new MahlzeitListRequest())->items, + ]); + } + + /** 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'); + } + + /** + * Bindet die Formularfelder (Name/Notiz/Wochentage) aus dem Request. + * + * @return array{0: string, 1: string, 2: list} + */ + 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]; + } + + /** + * @param array{name: string, notiz: string, wochentage: list} $werte + * @return array + */ + private function formKontext(?Mahlzeit $mahlzeit, ?string $fehler, array $werte): array + { + return [ + 'mahlzeit' => $mahlzeit, + 'fehler' => $fehler, + 'werte' => $werte, + 'wochentage_choices' => Wochentag::alle(), + ]; + } +} diff --git a/templates/layout/base.html.twig b/templates/layout/base.html.twig index 356a19d..fba7bfb 100644 --- a/templates/layout/base.html.twig +++ b/templates/layout/base.html.twig @@ -12,6 +12,7 @@ --accent: #0a7bff; --muted: #667; --warn: #b45309; + --danger: #b91c1c; } * { box-sizing: border-box; } body { @@ -44,17 +45,61 @@ border-radius: 0.5rem; box-shadow: 0 1px 2px rgba(0,0,0,.06); } + .muted { color: var(--muted); font-size: 0.9rem; } + .row-between { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; } + .btn { + display: inline-block; + background: var(--accent); + color: #fff; + padding: 0.4rem 0.8rem; + border-radius: 0.35rem; + text-decoration: none; + border: none; + font: inherit; + cursor: pointer; + } + .btn:hover { opacity: 0.92; } + .btn-danger { background: var(--danger); } + .btn-link { background: transparent; color: var(--muted); } + .actions { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } + .list { list-style: none; margin: 1rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.75rem; } + .item { display: flex; justify-content: space-between; gap: 1rem; padding: 0.75rem; border: 1px solid #eee; border-radius: 0.4rem; align-items: center; flex-wrap: wrap; } + .tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.4rem; } + .tag { background: #eef4ff; color: var(--accent); padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.8rem; } + .status { font-size: 0.8rem; color: var(--muted); } + .status-gegessen { color: #15573b; } + .field { margin-bottom: 1rem; } + .field > label { display: block; font-weight: 600; margin-bottom: 0.3rem; } + .field input[type="text"], .field textarea { + width: 100%; + padding: 0.5rem; + border: 1px solid #ccc; + border-radius: 0.35rem; + font: inherit; + } + .req { color: var(--danger); } + .checkboxes { display: flex; flex-wrap: wrap; gap: 0.5rem 0.9rem; margin-top: 0.3rem; } + .checkbox { display: inline-flex; align-items: center; gap: 0.3rem; font-weight: 400; cursor: pointer; } + .flash { padding: 0.6rem 0.9rem; border-radius: 0.4rem; margin-bottom: 1rem; } + .flash-notice { background: #e6f6ec; color: #15573b; } + .flash-error { background: #fdecec; color: #8a1f1f; } {% block stylesheets %}{% endblock %}
- essen.ljh31.de + essen.ljh31.de
+ {% for label, messages in app.flashes %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endfor %} {% block body %}{% endblock %}
diff --git a/templates/mahlzeiten/form.html.twig b/templates/mahlzeiten/form.html.twig new file mode 100644 index 0000000..3518721 --- /dev/null +++ b/templates/mahlzeiten/form.html.twig @@ -0,0 +1,48 @@ +{% extends 'layout/base.html.twig' %} + +{% set titel = mahlzeit ? 'Mahlzeit bearbeiten' : 'Neue Mahlzeit' %} + +{% block title %}{{ titel }} · svc-essensplanung{% endblock %} + +{% block body %} +
+

{{ titel }}

+ + {% if fehler %} +
{{ fehler }}
+ {% endif %} + +
+ +
+ + +
+ +
+ + +
+ +
+ + {% set gewaehlte = werte.wochentage|map(t => t.value) %} +
+ {% for tag in wochentage_choices %} + + {% endfor %} +
+
+ +
+ + Abbrechen +
+
+
+{% endblock %} diff --git a/templates/mahlzeiten/index.html.twig b/templates/mahlzeiten/index.html.twig new file mode 100644 index 0000000..25e4abe --- /dev/null +++ b/templates/mahlzeiten/index.html.twig @@ -0,0 +1,46 @@ +{% extends 'layout/base.html.twig' %} + +{% block title %}Mahlzeiten · svc-essensplanung{% endblock %} + +{% block body %} +
+
+

Mahlzeiten

+ + Neue Mahlzeit +
+ + {% if mahlzeiten|length == 0 %} +

Noch keine Mahlzeiten angelegt.

+ {% else %} +
    + {% for m in mahlzeiten %} +
  • +
    + {{ m.name }} + {% if m.notiz != '' %} +
    {{ m.notiz }}
    + {% endif %} + {% if m.wochentage|length > 0 %} +
    + {% for tag in m.wochentage %} + {{ tag.name }} + {% endfor %} +
    + {% endif %} + + {{ m.status.value == 'gegessen' ? 'gegessen' : 'geplant' }} + +
    +
    + Bearbeiten +
    + +
    +
    +
  • + {% endfor %} +
+ {% endif %} +
+{% endblock %} diff --git a/tests/Functional/UI/Essen/MahlzeitControllerTest.php b/tests/Functional/UI/Essen/MahlzeitControllerTest.php new file mode 100644 index 0000000..cd5a3d1 --- /dev/null +++ b/tests/Functional/UI/Essen/MahlzeitControllerTest.php @@ -0,0 +1,201 @@ + '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"]'); + } + + /** + * 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(); + } +}