Files
myep/tests/Service/ContingentSnapshotManagerTest.php
T

257 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BpnConnect\ContingentsClient;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\Model\ContingentCalendarEntry;
use App\BpnConnect\Model\ContingentCalendarMeta;
use App\BpnConnect\Model\ContingentCalendarResponse;
use App\BpnConnect\Model\ContingentStatus;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\ContingentDay;
use App\Entity\Groups\ContingentSyncState;
use App\Repository\Groups\ContingentDayRepository;
use App\Repository\Groups\ContingentSyncStateRepository;
use App\Service\ContingentSnapshotManager;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class ContingentSnapshotManagerTest extends TestCase
{
private ContingentsClient&Stub $client;
private ContingentDayRepository&Stub $dayRepository;
private ContingentSyncStateRepository&Stub $syncStateRepository;
private EntityManagerInterface&Stub $entityManager;
protected function setUp(): void
{
$this->client = $this->createStub(ContingentsClient::class);
$this->dayRepository = $this->createStub(ContingentDayRepository::class);
$this->syncStateRepository = $this->createStub(ContingentSyncStateRepository::class);
$this->entityManager = $this->createStub(EntityManagerInterface::class);
}
public function testAddsMissingDaysAndMarksSnapshotChanged(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-02', ContingentStatus::Blocked),
]));
$this->entityManager->expects(self::exactly(3))->method('persist');
$result = $this->sync();
self::assertTrue($result->successful);
self::assertTrue($result->changed);
self::assertSame(2, $result->added);
self::assertSame(0, $result->updated);
self::assertSame(0, $result->removed);
}
public function testUpdatesChangedStatusAndRemovesVanishedDays(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$existing = [
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
'2026-07-02' => $this->day('2026-07-02', ContingentStatus::Ok),
'2026-07-03' => $this->day('2026-07-03', ContingentStatus::Ok),
];
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn($existing);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-02', ContingentStatus::OnRequest),
]));
$this->entityManager->expects(self::once())->method('remove');
$result = $this->sync();
self::assertSame(0, $result->added);
self::assertSame(1, $result->updated);
self::assertSame(1, $result->removed);
self::assertSame(ContingentStatus::OnRequest, $existing['2026-07-02']->getStatus());
}
public function testAnIdenticalUpstreamResponseDoesNotMoveChangedAt(): void
{
$state = new ContingentSyncState($this->accommodation());
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
]));
$result = $this->sync();
self::assertFalse($result->changed);
self::assertNull($state->getChangedAt());
self::assertNotNull($state->getSyncedAt());
}
public function testUpstreamFailureKeepsSnapshotAndRecordsError(): void
{
$this->dayRepository = $this->createMock(ContingentDayRepository::class);
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$state = new ContingentSyncState($this->accommodation());
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->client->method('getContingentCalendar')->willThrowException(new BpnConnectException('upstream down'));
$this->dayRepository->expects(self::never())->method('findByAccommodationAndDateRange');
$this->entityManager->expects(self::never())->method('remove');
$result = $this->sync();
self::assertFalse($result->successful);
self::assertSame('upstream down', $result->error);
self::assertSame(1, $state->getFailureCount());
self::assertNull($state->getSyncedAt());
}
public function testEmptyUpstreamResponseDoesNotWipeAPopulatedRange(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$state = new ContingentSyncState($this->accommodation());
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
]);
$this->client->method('getContingentCalendar')->willReturn($this->response([]));
$this->entityManager->expects(self::never())->method('remove');
$result = $this->sync();
self::assertFalse($result->successful);
self::assertSame(1, $state->getFailureCount());
}
public function testDaysOutsideTheRequestedWindowAreIgnored(): void
{
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-06-30', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-08-01', ContingentStatus::Ok),
]));
$result = $this->sync();
self::assertSame(1, $result->added);
}
public function testNearTermSyncDoesNotShrinkTheHorizonEstablishedByTheFullRun(): void
{
$state = new ContingentSyncState($this->accommodation());
$state->recordSuccess(new \DateTimeImmutable('2026-08-20'), new \DateTimeImmutable('2028-08-20'));
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
]));
// The near-term job syncs only to 2026-07-03 but must not discard the 24-month reach.
$this->sync();
self::assertSame('2028-08-20', $state->getHorizonTo()?->format('Y-m-d'));
}
public function testHorizonGrowsWhenAFurtherWindowIsSynced(): void
{
$state = new ContingentSyncState($this->accommodation());
$state->recordSuccess(new \DateTimeImmutable('2026-08-20'), new \DateTimeImmutable('2026-01-01'));
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
]));
$this->sync();
self::assertSame('2026-07-03', $state->getHorizonTo()?->format('Y-m-d'));
}
public function testTheRollingWindowGrowingIsNotAChange(): void
{
$state = new ContingentSyncState($this->accommodation());
$state->recordSuccess(new \DateTimeImmutable('2026-06-30'), new \DateTimeImmutable('2026-07-02'));
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
'2026-07-02' => $this->day('2026-07-02', ContingentStatus::Ok),
]);
// Same two days as before plus the one the window just grew by, which is what every
// scheduled run sees after midnight.
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-02', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-03', ContingentStatus::Ok),
]));
$result = $this->sync();
self::assertFalse($result->changed);
self::assertSame(0, $result->added);
self::assertSame(1, $result->extended);
self::assertNull($state->getChangedAt());
}
private function sync(): \App\Model\ContingentSyncResult
{
$manager = new ContingentSnapshotManager(
$this->client,
$this->dayRepository,
$this->syncStateRepository,
$this->entityManager,
$this->createStub(LoggerInterface::class),
);
return $manager->sync(
$this->accommodation(),
new \DateTimeImmutable('2026-07-01'),
new \DateTimeImmutable('2026-07-03'),
);
}
private function accommodation(): Accommodation
{
return (new Accommodation())->setCalendarCode('HOTEL1');
}
private function day(string $date, ContingentStatus $status): ContingentDay
{
return (new ContingentDay())
->setDate(new \DateTimeImmutable($date))
->setStatus($status);
}
/**
* @param ContingentCalendarEntry[] $entries
*/
private function response(array $entries): ContingentCalendarResponse
{
return new ContingentCalendarResponse(
new ContingentCalendarMeta('2026-07-01', '2026-07-03', 'days', 'HOTEL1', 1, count($entries)),
$entries,
);
}
}