M1: CRUD-Slice 'Mahlzeit' Schritt 2 – UI (Controller, Templates, Routen, Feature-Tests)
This commit is contained in:
+1
-1
@@ -391,7 +391,7 @@ tests/
|
|||||||
│ ├── Essen/UseCase/MahlzeitCreateUseCaseTest.php
|
│ ├── Essen/UseCase/MahlzeitCreateUseCaseTest.php
|
||||||
│ ├── Essen/Query/InspirationQueryTest.php // mit gemocktem Provider
|
│ ├── Essen/Query/InspirationQueryTest.php // mit gemocktem Provider
|
||||||
│ └── Essen/Mapping/MahlzeitFactoryTest.php
|
│ └── 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/MahlzeitProviderTest.php
|
||||||
│ ├── Essen/MahlzeitProcessorTest.php
|
│ ├── Essen/MahlzeitProcessorTest.php
|
||||||
│ └── Essen/MahlzeitMapperTest.php
|
│ └── Essen/MahlzeitMapperTest.php
|
||||||
|
|||||||
+3
-3
@@ -38,7 +38,7 @@ Status: **final** (2026-09-13, abgestimmt) · Basis: [`anforderungen.md`](anford
|
|||||||
`MahlzeitManager`, `Mahlzeit{Create,Update,Delete}UseCase`, `MahlzeitListQuery`,
|
`MahlzeitManager`, `Mahlzeit{Create,Update,Delete}UseCase`, `MahlzeitListQuery`,
|
||||||
`MahlzeitNotFoundException` (→404), Events; `src/Data/Essen` – `MahlzeitEntity`
|
`MahlzeitNotFoundException` (→404), Events; `src/Data/Essen` – `MahlzeitEntity`
|
||||||
(Tabelle `essens_mahlzeiten`, wochentage als CSV), `MahlzeitMapper/Provider/Processor`, Migration.
|
(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.
|
**DoD 1:** `composer test` grün, CRUD vollständig über Logic-API testbar.
|
||||||
- **Schritt 2 (UI):** `MahlzeitController` + `templates/mahlzeiten/` + Routen `/mahlzeiten[/{id}]`
|
- **Schritt 2 (UI):** `MahlzeitController` + `templates/mahlzeiten/` + Routen `/mahlzeiten[/{id}]`
|
||||||
(POST/Redirect/Flash, Wochentage als Checkboxes) + Controller-Feature-Test (eingeloggte Session).
|
(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)
|
## Test-Strategie (quer)
|
||||||
|
|
||||||
- PHPUnit je Milestone: UseCase-Unit (in-mem SQLite, Logic-Dependencies als
|
- PHPUnit je Milestone: UseCase/Model-Unit (Pure PHP, Logic-Dependencies als
|
||||||
Fakes) + Feature-Tests (HTTP gegen Test-DB)
|
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`
|
- Manuell: `docker compose run app composer test` bzw. `vendor/bin/phpunit`
|
||||||
- Kein CI (bewusst, s. Prinzipien)
|
- Kein CI (bewusst, s. Prinzipien)
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -19,3 +19,40 @@ 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]
|
||||||
|
|
||||||
|
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
|
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;
|
||||||
|
|||||||
@@ -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,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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
--accent: #0a7bff;
|
--accent: #0a7bff;
|
||||||
--muted: #667;
|
--muted: #667;
|
||||||
--warn: #b45309;
|
--warn: #b45309;
|
||||||
|
--danger: #b91c1c;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
@@ -44,17 +45,61 @@
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,.06);
|
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>
|
</style>
|
||||||
{% block stylesheets %}{% endblock %}
|
{% block stylesheets %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<strong>essen.ljh31.de</strong>
|
<a href="{{ path('home') }}" style="color: inherit; font-weight: 600;">essen.ljh31.de</a>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="logout">Abmelden</a>
|
<a href="{{ path('mahlzeiten_index') }}">Mahlzeiten</a>
|
||||||
|
<a href="{{ path('logout') }}">Abmelden</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user