114 lines
3.8 KiB
PHP
114 lines
3.8 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Appointment;
|
|
|
|
use App\Appointment\Entity\Appointment;
|
|
use App\Appointment\Repository\SlotTakenException;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Tests\ApiTestCase;
|
|
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
|
|
|
/**
|
|
* A doctor's (doctor_id, slot_start) can be held by at most one live booking.
|
|
* The unique active_slot_key is the race backstop behind bookAtomically:
|
|
* even if two concurrent transactions both pass the isSlotTaken re-check, only
|
|
* one INSERT can win. Cancelling/expiring a booking releases the slot.
|
|
*/
|
|
class SlotUniquenessTest extends ApiTestCase
|
|
{
|
|
private function makeDoctor(): Doctor
|
|
{
|
|
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست');
|
|
$this->em->persist($doctor);
|
|
$this->em->flush();
|
|
|
|
return $doctor;
|
|
}
|
|
|
|
private function newBooking(Doctor $doctor, int $start): Appointment
|
|
{
|
|
return new Appointment($doctor, $this->createUser(), $start, $start + 1_800);
|
|
}
|
|
|
|
public function testTwoLiveBookingsOnSameSlotViolateUniqueKey(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$start = time() + 86_400;
|
|
|
|
$this->em->persist($this->newBooking($doctor, $start));
|
|
$this->em->flush();
|
|
|
|
$this->em->persist($this->newBooking($doctor, $start));
|
|
$this->expectException(UniqueConstraintViolationException::class);
|
|
$this->em->flush();
|
|
}
|
|
|
|
public function testCancelledBookingFreesTheSlot(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$start = time() + 86_400;
|
|
|
|
$first = $this->newBooking($doctor, $start);
|
|
$this->em->persist($first);
|
|
$this->em->flush();
|
|
|
|
$first->transitionTo(Appointment::STATUS_CANCELLED_BY_USER);
|
|
$this->em->flush();
|
|
|
|
$second = $this->newBooking($doctor, $start);
|
|
$this->em->persist($second);
|
|
$this->em->flush();
|
|
|
|
$this->assertNotNull($second->getId());
|
|
}
|
|
|
|
public function testBookAtomicallyRejectsAlreadyTakenSlot(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$start = time() + 86_400;
|
|
$repo = $this->em->getRepository(Appointment::class);
|
|
|
|
$first = $this->newBooking($doctor, $start);
|
|
$first->markPendingWithTtl(Appointment::PAYMENT_TTL);
|
|
$repo->bookAtomically($first);
|
|
|
|
$this->expectException(SlotTakenException::class);
|
|
$repo->bookAtomically($this->newBooking($doctor, $start));
|
|
}
|
|
|
|
/**
|
|
* Regression: a slot whose only booking has already been consumed (completed,
|
|
* no_show, …) must still read as taken in the availability view — otherwise the
|
|
* public site offers an occupied slot as free. isSlotTaken counts the broader
|
|
* SLOT_BLOCKING_STATUSES, not just live pending/confirmed.
|
|
*/
|
|
public function testConsumedBookingStillMarksSlotTaken(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$start = time() + 86_400;
|
|
$repo = $this->em->getRepository(Appointment::class);
|
|
|
|
$appt = $this->newBooking($doctor, $start);
|
|
$appt->transitionTo(Appointment::STATUS_CONFIRMED);
|
|
$appt->transitionTo(Appointment::STATUS_COMPLETED);
|
|
$this->em->persist($appt);
|
|
$this->em->flush();
|
|
|
|
$this->assertTrue($repo->isSlotTaken($doctor, $start, $start + 1_800));
|
|
}
|
|
|
|
public function testCancelledBookingLeavesSlotFreeForAvailability(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$start = time() + 86_400;
|
|
$repo = $this->em->getRepository(Appointment::class);
|
|
|
|
$appt = $this->newBooking($doctor, $start);
|
|
$appt->transitionTo(Appointment::STATUS_CANCELLED_BY_USER);
|
|
$this->em->persist($appt);
|
|
$this->em->flush();
|
|
|
|
$this->assertFalse($repo->isSlotTaken($doctor, $start, $start + 1_800));
|
|
}
|
|
}
|