Files
clinicpro/tests/Appointment/HoldAndBookTest.php
T
hamed c4f1f25c80 Refactor booking system: Remove unused policies, packages, and related entities
- Removed package consumption flags and related properties from PriceQuote.
- Eliminated unused domain event publishing for policies and waitlist in Schedule.
- Cleaned up BookingEngineSeeder by removing package and policy related logic.
- Updated SeedScenariosCommand to reflect removal of policies from output.
- Dropped policy, package, treatment course, cancellation, waitlist, and domain event tables in migration.
- Removed domain event assertions from tests related to resource blocking.
2026-08-01 20:50:47 +03:30

515 lines
22 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, 'اتاق نباید از رزروِ شکست‌خورده قفل بماند');
}
/**
* جابه‌جایی از بیرون شبیه «لغو + رزرو» است، ولی برای بیمار یک اتفاق است.
*
* رویداد `AppointmentRescheduled` همین را می‌گوید؛ بدون آن، مصرف‌کننده‌ای که فقط
* `AppointmentCancelled` را می‌شنود برای بیماری که هنوز نوبت دارد پیام لغو می‌فرستد.
*/
public function testRebookingMovesTheAppointmentAndRecordsTheEvent(): void
{
$s = $this->simpleSetup();
$start = $this->nextSaturdayAt(14);
$first = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]);
$book = $this->authJson('POST', '/api/v1/appointment-confirm', $s['user'], [
'hold_uuid' => $first['data']['hold_uuid'],
'doctor_uuid' => $s['doctor']->getUuid(),
]);
self::assertSame(200, $this->responseCode(), json_encode($book, JSON_UNESCAPED_UNICODE));
$newStart = $start + 2 * 3600;
$second = $this->hold($s['user'], $s['service'], $s['address'], $newStart, ['room' => [$s['room']['uuid']]]);
self::assertSame(201, $this->responseCode());
$moved = $this->authJson(
'POST',
"/api/v1/appointment/{$book['data']['appointment_uuid']}/rebook",
$s['user'],
['hold_uuid' => $second['data']['hold_uuid']],
);
self::assertSame(200, $this->responseCode(), json_encode($moved, JSON_UNESCAPED_UNICODE));
self::assertSame($newStart, $moved['data']['starts_at']);
}
}