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 $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(); } }