refactor: use english for routes, parameters and internal messages

This commit is contained in:
fromme committed 2026-09-13 18:58:24 +02:00
1 parent bce8f58be3
commit a88ff7ff24
22 files changed
+106 -135

No files matched your search

+3 -3
View File
@@ -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;
+2 -2
View File
@@ -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.');
}
}
+1 -1
View File
@@ -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;
}
+9 -11
View File
@@ -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));
}
}
+10 -10
View File
@@ -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.');
}
}
}
+5 -24
View File
@@ -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/_<value>.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));
}
}
+4 -4
View File
@@ -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;
+4 -4
View File
@@ -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 } %}
<div class="rounded-md border border-gray-200 p-4">
<div class="flex items-center justify-between pb-4">
<button type="button"
class="p-1 rounded-md hover:bg-gray-100"
aria-label="Voriger Monat"
hx-get="{{ path('app_board_day', { type: type.slug, date: month_start|date_modify('-1 month')|date('Y-m-d') }|merge(time_params)) }}"
hx-get="{{ path('app_board_day', { type: type.value, date: month_start|date_modify('-1 month')|date('Y-m-d') }|merge(time_params)) }}"
hx-swap="none">
{{ ux_icon('lucide:chevron-left', { class: 'w-5 h-5' }) }}
</button>
@@ -19,7 +19,7 @@
<button type="button"
class="p-1 rounded-md hover:bg-gray-100"
aria-label="Nächster Monat"
hx-get="{{ path('app_board_day', { type: type.slug, date: month_start|date_modify('+1 month')|date('Y-m-d') }|merge(time_params)) }}"
hx-get="{{ path('app_board_day', { type: type.value, date: month_start|date_modify('+1 month')|date('Y-m-d') }|merge(time_params)) }}"
hx-swap="none">
{{ ux_icon('lucide:chevron-right', { class: 'w-5 h-5' }) }}
</button>
@@ -49,7 +49,7 @@
<button type="button"
class="{{ classes }}"
aria-pressed="{{ day.is_selected ? 'true' : 'false' }}"
hx-get="{{ path('app_board_day', { type: type.slug, date: day.date|date('Y-m-d') }|merge(time_params)) }}"
hx-get="{{ path('app_board_day', { type: type.value, date: day.date|date('Y-m-d') }|merge(time_params)) }}"
hx-swap="none">
<span>{{ day.date|date('j') }}</span>
{# Team view: the occupancy count is the whole point of showing the month. #}
+2 -2
View File
@@ -4,8 +4,8 @@
partial there and nothing else.
Colouring needs no JavaScript at all: the plan is re-rendered server side for the selected day
and span, so free/belegt/deine is decided in Twig. Note "free" is always relative to the span
in the header -- a desk booked 09:00-13:00 is free for an afternoon board and belegt for an
and span, so free/taken/yours is decided in Twig. Note "free" is always relative to the span
in the header -- a desk booked 09:00-13:00 is free for an afternoon board and taken for an
all-day one.
#}
<div class="rounded-md border border-gray-200 p-4">
+10 -10
View File
@@ -1,5 +1,5 @@
{#
The span the whole board is relative to. Free/belegt has no answer without it once bookings
The span the whole board is relative to. Free/taken has no answer without it once bookings
can cover part of a day, so this drives the plan colours AND the calendar occupancy counts.
Both fields hit the same fragment endpoint as the calendar does, so narrowing the span is the
@@ -12,27 +12,27 @@
<div class="flex flex-wrap items-end gap-3 pb-4" id="board-timerange">
<div>
<label class="block text-xs text-gray-500 pb-1" for="board-von">Von</label>
<input type="time" id="board-von" name="von" value="{{ selected_time.allDay ? '' : selected_time.start }}" step="900"
<label class="block text-xs text-gray-500 pb-1" for="board-from">Von</label>
<input type="time" id="board-from" name="from" value="{{ selected_time.allDay ? '' : selected_time.start }}" step="900"
class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary"
hx-get="{{ path('app_board_day', { type: type.slug, date: day }) }}"
hx-include="#board-bis"
hx-get="{{ path('app_board_day', { type: type.value, date: day }) }}"
hx-include="#board-to"
hx-trigger="change"
hx-swap="none">
</div>
<div>
<label class="block text-xs text-gray-500 pb-1" for="board-bis">Bis</label>
<input type="time" id="board-bis" name="bis" value="{{ selected_time.allDay ? '' : selected_time.end }}" step="900"
<label class="block text-xs text-gray-500 pb-1" for="board-to">Bis</label>
<input type="time" id="board-to" name="to" value="{{ selected_time.allDay ? '' : selected_time.end }}" step="900"
class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary"
hx-get="{{ path('app_board_day', { type: type.slug, date: day }) }}"
hx-include="#board-von"
hx-get="{{ path('app_board_day', { type: type.value, date: day }) }}"
hx-include="#board-from"
hx-trigger="change"
hx-swap="none">
</div>
{% if not selected_time.allDay %}
<button type="button" class="button button--small button--neutral"
hx-get="{{ path('app_board_day', { type: type.slug, date: day }) }}"
hx-get="{{ path('app_board_day', { type: type.value, date: day }) }}"
hx-swap="none">
Ganzer Tag
</button>
+1 -1
View File
@@ -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 } %}
<svg viewBox="0 0 520 460" class="w-full h-auto" role="img" aria-label="Raumplan Büro">
<rect x="10" y="20" width="500" height="420" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
+1 -1
View File
@@ -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 } %}
<svg viewBox="0 0 520 300" class="w-full h-auto" role="img" aria-label="Parkplatz">
<rect x="10" y="20" width="500" height="260" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
+1 -1
View File
@@ -1,6 +1,6 @@
{#
The shared shape a plan is drawn from. Imported by each board/plan/_<type>.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
+1 -1
View File
@@ -57,7 +57,7 @@
</div>
<div class="flex items-center gap-3">
<a class="text-sm underline"
href="{{ path('app_board', { type: booking.resource.type.slug, tag: booking.date|date('Y-m-d') }) }}">Im Plan</a>
href="{{ path('app_board', { type: booking.resource.type.value, date: booking.date|date('Y-m-d') }) }}">Im Plan</a>
{# Ordinary form post: this page has no board fragments to swap, so it
reloads afterwards rather than pretending to be htmx. #}
<form method="post" action="{{ path('app_booking_release', { id: booking.id }) }}">
+2 -2
View File
@@ -12,7 +12,7 @@
#}
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{% for tile in tiles %}
<a href="{{ path('app_board', { type: tile.type.slug }) }}"
<a href="{{ path('app_board', { type: tile.type.value }) }}"
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
<div class="flex items-center gap-3 pb-2">
{{ ux_icon(tile.type.icon, { class: 'h-6 w-6 shrink-0 text-primary' }) }}
@@ -70,7 +70,7 @@
</div>
</div>
<a class="text-sm underline shrink-0"
href="{{ path('app_board', { type: booking.resource.type.slug, tag: booking.date|date('Y-m-d') }) }}">
href="{{ path('app_board', { type: booking.resource.type.value, date: booking.date|date('Y-m-d') }) }}">
Im Plan
</a>
</li>
+16 -16
View File
@@ -44,11 +44,11 @@ class BookingControllerTest extends WebTestCase
{
$this->client->loginUser($this->user('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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();
+10 -10
View File
@@ -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(),
);
}
+12 -20
View File
@@ -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);
}
}
/**
@@ -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
@@ -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'),
);
+7 -7
View File
@@ -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
+2 -2
View File
@@ -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);
}