diff --git a/src/Controller/Admin/AccommodationBooking/DeleteController.php b/src/Controller/Admin/AccommodationBooking/DeleteController.php new file mode 100644 index 0000000..0450aa8 --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/DeleteController.php @@ -0,0 +1,68 @@ +isDraft()) { + $this->addFlash('error', 'Nur ein Entwurf lässt sich löschen. Eine Buchung, die bereits beim Kunden war, wird abgesagt.'); + + return $this->htmxRedirect($request, $this->getReturnUrl($request, 'app_admin_accommodationbooking')); + } + + if ($request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('delete_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $this->logger->info('Deleted accommodation booking draft', [ + 'id' => $booking->getId(), + 'groupName' => $booking->getGroupName(), + 'accommodation' => $booking->getAccommodation()?->getName(), + ]); + + $this->entityManager->remove($booking); + $this->entityManager->flush(); + + $this->addFlash('success', 'Der Entwurf wurde gelöscht'); + + // Back to the list the delete was started from, so the filter and the page the + // manager was on survive the round trip. + return $this->htmxRedirect($request, $this->getReturnUrl($request, 'app_admin_accommodationbooking')); + } + + return $this->render('admin/accommodation_booking/modal_delete.html.twig', [ + 'booking' => $booking, + 'csrf_token_id' => 'delete_accommodation_booking_'.$booking->getId(), + ]); + } +} diff --git a/templates/admin/accommodation_booking/index.html.twig b/templates/admin/accommodation_booking/index.html.twig index d56791a..9ffbb72 100644 --- a/templates/admin/accommodation_booking/index.html.twig +++ b/templates/admin/accommodation_booking/index.html.twig @@ -99,6 +99,13 @@ PDF herunterladen {% endif %} + {# A record that has been in front of the customer gets discarded, + not removed — see DeleteController. #} + {% if booking.draft %} + + löschen + + {% endif %} diff --git a/templates/admin/accommodation_booking/modal_delete.html.twig b/templates/admin/accommodation_booking/modal_delete.html.twig new file mode 100644 index 0000000..4031a61 --- /dev/null +++ b/templates/admin/accommodation_booking/modal_delete.html.twig @@ -0,0 +1,12 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du den Entwurf für {{ booking.accommodation.name }} + {% if booking.groupName %}({{ booking.groupName }}){% endif %} + vom {{ booking.dateFrom | date('d.m.Y') }} – {{ booking.dateTo | date('d.m.Y') }} + wirklich dauerhaft löschen? +
+{% endblock %} + +{% block button_confirm %}löschen{% endblock %} diff --git a/tests/Controller/Admin/AccommodationBooking/DeleteControllerTest.php b/tests/Controller/Admin/AccommodationBooking/DeleteControllerTest.php new file mode 100644 index 0000000..7fd70e6 --- /dev/null +++ b/tests/Controller/Admin/AccommodationBooking/DeleteControllerTest.php @@ -0,0 +1,177 @@ +createMock(EntityManagerInterface::class); + $entityManager->expects(self::never())->method('remove'); + + $controller = $this->buildController($entityManager); + + $response = $controller->index($this->draft(), $this->request()); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('admin/accommodation_booking/modal_delete.html.twig', $controller->renderedView); + } + + public function testPostRemovesTheDraftAndReturnsToTheList(): void + { + $booking = $this->draft(); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('remove')->with($booking); + $entityManager->expects(self::once())->method('flush'); + + $controller = $this->buildController($entityManager); + + $response = $controller->index($booking, $this->request('POST')); + + self::assertSame( + '/admin/accommodation-booking?page=2', + $response->headers->get('HX-Redirect'), + 'the filtered list the delete was started from has to survive the round trip', + ); + self::assertSame('success', $controller->flashes[0]['type']); + } + + /** + * @dataProvider undeletableStatuses + */ + public function testOnlyADraftCanBeDeleted(AccommodationBookingStatus $status): void + { + // Anything past Entwurf has been in contact with the customer and is closed by an + // Absage instead, which keeps the record readable. + $booking = $this->draft(); + $booking->setStatus($status); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::never())->method('remove'); + $entityManager->expects(self::never())->method('flush'); + + $controller = $this->buildController($entityManager); + + $response = $controller->index($booking, $this->request('POST')); + + self::assertTrue($response->headers->has('HX-Redirect')); + self::assertNull($controller->renderedView); + self::assertSame('error', $controller->flashes[0]['type']); + } + + /** + * @return iterable + */ + public static function undeletableStatuses(): iterable + { + yield 'requested' => [AccommodationBookingStatus::Requested]; + yield 'open' => [AccommodationBookingStatus::Open]; + yield 'received' => [AccommodationBookingStatus::Received]; + yield 'confirmed' => [AccommodationBookingStatus::Confirmed]; + yield 'discarded' => [AccommodationBookingStatus::Discarded]; + } + + public function testPostWithAnInvalidTokenIsDenied(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::never())->method('remove'); + + $controller = $this->buildController($entityManager, tokenValid: false); + + $this->expectException(AccessDeniedException::class); + + $controller->index($this->draft(), $this->request('POST')); + } + + private function draft(): AccommodationBooking + { + $booking = new AccommodationBooking(); + $booking->setStatus(AccommodationBookingStatus::Draft); + $booking->setGroupName('Schulklasse 7b'); + $booking->setAccommodation((new Accommodation())->setName('Seehaus')); + + return $booking; + } + + private function request(string $method = 'GET'): Request + { + // Every entry point is htmx — the dropdown opens the modal, the modal posts back. + return Request::create( + '/admin/accommodation-booking/1/delete?r=%2Fadmin%2Faccommodation-booking%3Fpage%3D2', + $method, + server: ['HTTP_HX-Request' => 'true'], + ); + } + + private function buildController( + EntityManagerInterface $entityManager, + bool $tokenValid = true, + ): TestableBookingDeleteController { + return new TestableBookingDeleteController( + $entityManager, + $this->createMock(LoggerInterface::class), + $tokenValid, + ); + } +} + +final class TestableBookingDeleteController extends DeleteController +{ + public ?string $renderedView = null; + + /** @var list */ + public array $flashes = []; + + public function __construct( + EntityManagerInterface $entityManager, + LoggerInterface $logger, + private readonly bool $tokenValid = true, + ) { + parent::__construct($entityManager, $logger); + } + + protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool + { + return $this->tokenValid; + } + + /** + * @param array $parameters + */ + protected function render(string $view, array $parameters = [], ?Response $response = null): Response + { + $this->renderedView = $view; + + return new Response(); + } + + protected function addFlash(string $type, mixed $message): void + { + $this->flashes[] = ['type' => $type, 'message' => $message]; + } + + /** + * @param array $parameters + */ + protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string + { + return '/'.$route.'?'.http_build_query($parameters); + } +}