refactor: use english for routes, parameters and internal messages
This commit is contained in:
1 parent
bce8f58be3
commit
a88ff7ff24
22 files changed
+106
-135
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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. #}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 }) }}">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user