Files
clinicpro/tests/Waitlist/WaitlistTest.php
T
hamedandClaude Opus 5 4bba322b8e feat(waitlist): make the day-part preference, the conversion and the expiry real
Three rows of task 13 were storing data nothing ever read.

`preferred_day_parts` was saved and displayed but never applied when matching.
It was deferred because "evening" has no fixed meaning — but branches already
carry a timezone (DoctorAddress::getTimezone), so the boundaries can be pinned:
morning [6,12), afternoon [12,17), evening [17,22), in the branch's local hour.
The list is now closed and validated; an unknown part is a 422 rather than a
preference that silently matches nothing. The filter runs *before* the cut to
ten recipients — otherwise the first ten slots go to people who did not want
that hour and the real eleventh person is never told.

`markConverted()` was dead code: nothing called it. It now runs off the
AppointmentBooked domain event rather than from inside BookingService, because
converting is a side effect of booking — inside the booking transaction a
waitlist error could roll back the patient's actual appointment. The match is
deliberately narrow (same patient, same service, start inside the window); a
loose match closes a row the patient is still waiting on. It is idempotent, so
redelivery is harmless.

Expiry now exists as a service, a daily scheduled message and
`app:waitlist:expire`. Expired rows were already excluded from matching, so
this is display hygiene, not a behaviour fix: without it the waitlist page
fills with dead entries and the operator cannot tell which are still live. It
sets a status rather than deleting — who waited and never got a slot is data.

Also: a waitlist window is capped at 90 days, matching the booking horizon. An
unbounded window is a row that never expires and shows up in every match.

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

464 lines
19 KiB
PHP

<?php
namespace App\Tests\Waitlist;
use App\Appointment\Entity\Appointment;
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\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
use App\Waitlist\Entity\WaitlistEntry;
use App\Waitlist\Repository\WaitlistEntryRepository;
use App\Waitlist\Service\WaitlistNotifier;
/**
* لیست انتظار — تسک ۱۳.
*
* تصمیم معماری: ظرفیت آزادشده **به همه** خبر داده می‌شود و اولین رزروکننده می‌برد.
* صف انحصاری یعنی وقتی که کسی جوابش را نمی‌دهد نیم ساعت قفل بماند، و ظرفیتِ دو ساعت
* مانده به نوبت آن نیم ساعت را ندارد.
*/
class WaitlistTest extends ApiTestCase
{
private int $slotCursor = 0;
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
private function clinicWithPatient(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک انتظار');
$this->em->persist($clinic);
$this->em->flush();
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر');
$this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($doctorUser, 'دکتر انتظار');
$this->em->persist($doctor);
$patientUser = $this->createUser(['ROLE_USER']);
$patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId());
$this->em->persist($patient);
$this->em->flush();
return [$user, $section, $address, $doctor, $patient];
}
private function extraPatient(int $clinicId): PatientRecord
{
$user = $this->createUser(['ROLE_USER']);
$patient = new PatientRecord('clinic', $clinicId, $user, 'clinic', $clinicId);
$this->em->persist($patient);
$this->em->flush();
return $patient;
}
private function service(ServiceSection $section, string $name = 'لیزر'): ServiceItem
{
$item = new ServiceItem($section, $name);
$item->setSoloDurationMinutes(30);
$item->setPriceRials(4_000_000);
$this->em->persist($item);
$this->em->flush();
return $item;
}
/** @param array<string, mixed> $extra */
private function join(User $user, PatientRecord $patient, ServiceItem $service, int $from, int $to, array $extra = []): array
{
$body = $this->authJson('POST', '/api/v1/waitlist', $user, $extra + [
'patient_uuid' => $patient->getUuid(),
'service_uuid' => $service->getUuid(),
'desired_from' => $from,
'desired_to' => $to,
]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
return $body['data'];
}
private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, DoctorAddress $address, int $start): Appointment
{
$em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class);
$start += (++$this->slotCursor) * 60;
$appointment = new Appointment(
$em->getRepository(Doctor::class)->find($doctor->getId()),
$em->getRepository(PatientRecord::class)->find($patient->getId())->getUser(),
$start,
$start + 1800,
);
$appointment->assignTenantPair('clinic', (int) $address->getClinicId());
$appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId()));
$appointment->setAddressId($address->getId());
$appointment->setPatientName('بیمار نوبت');
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$em->persist($appointment);
$em->flush();
return $appointment;
}
private function notifier(): WaitlistNotifier
{
return static::getContainer()->get(WaitlistNotifier::class);
}
// ── ثبت ─────────────────────────────────────────────────────────────────
public function testJoiningTheWaitlistStoresTheWindow(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$from = time() + 86400;
$to = $from + 3 * 86400;
$entry = $this->join($user, $patient, $service, $from, $to, ['preferred_day_parts' => ['evening']]);
self::assertSame('waiting', $entry['status']);
self::assertSame($from, $entry['desired_from']);
self::assertSame(['evening'], $entry['preferred_day_parts']);
self::assertSame(0, $entry['notify_count']);
$list = $this->authJson('GET', '/api/v1/waitlist', $user);
self::assertCount(1, $list['data']);
}
/** انتظار برای بازهٔ گذشته هرگز به نتیجه نمی‌رسد. */
public function testAPastWindowIsRejected(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$this->authJson('POST', '/api/v1/waitlist', $user, [
'patient_uuid' => $patient->getUuid(),
'service_uuid' => $service->getUuid(),
'desired_from' => time() - 5 * 86400,
'desired_to' => time() - 86400,
]);
self::assertSame(422, $this->responseCode());
}
public function testAnInvertedWindowIsRejected(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$this->authJson('POST', '/api/v1/waitlist', $user, [
'patient_uuid' => $patient->getUuid(),
'service_uuid' => $service->getUuid(),
'desired_from' => time() + 5 * 86400,
'desired_to' => time() + 86400,
]);
self::assertSame(422, $this->responseCode());
}
// ── تطبیق و اطلاع ───────────────────────────────────────────────────────
public function testMatchesFindsEveryoneWaitingForThatMoment(): void
{
[$user, $section, $address, , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$from = time() + 86400;
$to = $from + 3 * 86400;
$this->join($user, $patient, $service, $from, $to);
$this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $from, $to);
// کسی که بازه‌اش پوشش نمی‌دهد نباید بیاید.
$this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $to + 86400, $to + 5 * 86400);
$start = $from + 3600;
$matches = $this->authJson(
'GET',
sprintf('/api/v1/waitlist/matches?service_uuid=%s&start=%d', $service->getUuid(), $start),
$user,
);
self::assertSame(200, $this->responseCode(), json_encode($matches, JSON_UNESCAPED_UNICODE));
self::assertCount(2, $matches['data']);
}
/** ⭐ همه خبر می‌شوند — نه فقط نفر اول. */
public function testCancellingAnAppointmentNotifiesEveryMatchingEntry(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$slot = time() + 2 * 86400;
$from = $slot - 86400;
$to = $slot + 86400;
$first = $this->join($user, $patient, $service, $from, $to);
$second = $this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $from, $to);
$appointment = $this->appointment($doctor, $patient, $service, $address, $slot);
$body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame(2, $body['data']['waitlist_notified']);
$repo = static::getContainer()->get(WaitlistEntryRepository::class);
foreach ([$first['uuid'], $second['uuid']] as $uuid) {
$entry = $repo->findByUuid($uuid);
self::assertSame(WaitlistEntry::STATUS_NOTIFIED, $entry->getStatus());
self::assertNotNull($entry->getNotifiedAt());
self::assertSame(1, $entry->getNotifyCount());
}
}
/** سقف اطلاع‌رسانی، یک بازهٔ پرلغو را به منبع اسپم تبدیل نمی‌کند. */
public function testNotificationsStopAtTheCap(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$slot = time() + 2 * 86400;
$entry = $this->join($user, $patient, $service, $slot - 86400, $slot + 86400);
$repo = static::getContainer()->get(WaitlistEntryRepository::class);
for ($i = 0; $i < WaitlistEntry::MAX_NOTIFICATIONS + 2; $i++) {
$appointment = $this->appointment($doctor, $patient, $service, $address, $slot);
$this->notifier()->notifyForFreedSlot($appointment);
}
self::assertSame(
WaitlistEntry::MAX_NOTIFICATIONS,
$repo->findByUuid($entry['uuid'])->getNotifyCount(),
);
}
/** درخواستی که شعبهٔ دیگری را خواسته، برای این ظرفیت خبر نمی‌شود. */
public function testAnEntryForAnotherBranchIsNotNotified(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$otherBranch = DoctorAddress::forClinic((int) $address->getClinicId());
$otherBranch->setName('شعبهٔ دوم');
$this->em->persist($otherBranch);
$this->em->flush();
$slot = time() + 2 * 86400;
$this->join($user, $patient, $service, $slot - 86400, $slot + 86400, [
'branch_uuid' => $otherBranch->getUuid(),
]);
$appointment = $this->appointment($doctor, $patient, $service, $address, $slot);
self::assertSame(0, $this->notifier()->notifyForFreedSlot($appointment));
}
public function testDeletingAnEntryRemovesItFromTheList(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$entry = $this->join($user, $patient, $service, time() + 86400, time() + 4 * 86400);
$this->authJson('DELETE', "/api/v1/waitlist/{$entry['uuid']}", $user);
self::assertSame(200, $this->responseCode());
self::assertCount(0, $this->authJson('GET', '/api/v1/waitlist', $user)['data']);
}
public function testAnotherClinicCannotSeeOrDeleteTheEntry(): void
{
[$owner, $section, , , $patient] = $this->clinicWithPatient();
[$other] = $this->clinicWithPatient();
$service = $this->service($section);
$entry = $this->join($owner, $patient, $service, time() + 86400, time() + 4 * 86400);
self::assertCount(0, $this->authJson('GET', '/api/v1/waitlist', $other)['data']);
$this->authJson('DELETE', "/api/v1/waitlist/{$entry['uuid']}", $other);
self::assertSame(404, $this->responseCode());
}
// ── بخش روز ─────────────────────────────────────────────────────────────
/**
* ⭐ ترجیح روز باید در **تطبیق** اعمال شود، نه فقط ذخیره.
*
* ذخیره‌کردنِ «عصر» و بعد خبر دادن برای ساعت ۹ صبح، بدتر از نپرسیدن است: بیمار
* فکر می‌کند سیستم حرفش را شنیده.
*/
public function testAnEntryIsNotNotifiedOutsideItsPreferredDayPart(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$morning = $this->localHour(9);
$this->join($user, $patient, $service, $morning - 86400, $morning + 86400, [
'preferred_day_parts' => ['evening'],
]);
$appointment = $this->appointment($doctor, $patient, $service, $address, $morning);
self::assertSame(0, $this->notifier()->notifyForFreedSlot($appointment));
}
public function testAnEntryIsNotifiedInsideItsPreferredDayPart(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$evening = $this->localHour(19);
$this->join($user, $patient, $service, $evening - 86400, $evening + 86400, [
'preferred_day_parts' => ['evening'],
]);
$appointment = $this->appointment($doctor, $patient, $service, $address, $evening);
self::assertSame(1, $this->notifier()->notifyForFreedSlot($appointment));
}
/** نداشتن ترجیح یعنی «هر ساعتی» — نه «هیچ ساعتی». */
public function testAnEntryWithoutAPreferenceMatchesAnyHour(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$dawn = $this->localHour(5);
$this->join($user, $patient, $service, $dawn - 86400, $dawn + 86400);
$appointment = $this->appointment($doctor, $patient, $service, $address, $dawn);
self::assertSame(1, $this->notifier()->notifyForFreedSlot($appointment));
}
public function testAnUnknownDayPartIsRejected(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$this->authJson('POST', '/api/v1/waitlist', $user, [
'patient_uuid' => $patient->getUuid(),
'service_uuid' => $service->getUuid(),
'desired_from' => time() + 86400,
'desired_to' => time() + 3 * 86400,
'preferred_day_parts' => ['midnight'],
]);
self::assertSame(422, $this->responseCode());
}
// ── مرزها و چرخهٔ عمر ───────────────────────────────────────────────────
public function testARangeLongerThanNinetyDaysIsRejected(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$from = time() + 86400;
$this->authJson('POST', '/api/v1/waitlist', $user, [
'patient_uuid' => $patient->getUuid(),
'service_uuid' => $service->getUuid(),
'desired_from' => $from,
'desired_to' => $from + 91 * 86400,
]);
self::assertSame(422, $this->responseCode());
}
/**
* ⭐ ردیفِ منقضی از قبل هم در تطبیق نمی‌آمد؛ این پاکسازیِ **نمایش** است تا اپراتور
* بفهمد کدام انتظار هنوز زنده است.
*/
public function testExpiringClosesPassedEntriesAndLeavesLiveOnes(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$live = $this->join($user, $patient, $service, time() + 86400, time() + 5 * 86400);
$dead = $this->join($user, $this->extraPatient((int) $patient->getEntityId()), $service, time() + 86400, time() + 2 * 86400);
// بازهٔ ردیف دوم را به گذشته می‌بریم — API عمداً بازهٔ گذشته را نمی‌پذیرد.
$this->em->getConnection()->executeStatement(
'UPDATE waitlist_entries SET desired_from = ?, desired_to = ? WHERE uuid = ?',
[time() - 10 * 86400, time() - 86400, $dead['uuid']],
);
$expired = static::getContainer()->get(\App\Waitlist\Service\WaitlistExpirer::class)->expire();
self::assertSame(1, $expired);
// خواندن مستقیم از دیتابیس: `expire()` با SQL خام می‌نویسد، پس هر نقشهٔ هویتِ
// باز، نسخهٔ کهنه را برمی‌گرداند.
self::assertSame(WaitlistEntry::STATUS_EXPIRED, $this->statusOf($dead['uuid']));
self::assertSame(WaitlistEntry::STATUS_WAITING, $this->statusOf($live['uuid']));
}
/**
* ⭐ تبدیل باید **تنگ** باشد: ردیفِ خدمت دیگر نباید بسته شود، وگرنه بیمار برای
* چیزی که هنوز منتظرش است دیگر هرگز خبر نمی‌شود.
*/
public function testBookingConvertsOnlyTheMatchingEntry(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$other = $this->service($section, 'بوتاکس');
$start = time() + 2 * 86400;
$mine = $this->join($user, $patient, $service, $start - 86400, $start + 86400);
$unrelated = $this->join($user, $patient, $other, $start - 86400, $start + 86400);
$appointment = $this->appointment($doctor, $patient, $service, $address, $start);
$converter = static::getContainer()->get(\App\Waitlist\Service\WaitlistConverter::class);
self::assertSame(1, $converter->convertFor($appointment));
// اجرای دوباره چیزی را دوباره نمی‌بندد — تحویل دوبارهٔ پیام بی‌خطر است.
self::assertSame(0, $converter->convertFor($appointment));
$this->em->clear();
$repo = static::getContainer()->get(WaitlistEntryRepository::class);
self::assertSame(WaitlistEntry::STATUS_CONVERTED, $repo->findByUuid($mine['uuid'])->getStatus());
self::assertSame(WaitlistEntry::STATUS_WAITING, $repo->findByUuid($unrelated['uuid'])->getStatus());
}
private function statusOf(string $uuid): string
{
return (string) $this->em->getConnection()->fetchOne(
'SELECT status FROM waitlist_entries WHERE uuid = ?',
[$uuid],
);
}
/** ساعت محلیِ شعبه روی فردا — تست نباید به ساعت اجرا وابسته باشد. */
private function localHour(int $hour): int
{
return (new \DateTimeImmutable('tomorrow', new \DateTimeZone(DoctorAddress::DEFAULT_TIMEZONE)))
->setTime($hour, 0)
->getTimestamp();
}
}