fix: report an unresolvable hotel instead of failing on it

This commit is contained in:
fromme committed 2026-09-29 15:27:44 +02:00
1 parent 72be779038
commit 2329fe17a0
6 files changed
+317 -7

No files matched your search

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Administrative\System\Destination;
use App\BusProNet\DataProvider\HotelDataProvider;
use App\BusProNet\DataProvider\PickupDataProvider;
use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\Pickup;
use App\Controller\Administrative\System\Destination\DestinationTrait;
use App\Entity\Destination;
use App\Model\DestinationDto;
use PHPUnit\Framework\TestCase;
/**
* The shared mapping behind the destination create, edit and duplicate forms.
*
* Both behaviours here are about what happens when MyE&P cannot resolve something, which is
* the state the whole form lands in during an outage: the hotel stops the write, a boarding
* point does not.
*/
class DestinationTraitTest extends TestCase
{
/**
* The same choice App\Command\MyEpImportCommand makes for the export's own unresolvable ids.
* Dropping the stop would hide a departure the coach actually makes; PickupOptions renders
* one without a town as "Zustieg {busProId}".
*/
public function testAnUnresolvablePickupIsKeptWithoutItsAddress(): void
{
$harness = $this->harness($this->hotel(), [4 => $this->pickup()]);
$destination = new Destination();
$harness->map($destination, $this->dto([4, 999]));
$this->assertSame([
['busProId' => 4, 'city' => 'Karlsruhe', 'street' => 'A5 - Autohof Bruchsal', 'time' => null],
['busProId' => 999, 'city' => null, 'street' => null, 'time' => null],
], $destination->getPickups());
}
/**
* ResolvableHotel reports this on the field, and every caller runs behind $form->isValid().
* This is the second line: a half built destination has no house name, no cost unit and no
* country, and nothing reads those back until an invoice is stamped.
*/
public function testAnUnresolvableHotelRefusesToWriteAnything(): void
{
$harness = $this->harness(hotel: null);
$destination = new Destination();
try {
$harness->map($destination, $this->dto([]));
$this->fail('Expected a LogicException');
} catch (\LogicException $e) {
$this->assertStringContainsString('does not resolve', $e->getMessage());
}
$this->assertNull($destination->getHotel());
$this->assertNull($destination->getProduct());
}
/**
* @param ?Hotel $hotel null stands for a hotel the base data cannot resolve
* @param array<int, Pickup> $pickups keyed by busProId; anything absent does not resolve
*/
private function harness(?Hotel $hotel, array $pickups = []): object
{
$hotelProvider = $this->createMock(HotelDataProvider::class);
$hotelProvider->method('get')->willReturn($hotel);
$pickupProvider = $this->createMock(PickupDataProvider::class);
$pickupProvider->method('get')->willReturnCallback(fn ($id) => $pickups[$id] ?? null);
return new class($hotelProvider, $pickupProvider) {
use DestinationTrait;
public function __construct(
private readonly HotelDataProvider $hotelDataProvider,
private readonly PickupDataProvider $pickupDataProvider,
) {
}
public function map(Destination $destination, DestinationDto $dto): void
{
$this->updateEntity($destination, $dto);
}
};
}
private function dto(array $pickupIds): DestinationDto
{
return (new DestinationDto())
->setProduct('Lenzerheide - Sportclub Jenatsch')
->setDateFrom(new \DateTimeImmutable('2026-04-04'))
->setDateTo(new \DateTimeImmutable('2026-04-11'))
->setHotelBusProId('163959')
->setCountry('ch')
->setPickups($pickupIds)
;
}
private function hotel(): Hotel
{
return (new Hotel())
->setBusProId(163959)
->setCode('LPJGR')
->setName('Sportclub Jenatsch')
->setCostUnit('4711')
;
}
private function pickup(): Pickup
{
return (new Pickup())
->setBusProId(4)
->setCity('Karlsruhe')
->setStreet('A5 - Autohof Bruchsal')
;
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Tests\Validator\Constraints;
use App\BusProNet\DataProvider\HotelDataProvider;
use App\BusProNet\Model\Hotel;
use App\Validator\Constraints\ResolvableHotel;
use App\Validator\Constraints\ResolvableHotelValidator;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* Everything a destination records about its house is derived from this lookup, so an id that
* does not resolve used to reach DestinationTrait::updateEntity() and fail there on a null -
* a 500 on a form submit rather than a message on a field.
*
* The two causes are reported apart on purpose. An unreachable MyE&P is not the operator's
* doing, and telling them their choice was invalid sends them hunting for a different answer
* when the one they gave was right.
*/
class ResolvableHotelValidatorTest extends ConstraintValidatorTestCase
{
public function testAResolvableHotelPasses(): void
{
$this->validator = $this->createValidator([163959 => $this->hotel()]);
$this->validator->initialize($this->context);
$this->validator->validate('163959', new ResolvableHotel());
$this->assertNoViolation();
}
public function testAHotelMissingFromALoadedListIsRejectedAsUnknown(): void
{
$constraint = new ResolvableHotel();
$this->validator = $this->createValidator([163959 => $this->hotel()]);
$this->validator->initialize($this->context);
$this->validator->validate('999999', $constraint);
$this->buildViolation($constraint->unknownMessage)->assertRaised();
}
/**
* StaleWhileErrorCache answers [] rather than throwing, so an empty list is the only signal
* that MyE&P could not be reached - and on a cold cache there is no stale copy to fall back to.
*/
public function testAnEmptyListIsReportedAsAnOutageRatherThanABadChoice(): void
{
$constraint = new ResolvableHotel();
$this->validator = $this->createValidator([]);
$this->validator->initialize($this->context);
$this->validator->validate('163959', $constraint);
$this->buildViolation($constraint->unavailableMessage)->assertRaised();
}
/**
* Whether a hotel was chosen at all belongs to NotBlank, which already sits on the property.
*/
public function testAnEmptyValueIsLeftToNotBlank(): void
{
$this->validator = $this->createValidator([]);
$this->validator->initialize($this->context);
$this->validator->validate(null, new ResolvableHotel());
$this->validator->validate('', new ResolvableHotel());
$this->assertNoViolation();
}
protected function createValidator(array $hotels = []): ResolvableHotelValidator
{
return new ResolvableHotelValidator($this->provider($hotels));
}
/**
* @return HotelDataProvider&MockObject
*/
private function provider(array $hotels): object
{
$provider = $this->createMock(HotelDataProvider::class);
$provider->method('getAll')->willReturn($hotels);
return $provider;
}
private function hotel(): Hotel
{
return (new Hotel())
->setBusProId(163959)
->setCode('LPJGR')
->setName('Sportclub Jenatsch')
;
}
}