createMock(AccommodationBookingService::class); $bookingService->expects(self::never())->method('duplicate'); $controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class)); $response = $controller->index($this->openBooking(), Request::create('/admin/accommodation-booking/1/duplicate')); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame('admin/accommodation_booking/modal_duplicate.html.twig', $controller->renderedView); } /** * Copying does not touch the original, so unlike absagen and löschen it is offered in * every status — "same group, next season" starts from a confirmed booking. */ #[DataProvider('everyStatus')] public function testABookingMayBeCopiedInAnyStatus(AccommodationBookingStatus $status): void { $booking = $this->openBooking(); $booking->setStatus($status); $bookingService = $this->createMock(AccommodationBookingService::class); $bookingService->expects(self::once()) ->method('duplicate') ->with($booking) ->willReturn(new AccommodationBooking()); $controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class)); $response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/duplicate', 'POST')); self::assertTrue($response->headers->has('HX-Redirect')); } /** * @return iterable */ public static function everyStatus(): iterable { foreach (AccommodationBookingStatus::cases() as $status) { yield $status->value => [$status]; } } public function testTheBrowserIsSentToTheCopyAndKeepsTheReturnUrl(): void { $bookingService = $this->createStub(AccommodationBookingService::class); $bookingService->method('duplicate')->willReturn(new AccommodationBooking()); $controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class)); $response = $controller->index( $this->openBooking(), Request::create('/admin/accommodation-booking/1/duplicate?r=%2Fadmin%2Faccommodation-booking%3Fpage%3D2', 'POST'), ); $location = (string) $response->headers->get('HX-Redirect'); self::assertStringContainsString('app_admin_accommodationbooking_edit', $location); self::assertStringContainsString('r=', $location); } public function testPostWithAnInvalidTokenIsDenied(): void { $bookingService = $this->createMock(AccommodationBookingService::class); $bookingService->expects(self::never())->method('duplicate'); $controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class), tokenValid: false); $this->expectException(AccessDeniedException::class); $controller->index($this->openBooking(), Request::create('/admin/accommodation-booking/1/duplicate', 'POST')); } private function openBooking(): AccommodationBooking { $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Open); $booking->setGroupName('Schulklasse 7b'); $booking->setEmail('customer@example.com'); return $booking; } } final class TestableDuplicateController extends DuplicateController { public ?string $renderedView = null; /** @var list */ public array $flashes = []; public function __construct( AccommodationBookingService $bookingService, LoggerInterface $logger, private readonly bool $tokenValid = true, ) { parent::__construct($bookingService, $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); } }