Files
svc-essensplanung/tests/Functional/UI/Essen/MahlzeitControllerTest.php
T

502 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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();
}
}