createGenerator($this->breakdown())->render($this->booking()); self::assertStringStartsWith('%PDF-', $pdf); self::assertSame(1, $this->pageCount($pdf)); } public function testTheDownloadIsAnAttachmentNamedAfterTheGroup(): void { $response = $this->createGenerator($this->breakdown())->createDownloadResponse($this->booking()); self::assertSame('application/pdf', $response->headers->get('Content-Type')); self::assertStringContainsString('attachment;', (string) $response->headers->get('Content-Disposition')); self::assertStringContainsString('buchung-2026-07-06-schulklasse-7b.pdf', (string) $response->headers->get('Content-Disposition')); } public function testADocumentForAnUnacceptedOfferIsNamedAsOne(): void { // The document describes whatever the record is at the time it is produced, so it // must not call an offer nobody has accepted a booking. $booking = $this->booking(); $booking->setAcceptedAt(null); $booking->setConfirmedAt(null); $response = $this->createGenerator($this->breakdown())->createDownloadResponse($booking); self::assertStringContainsString('angebot-2026-07-06-schulklasse-7b.pdf', (string) $response->headers->get('Content-Disposition')); } public function testABookingWithoutPricesCannotBeRendered(): void { $this->expectException(\RuntimeException::class); $this->createGenerator(null)->render($this->booking()); } /** * @param array|null $breakdown */ private function createGenerator(?array $breakdown): AccommodationBookingPdfGenerator { $twig = new Environment(new FilesystemLoader(__DIR__.'/../../templates')); $twig->addExtension(new IntlExtension()); $calculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); $calculator->method('compute')->willReturn($breakdown); return new AccommodationBookingPdfGenerator($twig, $calculator, \dirname(__DIR__, 2)); } private function booking(): AccommodationBooking { $accommodation = new Accommodation(); $accommodation->setName('Haus Bergblick'); $accommodation->setMaxAdolescentAge(11); $accommodation->setCurrency('EUR'); $booking = new AccommodationBooking(); $booking->setAccommodation($accommodation); $booking->setGroupName('Schulklasse 7b'); $booking->setSalutation('Frau'); $booking->setFirstName('Änne'); $booking->setLastName('Müller'); $booking->setEmail('aenne@example.com'); $booking->setStreet('Hauptstraße 1'); $booking->setZip('50667'); $booking->setCity('Köln'); $booking->setDateFrom(new \DateTimeImmutable('2026-07-06')); $booking->setDateTo(new \DateTimeImmutable('2026-07-11')); $booking->setPaxCount(32); $booking->setChildrenCount(4); $booking->setBoardServiceLabel('Vollpension'); $booking->addAdditionalServiceSnapshot('Bettwäsche', 900, 'per_person', 5); $booking->setRemarks('Zwei Vegetarier.'); // A confirmation is only ever released for a booking the customer committed to, // so acceptedAt is always set by the time confirmedAt is. $booking->setAcceptedAt(new \DateTimeImmutable('2026-08-17')); $booking->setConfirmedAt(new \DateTimeImmutable('2026-08-18')); return $booking; } /** * @return array */ private function breakdown(): array { return [ 'effectivePax' => 32, 'totalPax' => 32, 'includedPax' => 25, 'nights' => 5, 'basePrice' => 250000, 'additionalPersonsPrice' => 70000, 'shortTermSurcharge' => 0, 'undersubscriptionSurcharge' => 0, 'undersubscriptionThreshold' => 40, 'boardPrice' => 480000, 'serviceDetails' => [['label' => 'Bettwäsche', 'price' => 28800]], 'servicesPrice' => 28800, 'runningCosts' => 16000, 'total' => 844800, 'currency' => 'EUR', 'discounts' => [['label' => 'Unterkunft', 'percent' => 5, 'amount' => 16000]], 'discountedTotal' => 828800, ]; } /** * dompdf's CPDF backend writes one /MediaBox for the page tree node and one per page, * so the page count is the number of occurrences minus one. Reading it back this way * avoids re-implementing the render pipeline just to reach the canvas. */ private function pageCount(string $pdf): int { return substr_count($pdf, '/MediaBox') - 1; } }