M1: CRUD-Slice 'Mahlzeit' Schritt 2 – UI (Controller, Templates, Routen, Feature-Tests)

This commit is contained in:
2026-09-19 10:17:55 +02:00
parent 1589088379
commit 160b89f5a1
11 changed files with 536 additions and 9 deletions
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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)
+3 -1
View File
@@ -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'
+37
View File
@@ -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+
@@ -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;
@@ -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
*/
+148
View File
@@ -0,0 +1,148 @@
<?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\MahlzeitUpdateRequest;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\Wochentag;
use App\Logic\Essen\Query\MahlzeitListQuery;
use App\Logic\Essen\UseCase\MahlzeitCreateUseCase;
use App\Logic\Essen\UseCase\MahlzeitDeleteUseCase;
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) – Web-UI.
*
* Stil: klassisches POST/Redirect/Flash (htmx-Toggles kommen in M2).
*
* - Reads über `MahlzeitListQuery` / `MahlzeitManagerInterface::find`.
* - Writes über die UseCases (Create/Update/Delete).
* - 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 MahlzeitListQuery $list,
private readonly MahlzeitManagerInterface $mahlzeiten,
) {}
/** GET /mahlzeiten — Planungsliste. */
public function index(): Response
{
return $this->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<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];
}
/**
* @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(),
];
}
}
+47 -2
View File
@@ -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; }
</style>
{% block stylesheets %}{% endblock %}
</head>
<body>
<header>
<strong>essen.ljh31.de</strong>
<a href="{{ path('home') }}" style="color: inherit; font-weight: 600;">essen.ljh31.de</a>
<nav>
<a href="logout">Abmelden</a>
<a href="{{ path('mahlzeiten_index') }}">Mahlzeiten</a>
<a href="{{ path('logout') }}">Abmelden</a>
</nav>
</header>
<main>
{% for label, messages in app.flashes %}
{% for message in messages %}
<div class="flash flash-{{ label }}">{{ message }}</div>
{% endfor %}
{% endfor %}
{% block body %}{% endblock %}
</main>
<script src="{{ asset('js/htmx.min.js') }}"></script>
+48
View File
@@ -0,0 +1,48 @@
{% extends 'layout/base.html.twig' %}
{% set titel = mahlzeit ? 'Mahlzeit bearbeiten' : 'Neue Mahlzeit' %}
{% block title %}{{ titel }} · svc-essensplanung{% endblock %}
{% block body %}
<div class="card">
<h1 style="margin-top: 0;">{{ titel }}</h1>
{% 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>
</div>
<div class="field">
<label for="notiz">Notiz</label>
<textarea id="notiz" name="notiz" rows="3" placeholder="z. B. mit Basilikum, scharf …">{{ 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">
<button type="submit" class="btn">{{ mahlzeit ? 'Speichern' : 'Anlegen' }}</button>
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">Abbrechen</a>
</div>
</form>
</div>
{% endblock %}
+46
View File
@@ -0,0 +1,46 @@
{% extends 'layout/base.html.twig' %}
{% block title %}Mahlzeiten · svc-essensplanung{% endblock %}
{% block body %}
<div class="card">
<div class="row-between">
<h1 style="margin: 0;">Mahlzeiten</h1>
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a>
</div>
{% if mahlzeiten|length == 0 %}
<p class="muted" style="margin-top: 1rem;">Noch keine Mahlzeiten angelegt.</p>
{% else %}
<ul class="list">
{% for m in mahlzeiten %}
<li class="item">
<div>
<strong>{{ m.name }}</strong>
{% if m.notiz != '' %}
<div class="muted">{{ m.notiz }}</div>
{% endif %}
{% if m.wochentage|length > 0 %}
<div class="tags">
{% for tag in m.wochentage %}
<span class="tag">{{ tag.name }}</span>
{% endfor %}
</div>
{% endif %}
<span class="status {{ m.status.value == 'gegessen' ? 'status-gegessen' : '' }}">
{{ m.status.value == 'gegessen' ? 'gegessen' : 'geplant' }}
</span>
</div>
<div class="actions">
<a class="btn btn-link" href="{{ path('mahlzeiten_bearbeiten', { id: m.id }) }}">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">Löschen</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,201 @@
<?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;
/**
* 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"]');
}
/**
* 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();
}
}