Files
hamedandClaude Opus 5 fd59eb57a3 feat(appointments): only doctors with a weekly schedule get a tab
my/clinic-doctors now reports has_schedule per doctor, and the appointments page
builds tabs from it. A doctor with no working days had a tab that could only ever
show an empty timeline.

The flag is resolved with one query for the whole list rather than one per
doctor. Clinic owners now read this authenticated endpoint too instead of the
public clinic doctor-list, which is where the flag lives; admin keeps the public
list and, with no flag present, hides nobody.

Also repairs fallout from making the resource supervisor mandatory: four test
classes build resources through their own helpers and were failing with 422. The
supervisorFor helper moved to ApiTestCase so all domains share one, rather than
copying it per suite. Full backend suite is green again (1306 tests) — the
previous commit only ran tests/Resource and missed this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:42:42 +03:30

516 lines
22 KiB
PHP
Raw Permalink 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(),
'supervisor_doctor_uuid' => $this->supervisorFor($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']);
}
}