diff --git a/src/Booking/BoardPresenter.php b/src/Booking/BoardPresenter.php index d83142e..10d134c 100644 --- a/src/Booking/BoardPresenter.php +++ b/src/Booking/BoardPresenter.php @@ -98,11 +98,11 @@ final readonly class BoardPresenter */ public static function urlParameters(ResourceType $type, \DateTimeImmutable $date, TimeRange $time): array { - $parameters = ['type' => $type->slug(), 'tag' => $date->format('Y-m-d')]; + $parameters = ['type' => $type->value, 'date' => $date->format('Y-m-d')]; if (!$time->isAllDay()) { - $parameters['von'] = $time->start(); - $parameters['bis'] = $time->end(); + $parameters['from'] = $time->start(); + $parameters['to'] = $time->end(); } return $parameters; diff --git a/src/Booking/TimeRange.php b/src/Booking/TimeRange.php index 06b8c31..6160a09 100644 --- a/src/Booking/TimeRange.php +++ b/src/Booking/TimeRange.php @@ -21,11 +21,11 @@ final readonly class TimeRange implements \Stringable public int $endMinute, ) { if ($startMinute < self::DAY_START || $endMinute > self::DAY_END) { - throw new \InvalidArgumentException('Eine Buchung muss innerhalb eines Tages liegen.'); + throw new \InvalidArgumentException('A time range must stay within a single day.'); } if ($startMinute >= $endMinute) { - throw new \InvalidArgumentException('Das Ende muss nach dem Beginn liegen.'); + throw new \InvalidArgumentException('A time range must end after it starts.'); } } diff --git a/src/Command/SeedCommand.php b/src/Command/SeedCommand.php index f354981..e7ca84b 100644 --- a/src/Command/SeedCommand.php +++ b/src/Command/SeedCommand.php @@ -66,7 +66,7 @@ final class SeedCommand extends Command $this->em->flush(); - $io->success(\sprintf('%d neue Datensätze angelegt.', $created)); + $io->success(\sprintf('%d new records created.', $created)); return Command::SUCCESS; } diff --git a/src/Controller/BoardController.php b/src/Controller/BoardController.php index fd50efc..3438951 100644 --- a/src/Controller/BoardController.php +++ b/src/Controller/BoardController.php @@ -29,11 +29,11 @@ final class BoardController extends AbstractController { } - #[Route('/buchen/{type}', name: 'app_board', requirements: ['type' => '[a-z]+'], methods: ['GET'])] + #[Route('/book/{type}', name: 'app_board', requirements: ['type' => '[a-z]+'], methods: ['GET'])] public function index(Request $request, string $type): Response { $resourceType = $this->resourceType($type); - $date = DateParam::parseOrToday($request->query->getString('tag')); + $date = DateParam::parseOrToday($request->query->getString('date')); return $this->render('board/index.html.twig', $this->board->context( $resourceType, @@ -51,7 +51,7 @@ final class BoardController extends AbstractController * client-side event choreography. Requests without the htmx header get a plain redirect * rather than a bare pair of fragments, so the URL stays shareable. */ - #[Route('/buchen/{type}/tag/{date}', name: 'app_board_day', requirements: ['type' => '[a-z]+', 'date' => DateParam::PATTERN], methods: ['GET'])] + #[Route('/book/{type}/day/{date}', name: 'app_board_day', requirements: ['type' => '[a-z]+', 'date' => DateParam::PATTERN], methods: ['GET'])] public function day(Request $request, string $type, string $date): Response { $resourceType = $this->resourceType($type); @@ -76,19 +76,17 @@ final class BoardController extends AbstractController */ public static function timeOf(Request $request): TimeRange { - return TimeRange::parse($request->query->getString('von'), $request->query->getString('bis')) + return TimeRange::parse($request->query->getString('from'), $request->query->getString('to')) ?? TimeRange::wholeDay(); } /** - * Route requirements cannot call a method, so the slug is matched loosely and resolved here. + * Route requirements cannot enumerate the enum, so {type} is matched loosely and resolved + * here -- an unknown kind is a 404 rather than a 500. */ - private function resourceType(string $slug): ResourceType + private function resourceType(string $value): ResourceType { - try { - return ResourceType::fromSlug($slug); - } catch (\ValueError $e) { - throw $this->createNotFoundException($e->getMessage(), $e); - } + return ResourceType::tryFrom($value) + ?? throw $this->createNotFoundException(\sprintf('Unknown bookable kind "%s".', $value)); } } diff --git a/src/Controller/BookingController.php b/src/Controller/BookingController.php index a80224a..ee07af4 100644 --- a/src/Controller/BookingController.php +++ b/src/Controller/BookingController.php @@ -40,7 +40,7 @@ final class BookingController extends AbstractController ) { } - #[Route('/buchungen', name: 'app_booking', methods: ['GET'])] + #[Route('/bookings', name: 'app_booking', methods: ['GET'])] public function index(): Response { $today = new \DateTimeImmutable('today'); @@ -60,11 +60,11 @@ final class BookingController extends AbstractController * out-of-band board blocks, so the modal's outerHTML target is swapped with an empty * fragment and the modal simply disappears. */ - #[Route('/buchungen/neu/{id}/{date}', name: 'app_booking_new', requirements: ['date' => DateParam::PATTERN], methods: ['GET', 'POST'])] + #[Route('/bookings/new/{id}/{date}', name: 'app_booking_new', requirements: ['date' => DateParam::PATTERN], methods: ['GET', 'POST'])] public function create(Request $request, Resource $resource, string $date): Response { if (!$resource->isActive()) { - throw $this->createNotFoundException('Diese Ressource wird nicht mehr angeboten.'); + throw $this->createNotFoundException('This resource has been retired.'); } $selected = DateParam::parseOrToday($date); @@ -110,7 +110,7 @@ final class BookingController extends AbstractController * Moves one occurrence, which is what an "override" of a series is: the other occurrences * are untouched and this one is flagged as differing from its series. */ - #[Route('/buchungen/{id}/bearbeiten', name: 'app_booking_edit', methods: ['GET', 'POST'])] + #[Route('/bookings/{id}/edit', name: 'app_booking_edit', methods: ['GET', 'POST'])] public function edit(Request $request, Booking $booking): Response { $this->denyUnlessOwn($booking); @@ -145,7 +145,7 @@ final class BookingController extends AbstractController ]); } - #[Route('/buchungen/{id}/freigeben', name: 'app_booking_release', methods: ['POST'])] + #[Route('/bookings/{id}/release', name: 'app_booking_release', methods: ['POST'])] public function release(Request $request, Booking $booking): Response { $this->denyUnlessOwn($booking); @@ -169,11 +169,11 @@ final class BookingController extends AbstractController /** * Ends a series from today onwards. Past occurrences are history and stay put. */ - #[Route('/serien/{id}/beenden', name: 'app_series_end', methods: ['POST'])] + #[Route('/series/{id}/end', name: 'app_series_end', methods: ['POST'])] public function endSeries(Request $request, BookingSeries $series): Response { if ($series->getUser()->getId() !== $this->currentUser()->getId()) { - throw $this->createAccessDeniedException('Fremde Serien können nicht beendet werden.'); + throw $this->createAccessDeniedException('A series may only be ended by the person who created it.'); } $this->denyUnlessCsrfValid($request, 'end-series-'.$series->getId()); @@ -252,20 +252,20 @@ final class BookingController extends AbstractController */ private function timeParameters(TimeRange $time): array { - return $time->isAllDay() ? [] : ['von' => $time->start(), 'bis' => $time->end()]; + return $time->isAllDay() ? [] : ['from' => $time->start(), 'to' => $time->end()]; } private function denyUnlessOwn(Booking $booking): void { if (!$booking->belongsTo($this->currentUser())) { - throw $this->createAccessDeniedException('Fremde Buchungen können nicht geändert werden.'); + throw $this->createAccessDeniedException('A booking may only be changed by the person holding it.'); } } private function denyUnlessCsrfValid(Request $request, string $tokenId): void { if (!$this->isCsrfTokenValid($tokenId, $request->request->getString('_token'))) { - throw $this->createAccessDeniedException('Ungültiges CSRF-Token.'); + throw $this->createAccessDeniedException('Invalid CSRF token.'); } } } diff --git a/src/Entity/ResourceType.php b/src/Entity/ResourceType.php index b95747e..3678434 100644 --- a/src/Entity/ResourceType.php +++ b/src/Entity/ResourceType.php @@ -8,13 +8,14 @@ namespace App\Entity; * The kinds of thing that can be booked. * * **This enum is the extension point.** The dashboard builds one tile per case, the board route - * accepts one slug per case, and the floor plan is looked up by convention from the case's - * value. Adding a third bookable kind is therefore: a case here, a + * accepts one backing value per case, and the floor plan is looked up by convention from that + * same value. Adding a third bookable kind is therefore: a case here, a * `templates/board/plan/_.html.twig` with its geometry, and rows in `app:seed`. Nothing * else needs editing — in particular no template lists the kinds by hand. * - * The backing values are persisted in `resource.type`, which is why they stay English while the - * URLs do not: `slug()` is what appears in a path, so the routes read German like the rest. + * The backing value is both what is persisted in `resource.type` and what appears in a URL, so + * there is no slug mapping to keep in sync. The label, plural and description are the only + * things here anyone sees, which is why they are the only German ones. */ enum ResourceType: string { @@ -52,24 +53,4 @@ enum ResourceType: string self::PARKING => 'lucide:car', }; } - - /** What appears in a URL. */ - public function slug(): string - { - return match ($this) { - self::DESK => 'schreibtische', - self::PARKING => 'parkplaetze', - }; - } - - public static function fromSlug(string $slug): self - { - foreach (self::cases() as $case) { - if ($case->slug() === $slug) { - return $case; - } - } - - throw new \ValueError(\sprintf('"%s" ist kein bekannter Buchungsbereich.', $slug)); - } } diff --git a/src/Security/OAuth2/EpClient.php b/src/Security/OAuth2/EpClient.php index b55ab8d..4c1bd2a 100644 --- a/src/Security/OAuth2/EpClient.php +++ b/src/Security/OAuth2/EpClient.php @@ -74,11 +74,11 @@ class EpClient $code = $request->query->get('code'); if (null === $code || '' === $code) { - throw new AuthorizationRequestException('Die Antwort von myep enthielt keinen Code.'); + throw new AuthorizationRequestException('The callback from myep carried no authorization code.'); } if (null === $expectedState || $request->query->get('state') !== $expectedState) { - throw new AuthorizationRequestException('Der state-Parameter passt nicht zur Sitzung.'); + throw new AuthorizationRequestException('The state parameter does not match the one this session started with.'); } return $this->getProvider()->getAccessToken('authorization_code', ['code' => $code]); @@ -107,13 +107,13 @@ class EpClient ['headers' => ['Accept' => 'application/json']], )); } catch (ClientExceptionInterface $e) { - throw new UserinfoUnavailableException('userinfo konnte nicht abgerufen werden.', previous: $e); + throw new UserinfoUnavailableException('The userinfo endpoint could not be reached.', previous: $e); } $userinfo = json_decode((string) $response->getBody(), true); if (!\is_array($userinfo)) { - throw new UserinfoUnavailableException('userinfo lieferte keine lesbare Antwort.'); + throw new UserinfoUnavailableException('The userinfo endpoint returned an unreadable payload.'); } return $userinfo; diff --git a/templates/board/_calendar.html.twig b/templates/board/_calendar.html.twig index ae5776b..d4fa6d0 100644 --- a/templates/board/_calendar.html.twig +++ b/templates/board/_calendar.html.twig @@ -2,14 +2,14 @@ requests whose response carries nothing but out-of-band blocks, hence hx-swap="none". #} {% set weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %} {# Every link keeps the board's span, so changing the day never silently widens it back to all-day. #} -{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %} +{% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %}
@@ -19,7 +19,7 @@ @@ -49,7 +49,7 @@ diff --git a/templates/board/plan/_desk.html.twig b/templates/board/plan/_desk.html.twig index 72e4cf0..5c7bf93 100644 --- a/templates/board/plan/_desk.html.twig +++ b/templates/board/plan/_desk.html.twig @@ -13,7 +13,7 @@ 'desk-07': { x: 40, y: 260 }, 'desk-08': { x: 200, y: 260 }, 'desk-09': { x: 360, y: 260 }, 'desk-10': { x: 40, y: 360 }, 'desk-11': { x: 200, y: 360 }, 'desk-12': { x: 360, y: 360 } } %} -{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %} +{% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %} diff --git a/templates/board/plan/_parking.html.twig b/templates/board/plan/_parking.html.twig index 3f89eb5..68e20a8 100644 --- a/templates/board/plan/_parking.html.twig +++ b/templates/board/plan/_parking.html.twig @@ -8,7 +8,7 @@ 'parking-01': { x: 40, y: 60 }, 'parking-02': { x: 40, y: 130 }, 'parking-03': { x: 40, y: 200 }, 'parking-04': { x: 280, y: 60 }, 'parking-05': { x: 280, y: 130 }, 'parking-06': { x: 280, y: 200 } } %} -{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %} +{% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %} diff --git a/templates/board/plan/_spot.html.twig b/templates/board/plan/_spot.html.twig index 1a7abcb..617dfc0 100644 --- a/templates/board/plan/_spot.html.twig +++ b/templates/board/plan/_spot.html.twig @@ -1,6 +1,6 @@ {# The shared shape a plan is drawn from. Imported by each board/plan/_.html.twig, so the - per-type partials carry geometry only and free/belegt/deine looks identical everywhere. + per-type partials carry geometry only and free/taken/yours looks identical everywhere. Clicking always opens a modal: the create modal when free, the edit modal when it is yours (which is where changing the times and releasing live). Nothing else is clickable, so the SVG diff --git a/templates/booking/index.html.twig b/templates/booking/index.html.twig index 2b142f5..640dfe9 100644 --- a/templates/booking/index.html.twig +++ b/templates/booking/index.html.twig @@ -57,7 +57,7 @@
Im Plan + href="{{ path('app_board', { type: booking.resource.type.value, date: booking.date|date('Y-m-d') }) }}">Im Plan {# Ordinary form post: this page has no board fragments to swap, so it reloads afterwards rather than pretending to be htmx. #}
diff --git a/templates/dashboard/index.html.twig b/templates/dashboard/index.html.twig index ad2b995..f62d7ba 100644 --- a/templates/dashboard/index.html.twig +++ b/templates/dashboard/index.html.twig @@ -12,7 +12,7 @@ #} + href="{{ path('app_board', { type: booking.resource.type.value, date: booking.date|date('Y-m-d') }) }}"> Im Plan diff --git a/tests/Controller/BookingControllerTest.php b/tests/Controller/BookingControllerTest.php index 69ae732..da4b081 100644 --- a/tests/Controller/BookingControllerTest.php +++ b/tests/Controller/BookingControllerTest.php @@ -44,11 +44,11 @@ class BookingControllerTest extends WebTestCase { $this->client->loginUser($this->user('anna@ep-reisen.de')); - $this->client->request('GET', '/buchen/schreibtische/tag/2026-10-01', server: ['HTTP_HX-Request' => 'true']); + $this->client->request('GET', '/book/desk/day/2026-10-01', server: ['HTTP_HX-Request' => 'true']); $response = $this->client->getResponse(); self::assertResponseIsSuccessful(); - self::assertSame('/buchen/schreibtische?tag=2026-10-01', $response->headers->get('HX-Push-Url')); + self::assertSame('/book/desk?date=2026-10-01', $response->headers->get('HX-Push-Url')); self::assertSame(2, substr_count((string) $response->getContent(), 'hx-swap-oob="true"')); self::assertStringContainsString('id="board-calendar"', (string) $response->getContent()); self::assertStringContainsString('id="board-floorplan"', (string) $response->getContent()); @@ -58,9 +58,9 @@ class BookingControllerTest extends WebTestCase { $this->client->loginUser($this->user('anna@ep-reisen.de')); - $this->client->request('GET', '/buchen/schreibtische/tag/2026-10-01'); + $this->client->request('GET', '/book/desk/day/2026-10-01'); - self::assertResponseRedirects('/buchen/schreibtische?tag=2026-10-01'); + self::assertResponseRedirects('/book/desk?date=2026-10-01'); } /** @@ -146,7 +146,7 @@ class BookingControllerTest extends WebTestCase $this->client->loginUser($this->user('anna@ep-reisen.de')); $desk = $this->resource('D1'); - $this->client->request('POST', \sprintf('/buchungen/neu/%d/2026-10-01', $desk->getId()), [ + $this->client->request('POST', \sprintf('/bookings/new/%d/2026-10-01', $desk->getId()), [ 'booking' => ['date' => '2026-10-01', '_token' => 'nope'], ], server: ['HTTP_HX-Request' => 'true']); @@ -167,7 +167,7 @@ class BookingControllerTest extends WebTestCase $this->em->flush(); $this->client->loginUser($this->user('anna@ep-reisen.de')); - $this->client->request('POST', \sprintf('/buchungen/%d/freigeben', $booking->getId()), server: ['HTTP_HX-Request' => 'true']); + $this->client->request('POST', \sprintf('/bookings/%d/release', $booking->getId()), server: ['HTTP_HX-Request' => 'true']); $response = $this->client->getResponse(); self::assertSame('body', $response->headers->get('HX-Retarget')); @@ -186,11 +186,11 @@ class BookingControllerTest extends WebTestCase // Releasing lives in the edit modal now, not on the floor plan -- clicking your own desk // opens that modal, and the token is rendered there. - $crawler = $this->client->request('GET', \sprintf('/buchungen/%d/bearbeiten', $id), server: ['HTTP_HX-Request' => 'true']); - $vals = $crawler->filter(\sprintf('[hx-post="/buchungen/%d/freigeben"]', $id))->attr('hx-vals'); + $crawler = $this->client->request('GET', \sprintf('/bookings/%d/edit', $id), server: ['HTTP_HX-Request' => 'true']); + $vals = $crawler->filter(\sprintf('[hx-post="/bookings/%d/release"]', $id))->attr('hx-vals'); $token = json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token']; - $this->client->request('POST', \sprintf('/buchungen/%d/freigeben', $id), ['_token' => $token], server: ['HTTP_HX-Request' => 'true']); + $this->client->request('POST', \sprintf('/bookings/%d/release', $id), ['_token' => $token], server: ['HTTP_HX-Request' => 'true']); self::assertResponseIsSuccessful(); self::assertSame(3, substr_count((string) $this->client->getResponse()->getContent(), 'hx-swap-oob="true"')); @@ -205,9 +205,9 @@ class BookingControllerTest extends WebTestCase $this->em->flush(); $this->client->loginUser($anna); - $crawler = $this->client->request('GET', '/buchen/schreibtische?tag=2026-10-01'); + $crawler = $this->client->request('GET', '/book/desk?date=2026-10-01'); - self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/buchungen/%d/bearbeiten"]', $booking->getId()))); + self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/edit"]', $booking->getId()))); } public function testBookingPartOfADay(): void @@ -338,7 +338,7 @@ class BookingControllerTest extends WebTestCase $crawler = $this->client->request( 'GET', - \sprintf('/buchungen/neu/%d/2026-10-01?von=09:00&bis=13:00', $desk->getId()), + \sprintf('/bookings/new/%d/2026-10-01?from=09:00&to=13:00', $desk->getId()), server: ['HTTP_HX-Request' => 'true'], ); @@ -351,10 +351,10 @@ class BookingControllerTest extends WebTestCase { $this->client->loginUser($this->user('anna@ep-reisen.de')); - $this->client->request('GET', '/buchen/schreibtische/tag/2026-10-01?von=09:00&bis=13:00', server: ['HTTP_HX-Request' => 'true']); + $this->client->request('GET', '/book/desk/day/2026-10-01?from=09:00&to=13:00', server: ['HTTP_HX-Request' => 'true']); self::assertResponseIsSuccessful(); - self::assertSame('/buchen/schreibtische?tag=2026-10-01&von=09:00&bis=13:00', $this->client->getResponse()->headers->get('HX-Push-Url')); + self::assertSame('/book/desk?date=2026-10-01&from=09:00&to=13:00', $this->client->getResponse()->headers->get('HX-Push-Url')); } public function testOverridingOneOccurrenceLeavesTheSeriesAlone(): void @@ -375,7 +375,7 @@ class BookingControllerTest extends WebTestCase self::assertCount(4, $occurrences); $first = $occurrences[0]; - $crawler = $this->client->request('GET', \sprintf('/buchungen/%d/bearbeiten', $first->getId()), server: ['HTTP_HX-Request' => 'true']); + $crawler = $this->client->request('GET', \sprintf('/bookings/%d/edit', $first->getId()), server: ['HTTP_HX-Request' => 'true']); // Only the span is editable here: the day and the recurrence belong to the series. self::assertCount(0, $crawler->filter('#booking_date')); self::assertCount(0, $crawler->filter('#booking_repeat')); @@ -410,7 +410,7 @@ class BookingControllerTest extends WebTestCase */ private function submitBookingModal(Resource $resource, string $routeDate, array $fields = ['allDay' => '1']): Response { - $url = \sprintf('/buchungen/neu/%d/%s', $resource->getId(), $routeDate); + $url = \sprintf('/bookings/new/%d/%s', $resource->getId(), $routeDate); $crawler = $this->client->request('GET', $url, server: ['HTTP_HX-Request' => 'true']); self::assertResponseIsSuccessful(); diff --git a/tests/Controller/DashboardControllerTest.php b/tests/Controller/DashboardControllerTest.php index 4443814..d5a67e5 100644 --- a/tests/Controller/DashboardControllerTest.php +++ b/tests/Controller/DashboardControllerTest.php @@ -46,8 +46,8 @@ class DashboardControllerTest extends WebTestCase foreach (ResourceType::cases() as $type) { self::assertCount( 1, - $crawler->filter(\sprintf('a[href="/buchen/%s"]', $type->slug())), - \sprintf('no dashboard tile links to the %s board', $type->slug()), + $crawler->filter(\sprintf('a[href="/book/%s"]', $type->value)), + \sprintf('no dashboard tile links to the %s board', $type->value), ); self::assertStringContainsString($type->plural(), $crawler->html()); } @@ -61,7 +61,7 @@ class DashboardControllerTest extends WebTestCase $this->client->loginUser($this->user()); $crawler = $this->client->request('GET', '/'); - self::assertCount(1, $crawler->filter('a[href="/buchungen"]')); + self::assertCount(1, $crawler->filter('a[href="/bookings"]')); } public function testTheNavigationIsGone(): void @@ -86,7 +86,7 @@ class DashboardControllerTest extends WebTestCase $this->client->loginUser($user); $crawler = $this->client->request('GET', '/'); - $tile = $crawler->filter('a[href="/buchen/schreibtische"]')->text(); + $tile = $crawler->filter('a[href="/book/desk"]')->text(); self::assertStringContainsString('von 2 heute frei', $tile); self::assertStringContainsString('1', $tile); } @@ -104,7 +104,7 @@ class DashboardControllerTest extends WebTestCase $this->client->loginUser($this->user()); $crawler = $this->client->request('GET', '/'); - self::assertStringContainsString('heute alles belegt', $crawler->filter('a[href="/buchen/schreibtische"]')->text()); + self::assertStringContainsString('heute alles belegt', $crawler->filter('a[href="/book/desk"]')->text()); } public function testYourOwnBookingIsCalledOutOnTheTile(): void @@ -116,7 +116,7 @@ class DashboardControllerTest extends WebTestCase $this->client->loginUser($user); $crawler = $this->client->request('GET', '/'); - self::assertStringContainsString('D1', $crawler->filter('a[href="/buchen/schreibtische"]')->text()); + self::assertStringContainsString('D1', $crawler->filter('a[href="/book/desk"]')->text()); } public function testABoardOnlyShowsItsOwnKindOfResource(): void @@ -126,11 +126,11 @@ class DashboardControllerTest extends WebTestCase $this->client->loginUser($this->user()); - $desks = $this->client->request('GET', '/buchen/schreibtische')->html(); + $desks = $this->client->request('GET', '/book/desk')->html(); self::assertStringContainsString('D1', $desks); self::assertStringNotContainsString('P1', $desks); - $parking = $this->client->request('GET', '/buchen/parkplaetze')->html(); + $parking = $this->client->request('GET', '/book/parking')->html(); self::assertStringContainsString('P1', $parking); self::assertStringNotContainsString('D1', $parking); } @@ -138,7 +138,7 @@ class DashboardControllerTest extends WebTestCase public function testAnUnknownBookingAreaIsNotFound(): void { $this->client->loginUser($this->user()); - $this->client->request('GET', '/buchen/fahrraeder'); + $this->client->request('GET', '/book/bikes'); self::assertResponseStatusCodeSame(404); } @@ -149,7 +149,7 @@ class DashboardControllerTest extends WebTestCase self::assertStringContainsString( 'noch nichts eingerichtet', - $this->client->request('GET', '/buchen/parkplaetze')->html(), + $this->client->request('GET', '/book/parking')->html(), ); } diff --git a/tests/Entity/ResourceTypeTest.php b/tests/Entity/ResourceTypeTest.php index 53cd521..0a8b5ea 100644 --- a/tests/Entity/ResourceTypeTest.php +++ b/tests/Entity/ResourceTypeTest.php @@ -9,35 +9,27 @@ use PHPUnit\Framework\TestCase; class ResourceTypeTest extends TestCase { - public function testEverySlugResolvesBackToItsCase(): void + /** + * The backing value is what appears in a URL, so it has to survive the round trip -- there + * is deliberately no separate slug mapping to keep in sync. + */ + public function testEveryValueResolvesBackToItsCase(): void { foreach (ResourceType::cases() as $type) { - self::assertSame($type, ResourceType::fromSlug($type->slug())); + self::assertSame($type, ResourceType::tryFrom($type->value)); } } - public function testSlugsAreUnique(): void + public function testAnUnknownValueResolvesToNothing(): void { - $slugs = array_map(static fn (ResourceType $type): string => $type->slug(), ResourceType::cases()); - - self::assertSame($slugs, array_unique($slugs)); + self::assertNull(ResourceType::tryFrom('bikes')); } - public function testAnUnknownSlugIsRejected(): void + public function testValuesAreUrlSafe(): void { - $this->expectException(\ValueError::class); - - ResourceType::fromSlug('fahrraeder'); - } - - /** - * The URL slugs are German while the backing values stay English, because the values are - * persisted in resource.type and renaming them would need a migration. - */ - public function testTheSlugIsNotTheStoredValue(): void - { - self::assertSame('desk', ResourceType::DESK->value); - self::assertSame('schreibtische', ResourceType::DESK->slug()); + foreach (ResourceType::cases() as $type) { + self::assertMatchesRegularExpression('/^[a-z]+$/', $type->value); + } } /** diff --git a/tests/EventListener/HtmxExceptionListenerFunctionalTest.php b/tests/EventListener/HtmxExceptionListenerFunctionalTest.php index c31c43d..40ade03 100644 --- a/tests/EventListener/HtmxExceptionListenerFunctionalTest.php +++ b/tests/EventListener/HtmxExceptionListenerFunctionalTest.php @@ -20,7 +20,7 @@ class HtmxExceptionListenerFunctionalTest extends WebTestCase { $client = self::createClient(); $client->catchExceptions(true); - $client->request('GET', '/gibt-es-nicht', server: ['HTTP_HX-Request' => 'true']); + $client->request('GET', '/does-not-exist', server: ['HTTP_HX-Request' => 'true']); $response = $client->getResponse(); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); @@ -33,7 +33,7 @@ class HtmxExceptionListenerFunctionalTest extends WebTestCase { $client = self::createClient(); $client->catchExceptions(true); - $client->request('GET', '/gibt-es-nicht'); + $client->request('GET', '/does-not-exist'); // Asserting on headers rather than body: in debug mode Symfony's exception page renders // the source of every stack frame, this file included, so any marker string searched for diff --git a/tests/EventListener/HtmxExceptionListenerTest.php b/tests/EventListener/HtmxExceptionListenerTest.php index e715e62..6e135cd 100644 --- a/tests/EventListener/HtmxExceptionListenerTest.php +++ b/tests/EventListener/HtmxExceptionListenerTest.php @@ -81,7 +81,7 @@ class HtmxExceptionListenerTest extends KernelTestCase { return new ExceptionEvent( self::bootKernel(), - Request::create('/buchungen', server: $server), + Request::create('/bookings', server: $server), HttpKernelInterface::MAIN_REQUEST, new \RuntimeException('boom'), ); diff --git a/tests/Htmx/HxTraitTest.php b/tests/Htmx/HxTraitTest.php index 1ad152a..859c159 100644 --- a/tests/Htmx/HxTraitTest.php +++ b/tests/Htmx/HxTraitTest.php @@ -16,20 +16,20 @@ class HxTraitTest extends TestCase { public function testHtmxRequestsGetAnHxRedirectInsteadOfA302(): void { - $response = $this->controller()->redirectTo($this->request(['HTTP_HX-Request' => 'true']), '/buchungen'); + $response = $this->controller()->redirectTo($this->request(['HTTP_HX-Request' => 'true']), '/bookings'); self::assertInstanceOf(HxRedirectResponse::class, $response); - self::assertSame('/buchungen', $response->headers->get('HX-Redirect')); + self::assertSame('/bookings', $response->headers->get('HX-Redirect')); // A 3xx would be followed by the browser and swapped as a whole page into the fragment. self::assertSame(Response::HTTP_OK, $response->getStatusCode()); } public function testOrdinaryRequestsGetANormalRedirect(): void { - $response = $this->controller()->redirectTo($this->request(), '/buchungen'); + $response = $this->controller()->redirectTo($this->request(), '/bookings'); self::assertInstanceOf(RedirectResponse::class, $response); - self::assertSame('/buchungen', $response->getTargetUrl()); + self::assertSame('/bookings', $response->getTargetUrl()); } public function testOobResponseConcatenatesEveryRequestedBlock(): void @@ -69,8 +69,8 @@ class HxTraitTest extends TestCase self::assertFalse($controller->oob('t.html.twig', ['b'])->headers->has('HX-Push-Url')); self::assertSame( - '/buchungen?datum=2026-09-13', - $controller->oob('t.html.twig', ['b'], [], '/buchungen?datum=2026-09-13')->headers->get('HX-Push-Url'), + '/bookings?date=2026-09-13', + $controller->oob('t.html.twig', ['b'], [], '/bookings?date=2026-09-13')->headers->get('HX-Push-Url'), ); } @@ -79,7 +79,7 @@ class HxTraitTest extends TestCase */ private function request(array $server = []): Request { - return Request::create('/buchungen', server: $server); + return Request::create('/bookings', server: $server); } private function controller(): object diff --git a/tests/Security/EpClientTest.php b/tests/Security/EpClientTest.php index 318b2be..2d7885c 100644 --- a/tests/Security/EpClientTest.php +++ b/tests/Security/EpClientTest.php @@ -56,7 +56,7 @@ class EpClientTest extends KernelTestCase $request->getSession()->set('oauth2state', 'abc'); $this->expectException(AuthorizationRequestException::class); - $this->expectExceptionMessage('keinen Code'); + $this->expectExceptionMessage('no authorization code'); $this->client()->fetchAccessToken($request); } @@ -71,7 +71,7 @@ class EpClientTest extends KernelTestCase $request->getSession()->set('oauth2state', 'expected'); $this->expectException(AuthorizationRequestException::class); - $this->expectExceptionMessage('state-Parameter'); + $this->expectExceptionMessage('state parameter does not match'); $this->client()->fetchAccessToken($request); }