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