setName('Berghotel'); $accommodation->setCalendarCode('CAL123'); $accommodation->setCmsCode('CMS456'); $accommodation->setMaxAdolescentAge(15); $accommodation->setCurrency('EUR'); $booking = new AccommodationBooking(); $booking->setAccommodation($accommodation); $booking->setDateFrom(new \DateTimeImmutable('2026-07-20')); $booking->setDateTo(new \DateTimeImmutable('2026-07-25')); $booking->setPaxCount(40); $booking->setMinorsCount(2); $booking->setChildrenCount(1); $booking->setGroupName('Schulklasse 7b'); $booking->setSalutation('Frau'); $booking->setFirstName('Mia'); $booking->setLastName('Muster'); $booking->setEmail('mia@example.com'); $booking->setPhone('+49123456789'); $booking->setStreet('Musterstr. 1'); $booking->setZip('12345'); $booking->setCity('Musterstadt'); $booking->setRemarks('Bitte am Bahnhof abholen'); $booking->setBoardServiceLabel('Halbpension'); $booking->setBoardServicePrice(1500); $booking->addAdditionalServiceSnapshot('Bettwäsche', 500, 'flat', 3); $booking->setAccommodationDiscount(10); $booking->setBoardServiceDiscount(20); $booking->setAdditionalServicesDiscount(30); $booking->setTotalDiscount(5); $booking->setTotalDiscountLabel('Treuerabatt'); $booking->setStatus(AccommodationBookingStatus::Confirmed); $booking->setOrigin(AccommodationBookingOrigin::Direct); $booking->setAcceptedAt(new \DateTimeImmutable('2026-07-15T10:00:00+00:00')); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository ->expects(self::once()) ->method('findOneBy') ->with(['uuid' => $booking->getUuid()]) ->willReturn($booking); $breakdown = ['total' => 12345, 'currency' => 'EUR']; $booking->setPriceSnapshot($breakdown, 11111, 'EUR', 1); $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); $breakdownCalculator ->expects(self::once()) ->method('compute') ->with($booking) ->willReturn($breakdown); $bookingService = $this->createMock(AccommodationBookingService::class); $bookingService->expects(self::never())->method('acceptBooking'); $controller = new AccommodationBookingController($bookingRepository, $breakdownCalculator, $this->serializer(), $bookingService); $response = $controller->single($booking->getUuid()); $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame([ 'uuid' => $booking->getUuid(), 'status' => 'confirmed', 'type' => 'direct', 'dateFrom' => '2026-07-20', 'dateTo' => '2026-07-25', 'nights' => 5, 'paxCount' => 40, 'minorsCount' => 2, 'childrenCount' => 1, 'groupName' => 'Schulklasse 7b', 'acceptedAt' => '2026-07-15T10:00:00+00:00', 'confirmedAt' => null, 'personalData' => [ 'salutation' => 'Frau', 'firstName' => 'Mia', 'lastName' => 'Muster', 'email' => 'mia@example.com', 'phone' => '+49123456789', 'street' => 'Musterstr. 1', 'zip' => '12345', 'city' => 'Musterstadt', 'remarks' => 'Bitte am Bahnhof abholen', ], 'accommodation' => [ 'calendarCode' => 'CAL123', 'cmsCode' => 'CMS456', ], 'boardService' => [ 'label' => 'Halbpension', 'price' => 1500, ], 'additionalServices' => [ [ 'label' => 'Bettwäsche', 'price' => 500, 'type' => 'flat', ], ], 'accommodationDiscount' => 10, 'boardServiceDiscount' => 20, 'additionalServicesDiscount' => 30, 'totalDiscount' => 5, 'totalDiscountLabel' => 'Treuerabatt', 'totalPrice' => 11111, 'pricingCurrency' => 'EUR', 'pricingVersion' => 1, 'priceBreakdown' => $breakdown, ], $payload); // CMS-only accommodation fields must not leak into the response. self::assertArrayNotHasKey('name', $payload['accommodation']); self::assertArrayNotHasKey('maxAdolescentAge', $payload['accommodation']); self::assertArrayNotHasKey('currency', $payload['accommodation']); } public function testSingleReturnsOpenStatusAndOfferOriginForAnOffer(): void { $booking = new AccommodationBooking(); $booking->setDateFrom(new \DateTimeImmutable('2026-08-01')); $booking->setDateTo(new \DateTimeImmutable('2026-08-02')); $booking->setGroupName('Verein e.V.'); $booking->setFirstName('Tom'); $booking->setLastName('Beispiel'); $booking->setEmail('tom@example.com'); $booking->setStatus(AccommodationBookingStatus::Open); $booking->setOrigin(AccommodationBookingOrigin::Offer); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository ->method('findOneBy') ->with(['uuid' => $booking->getUuid()]) ->willReturn($booking); $breakdownCalculator = $this->createStub(AccommodationBookingBreakdownCalculator::class); $breakdownCalculator->method('compute')->willReturn(null); $controller = new AccommodationBookingController($bookingRepository, $breakdownCalculator, $this->serializer(), $this->createStub(AccommodationBookingService::class)); $response = $controller->single($booking->getUuid()); $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); self::assertSame('open', $payload['status']); self::assertSame('offer', $payload['type']); self::assertNull($payload['priceBreakdown']); self::assertNull($payload['acceptedAt']); } public function testSingleReturnsNotFoundForUnknownUuid(): void { $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository ->expects(self::once()) ->method('findOneBy') ->with(['uuid' => 'unknown-uuid']) ->willReturn(null); $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); $breakdownCalculator->expects(self::never())->method('compute'); $controller = new AccommodationBookingController($bookingRepository, $breakdownCalculator, $this->serializer(), $this->createStub(AccommodationBookingService::class)); $response = $controller->single('unknown-uuid'); $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); self::assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); self::assertSame(['message' => 'Not found'], $payload); } public function testAcceptAcceptsOpenOfferAndSetsAcceptedAt(): void { $booking = new AccommodationBooking(); $booking->setDateFrom(new \DateTimeImmutable('2026-08-01')); $booking->setDateTo(new \DateTimeImmutable('2026-08-02')); $booking->setGroupName('Verein e.V.'); $booking->setFirstName('Tom'); $booking->setLastName('Beispiel'); $booking->setEmail('tom@example.com'); $booking->setStatus(AccommodationBookingStatus::Open); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository ->expects(self::once()) ->method('findOneBy') ->with(['uuid' => $booking->getUuid()]) ->willReturn($booking); $breakdownCalculator = $this->createStub(AccommodationBookingBreakdownCalculator::class); $breakdownCalculator->method('compute')->willReturn(null); $bookingService = $this->createMock(AccommodationBookingService::class); $bookingService ->expects(self::once()) ->method('acceptBooking') ->with($booking) ->willReturnCallback(static function (AccommodationBooking $b): void { $b->setStatus(AccommodationBookingStatus::Received); $b->setAcceptedAt(new \DateTimeImmutable()); }); $controller = new AccommodationBookingController($bookingRepository, $breakdownCalculator, $this->serializer(), $bookingService); $response = $controller->accept($booking->getUuid()); $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame('received', $payload['status']); self::assertSame('offer', $payload['type']); self::assertSame(AccommodationBookingStatus::Received, $booking->getStatus()); self::assertNotNull($booking->getAcceptedAt()); self::assertSame($booking->getAcceptedAt()->format(\DATE_ATOM), $payload['acceptedAt']); } public function testAcceptIsIdempotentWhenBookingAlreadyAccepted(): void { $acceptedAt = new \DateTimeImmutable('2026-07-15T10:00:00+00:00'); $booking = new AccommodationBooking(); $booking->setDateFrom(new \DateTimeImmutable('2026-08-01')); $booking->setDateTo(new \DateTimeImmutable('2026-08-02')); $booking->setGroupName('Verein e.V.'); $booking->setFirstName('Tom'); $booking->setLastName('Beispiel'); $booking->setEmail('tom@example.com'); $booking->setStatus(AccommodationBookingStatus::Received); $booking->setAcceptedAt($acceptedAt); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository ->method('findOneBy') ->with(['uuid' => $booking->getUuid()]) ->willReturn($booking); $breakdownCalculator = $this->createStub(AccommodationBookingBreakdownCalculator::class); $breakdownCalculator->method('compute')->willReturn(null); $bookingService = $this->createMock(AccommodationBookingService::class); $bookingService->expects(self::once())->method('acceptBooking')->with($booking); $controller = new AccommodationBookingController($bookingRepository, $breakdownCalculator, $this->serializer(), $bookingService); $response = $controller->accept($booking->getUuid()); $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame('received', $payload['status']); self::assertSame($acceptedAt, $booking->getAcceptedAt()); self::assertSame($acceptedAt->format(\DATE_ATOM), $payload['acceptedAt']); } public function testAcceptReturnsNotFoundForUnknownUuid(): void { $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository ->expects(self::once()) ->method('findOneBy') ->with(['uuid' => 'unknown-uuid']) ->willReturn(null); $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); $breakdownCalculator->expects(self::never())->method('compute'); $bookingService = $this->createMock(AccommodationBookingService::class); $bookingService->expects(self::never())->method('acceptBooking'); $controller = new AccommodationBookingController($bookingRepository, $breakdownCalculator, $this->serializer(), $bookingService); $response = $controller->accept('unknown-uuid'); $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); self::assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); self::assertSame(['message' => 'Not found'], $payload); } private function serializer(): Serializer { $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); return new Serializer( [ new DateTimeNormalizer(), new ObjectNormalizer($classMetadataFactory, propertyTypeExtractor: new ReflectionExtractor()), ], [new JsonEncoder()], ); } }