Files
clinicpro/tests/Appointment/HoldAndBookTest.php
T
hamedandClaude Opus 5 4395eea56e feat(booking): multi-resource holds and confirmation with a database-level guarantee
Section 11 and the third closing rule of the design document: preventing a double
booking is the database's job, not the code's. Any "is it free?" check in PHP has a
race window between the read and the write — two concurrent requests both see free
and both write.

MariaDB has no range EXCLUDE constraint, so every occupied interval is broken into
fixed five-minute buckets under UNIQUE(resource_id, bucket_at, seat). The code only
INSERTs; a rejection from the database *is* the answer. `seat` carries capacity: a
three-bed room has seats 0..2, allocation walks upward on each collision, and the
fourth concurrent hold finds nowhere to sit. Counting capacity in PHP would have
rebuilt the very race this removes.

Buckets are written through DBAL rather than the ORM on purpose: a unique violation
raised inside flush() closes the EntityManager, and the next seat attempt would then
fail with "EntityManager is closed", hiding the real outcome.

Occupancy is one row per (segment × resource). The reference test asserts the payoff
directly: for a 55-minute appointment of numbing / waiting / laser, the room gets
three rows and the operator only two — the operator holds nothing during the wait and
stays bookable for someone else.

A partial hold never survives. If the second resource has no room, the first is
released and the hold itself removed; otherwise a resource stays locked for an
appointment that will never exist.

Confirming does not re-reserve anything — the seats were taken at hold time and only
the label changes. Re-reserving on confirm would reopen the race the hold closed.
Cancelling marks rows `released` instead of deleting them, because the history of
which resource was busy when is the input to the utilisation reports; the uniqueness
buckets *are* deleted, or that interval would stay locked forever.

Expired holds are released by the existing scheduler rather than a new one. That
exposed a bug in my own change: the flush guard used $count, which now includes
released holds, so reset([]) could pass false to save(). It is guarded on $expired.

The appointment itself is still built with the existing constructor, so
active_slot_key, events and the payment path behave exactly as before — the
multi-resource occupancy sits beside them, not instead of them.

12 tests. Two matter most: the second hold on the same resource and interval getting
409, and a test that writes a duplicate bucket row over a *separate connection* and
expects the unique-key violation — if that one ever passes silently, the guarantee
had moved back into the code.

1208 tests / 3495 assertions. phpstan at its 14-error baseline. Frozen slot contract
green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:28:55 +03:30

482 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Tests\Appointment;
use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Appointment\Booking\Entity\AppointmentHold;
use App\Appointment\Booking\Entity\OccupancyBucket;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ResourceType;
use App\Tests\ApiTestCase;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
/**
* رزرو موقت و ثبت نهایی چندمنبعی — بند ۱۱ مستند.
*/
class HoldAndBookTest extends ApiTestCase
{
private const TEHRAN = 'Asia/Tehran';
private function nextSaturdayAt(int $hour): int
{
return (new \DateTimeImmutable('next saturday', new \DateTimeZone(self::TEHRAN)))
->setTime($hour, 0)
->getTimestamp();
}
/**
* اشغال‌های یک منبع مشخص.
*
* `db_test` هرگز ریست نمی‌شود و تست‌های دیگر هم روی همین ساعت ردیف می‌سازند، پس
* پرس‌وجو حتماً باید به منبعِ همین تست محدود شود — وگرنه تست، دادهٔ دیگران را
* می‌شمارد.
*
* @return list<ResourceOccupancy>
*/
private function occupancyOf(string $resourceUuid, ?int $startsAt = null): array
{
$qb = $this->em->createQueryBuilder()
->select('o')
->from(ResourceOccupancy::class, 'o')
->join('o.resource', 'r')
->where('r.uuid = :uuid')
->setParameter('uuid', $resourceUuid)
->orderBy('o.startsAt', 'ASC');
if ($startsAt !== null) {
$qb->andWhere('o.startsAt = :start')->setParameter('start', $startsAt);
}
return $qb->getQuery()->getResult();
}
/** @return array{user: User, doctor: Doctor, section: ServiceSection, address: DoctorAddress} */
private function clinic(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک رزرو');
$this->em->persist($clinic);
$this->em->flush();
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($doctorUser, 'دکتر رزرو');
$this->em->persist($doctor);
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر');
$this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$this->em->flush();
return ['user' => $user, 'doctor' => $doctor, 'section' => $section, 'address' => $address];
}
private function service(ServiceSection $section, string $name, int $solo): ServiceItem
{
$section = $this->em->getRepository(ServiceSection::class)->find($section->getId());
$item = new ServiceItem($section, $name);
$item->setSoloDurationMinutes($solo);
$this->em->persist($item);
$this->em->flush();
return $item;
}
private function type(DoctorAddress $address, string $code, string $name): ResourceType
{
$type = new ResourceType($address->tenantEntityType(), $address->tenantEntityId(), $code, $name);
$this->em->persist($type);
$this->em->flush();
return $type;
}
/** @param array<string, mixed> $extra */
private function resource(User $user, DoctorAddress $address, ResourceType $type, string $name, array $extra = []): array
{
$created = $this->authJson('POST', '/api/v1/resource', $user, $extra + [
'address_uuid' => $address->getUuid(),
'type_uuid' => $type->getUuid(),
'name' => $name,
]);
self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE));
$this->authJson('PUT', "/api/v1/resource/{$created['data']['uuid']}/calendar", $user, [
'days' => array_fill_keys(range(0, 6), [['start_minute' => 0, 'end_minute' => 1440]]),
]);
return $created['data'];
}
/** @param list<array<string, mixed>> $segments */
private function segments(User $user, ServiceItem $service, array $segments): void
{
$this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, ['segments' => $segments]);
self::assertSame(200, $this->responseCode());
}
/** @param array<string, list<string>> $assignment */
private function hold(User $user, ServiceItem $service, DoctorAddress $address, int $start, array $assignment): array
{
return $this->authJson('POST', '/api/v1/appointment-hold', $user, [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $address->getUuid(),
'start' => $start,
'assignment' => $assignment,
]);
}
/** یک سرویس بیست‌دقیقه‌ای که فقط یک اتاق می‌خواهد. */
private function simpleSetup(int $capacity = 1): array
{
$c = $this->clinic();
$service = $this->service($c['section'], 'ویزیت', 20);
$room = $this->type($c['address'], 'room', 'اتاق');
$created = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱', ['capacity' => $capacity]);
$this->segments($c['user'], $service, [
['sequence' => 1, 'name' => 'ویزیت', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]],
]);
return $c + ['service' => $service, 'room' => $created];
}
public function testHoldCreatesOccupancyRowsPerSegmentAndResource(): void
{
$s = $this->simpleSetup();
$start = $this->nextSaturdayAt(10);
$body = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertNotEmpty($body['data']['hold_uuid']);
self::assertGreaterThan(time(), $body['data']['expires_at']);
$this->em->clear();
$rows = $this->occupancyOf($s['room']['uuid'], $start);
self::assertCount(1, $rows);
self::assertSame(ResourceOccupancy::STATUS_HOLD, $rows[0]->getStatus());
self::assertSame('ویزیت', $rows[0]->getSegmentName());
}
/** بعد از رزرو موقت، همان زمان دیگر پیشنهاد نمی‌شود. */
public function testHeldTimeDisappearsFromAvailability(): void
{
$s = $this->simpleSetup();
$start = $this->nextSaturdayAt(10);
$saturday = $this->nextSaturdayAt(0);
$before = $this->authJson('POST', '/api/v1/appointment-availability', $s['user'], [
'service_uuid' => $s['service']->getUuid(),
'branch_uuid' => $s['address']->getUuid(),
'from' => $saturday,
'to' => $saturday,
'step_minutes' => 20,
]);
self::assertContains($start, array_column($before['data']['slots'], 'start'));
$this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode());
$after = $this->authJson('POST', '/api/v1/appointment-availability', $s['user'], [
'service_uuid' => $s['service']->getUuid(),
'branch_uuid' => $s['address']->getUuid(),
'from' => $saturday,
'to' => $saturday,
'step_minutes' => 20,
]);
self::assertNotContains($start, array_column($after['data']['slots'], 'start'));
}
/**
* ⭐ تست همزمانی — اصلی‌ترین تست این تسک.
*
* دو رزرو روی همان منبع و همان بازه: دقیقاً یکی ۲۰۱ و دیگری ۴۰۹. تضمین از قید
* یکتای دیتابیس می‌آید نه از بررسی کد، و همین تست آن قید را مستقیم هم می‌سنجد.
*/
public function testSecondHoldOnTheSameResourceAndIntervalIsRejected(): void
{
$s = $this->simpleSetup();
$start = $this->nextSaturdayAt(11);
$first = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode(), json_encode($first, JSON_UNESCAPED_UNICODE));
$second = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(409, $this->responseCode());
self::assertSame('ERR_SLOT_TAKEN', $second['errors'][0]['code']);
}
/**
* خودِ قید دیتابیس، مستقل از هر کدِ PHP: نوشتن مستقیم دو ردیف یکسان باید با
* نقض کلید یکتا رد شود. اگر این تست بشکند، یعنی تضمین فقط در کد بوده است.
*/
public function testDatabaseItselfRefusesADuplicateBucketSeat(): void
{
$s = $this->simpleSetup();
$start = $this->nextSaturdayAt(12);
$this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode());
$this->em->clear();
$occupancy = $this->occupancyOf($s['room']['uuid'], $start)[0];
$bucket = OccupancyBucket::bucketsFor($start, $start + 1200)[0];
// اتصال جدا، نه اتصال مشترکِ تست: هم به «درخواست دیگر» وفادارتر است و هم
// خطای عمدی، EntityManager مشترک را برای تست‌های بعدی خراب نمی‌کند.
$connection = DriverManager::getConnection(
$this->em->getConnection()->getParams(),
);
$this->expectException(UniqueConstraintViolationException::class);
try {
$connection->insert('resource_occupancy_buckets', [
'resource_id' => $occupancy->getResource()->getId(),
'occupancy_id' => $occupancy->getId(),
'bucket_at' => $bucket,
'seat' => 0,
]);
} finally {
$connection->close();
}
}
/** ظرفیت ۳: سه رزرو هم‌زمان می‌گذرند، چهارمی ۴۰۹. */
public function testCapacityThreeAllowsThreeConcurrentHoldsAndRefusesTheFourth(): void
{
$s = $this->simpleSetup(capacity: 3);
$start = $this->nextSaturdayAt(13);
foreach (range(1, 3) as $n) {
$this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode(), "رزرو $n باید بگذرد");
}
$fourth = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(409, $this->responseCode());
self::assertSame('ERR_SLOT_TAKEN', $fourth['errors'][0]['code']);
}
public function testConfirmMarksEverythingBooked(): void
{
$s = $this->simpleSetup();
$start = $this->nextSaturdayAt(14);
$hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode());
$body = $this->authJson('POST', '/api/v1/appointment-confirm', $s['user'], [
'hold_uuid' => $hold['data']['hold_uuid'],
'doctor_uuid' => $s['doctor']->getUuid(),
]);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertNotEmpty($body['data']['appointment_uuid']);
$this->em->clear();
$rows = $this->occupancyOf($s['room']['uuid'], $start);
self::assertSame(ResourceOccupancy::STATUS_BOOKED, $rows[0]->getStatus());
self::assertNotNull($rows[0]->getAppointmentId());
}
/** رزرو منقضی ثبت نمی‌شود و آن زمان دوباره آزاد است. */
public function testExpiredHoldCannotBeConfirmed(): void
{
$s = $this->simpleSetup();
$start = $this->nextSaturdayAt(15);
$hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode());
// مهلت را به عقب می‌بریم — همان کاری که گذر زمان می‌کند.
$this->em->clear();
$entity = $this->em->getRepository(AppointmentHold::class)->findOneBy(['uuid' => $hold['data']['hold_uuid']]);
$this->em->getConnection()->update(
'appointment_holds',
['expires_at' => time() - 60],
['id' => $entity->getId()],
);
$body = $this->authJson('POST', '/api/v1/appointment-confirm', $s['user'], [
'hold_uuid' => $hold['data']['hold_uuid'],
'doctor_uuid' => $s['doctor']->getUuid(),
]);
self::assertSame(409, $this->responseCode());
self::assertSame('ERR_HOLD_EXPIRED', $body['errors'][0]['code']);
}
/** آزادسازی زودهنگام: زمان دوباره در جستجو ظاهر می‌شود. */
public function testReleasingAHoldFreesTheTimeAgain(): void
{
$s = $this->simpleSetup();
$start = $this->nextSaturdayAt(16);
$saturday = $this->nextSaturdayAt(0);
$hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode());
$this->authJson('DELETE', "/api/v1/appointment-hold/{$hold['data']['hold_uuid']}", $s['user']);
self::assertSame(200, $this->responseCode());
$after = $this->authJson('POST', '/api/v1/appointment-availability', $s['user'], [
'service_uuid' => $s['service']->getUuid(),
'branch_uuid' => $s['address']->getUuid(),
'from' => $saturday,
'to' => $saturday,
'step_minutes' => 20,
]);
self::assertContains($start, array_column($after['data']['slots'], 'start'));
}
/** رزرو کاربر دیگر ۴۰۴ می‌گیرد، نه ۴۰۳ — وجودش نباید لو برود. */
public function testAnotherUsersHoldIsNotFound(): void
{
$s = $this->simpleSetup();
$start = $this->nextSaturdayAt(17);
$hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode());
// مزاحم باید محیط معتبر خودش را داشته باشد، وگرنه ۴۰۳ «محیط انتخاب نشده»
// می‌گیرد و تست چیزی را که ادعا می‌کند نمی‌سنجد.
$intruder = $this->clinic()['user'];
$this->authJson('POST', '/api/v1/appointment-confirm', $intruder, [
'hold_uuid' => $hold['data']['hold_uuid'],
'doctor_uuid' => $s['doctor']->getUuid(),
]);
self::assertSame(404, $this->responseCode());
}
/** نیازمندی‌ای که در `assignment` منبع ندارد → ۴۲۲، پیش از هر رزروی. */
public function testAssignmentMissingARoleIsRejected(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'لیزر', 20);
$room = $this->type($c['address'], 'room', 'اتاق');
$operator = $this->type($c['address'], 'operator', 'اپراتور');
$roomRes = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱');
$this->resource($c['user'], $c['address'], $operator, 'اپراتور ۱');
$this->segments($c['user'], $service, [
['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [
['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()],
]],
]);
$body = $this->hold($c['user'], $service, $c['address'], $this->nextSaturdayAt(18), [
'room' => [$roomRes['uuid']], // اپراتور نیامده
]);
self::assertSame(422, $this->responseCode());
self::assertStringContainsString('اپراتور', $body['errors'][0]['message']);
}
/**
* ⭐ آزادسازی ظرفیت حفظ می‌شود: اپراتور در بخش «انتظار» ردیف اشغال **ندارد**.
*/
public function testOperatorHasNoOccupancyDuringTheWaitingSegment(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'لیزر', 20);
$room = $this->type($c['address'], 'room', 'اتاق');
$operator = $this->type($c['address'], 'operator', 'اپراتور');
$roomRes = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱');
$opRes = $this->resource($c['user'], $c['address'], $operator, 'اپراتور ۱');
$this->segments($c['user'], $service, [
['sequence' => 1, 'name' => 'بی‌حسی', 'duration_minutes' => 5, 'requirements' => [['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()]]],
['sequence' => 2, 'name' => 'انتظار', 'duration_minutes' => 30, 'requirements' => [['type_uuid' => $room->getUuid()]]],
['sequence' => 3, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()]]],
]);
$start = $this->nextSaturdayAt(19);
$body = $this->hold($c['user'], $service, $c['address'], $start, [
'room' => [$roomRes['uuid']],
'operator' => [$opRes['uuid']],
]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
$this->em->clear();
$operatorRows = $this->occupancyOf($opRes['uuid']);
self::assertCount(2, $operatorRows, 'دو بخش، نه یک بازهٔ پیوستهٔ ۵۵ دقیقه‌ای');
self::assertSame($start, $operatorRows[0]->getStartsAt());
self::assertSame($start + 5 * 60, $operatorRows[0]->getEndsAt());
self::assertSame($start + 35 * 60, $operatorRows[1]->getStartsAt());
// اتاق برعکس: هر سه بخش را می‌گیرد.
self::assertCount(3, $this->occupancyOf($roomRes['uuid']));
}
/** رزرو نیمه‌کاره نمی‌ماند: اگر منبع دوم جا نداشت، اولی هم آزاد می‌شود. */
public function testPartialHoldIsRolledBack(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'لیزر', 20);
$room = $this->type($c['address'], 'room', 'اتاق');
$operator = $this->type($c['address'], 'operator', 'اپراتور');
$roomRes = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱');
$opRes = $this->resource($c['user'], $c['address'], $operator, 'اپراتور ۱');
$this->segments($c['user'], $service, [
['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [
['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()],
]],
]);
$start = $this->nextSaturdayAt(20);
// اپراتور را از پیش می‌گیریم تا رزرو دوم روی او شکست بخورد.
$onlyOperator = $this->service($c['section'], 'کار اپراتور', 20);
$this->segments($c['user'], $onlyOperator, [
['sequence' => 1, 'name' => 'کار', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $operator->getUuid()]]],
]);
$this->hold($c['user'], $onlyOperator, $c['address'], $start, ['operator' => [$opRes['uuid']]]);
self::assertSame(201, $this->responseCode());
$body = $this->hold($c['user'], $service, $c['address'], $start, [
'room' => [$roomRes['uuid']],
'operator' => [$opRes['uuid']],
]);
self::assertSame(409, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
// اتاق نباید قفل مانده باشد.
$this->em->clear();
$roomRows = $this->em->createQuery(
'SELECT o FROM App\Appointment\Availability\Entity\ResourceOccupancy o
JOIN o.resource r WHERE r.uuid = :uuid AND o.status IN (:blocking)'
)
->setParameter('uuid', $roomRes['uuid'])
->setParameter('blocking', ResourceOccupancy::BLOCKING_STATUSES)
->getResult();
self::assertSame([], $roomRows, 'اتاق نباید از رزروِ شکست‌خورده قفل بماند');
}
}