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); } }