502 lines
18 KiB
PHP
502 lines
18 KiB
PHP
<?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();
|
||
}
|
||
}
|