createUser(['ROLE_USER', 'ROLE_CLINIC'])); $clinic->setName('کلینیک چنددستگاهی'); $this->em->persist($clinic); $this->em->flush(); $doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'دکتر ناظر'); $this->em->persist($doctor); $clinic->getDoctors()->add($doctor); $address = DoctorAddress::forClinic($clinic->getId()); $address->setName('شعبهٔ مرکزی'); $this->em->persist($address); $this->em->flush(); return [$clinic, $doctor, $address]; } private function device(Clinic $clinic, DoctorAddress $address, Doctor $supervisor, string $name, int $capacity = 1): ClinicResource { $type = new ResourceType('clinic', (int) $clinic->getId(), 'laser_' . bin2hex(random_bytes(3)), 'دستگاه لیزر'); $this->em->persist($type); $this->em->flush(); $resource = new ClinicResource($address, $type, $name); $resource->setSupervisor($supervisor); $resource->setCapacity($capacity); $this->em->persist($resource); $this->em->flush(); return $resource; } /** @return array{0: array, 1: int} */ private function book(ClinicResource $resource, int $start): array { return [$this->authJson('POST', '/api/v1/appointment', $this->createUser(['ROLE_USER']), [ 'resource_uuid' => $resource->getUuid(), 'slot_start' => $start, 'slot_end' => $start + 1800, 'for_self' => true, 'patient_national_code' => str_pad((string) random_int(0, 9_999_999_999), 10, '0', STR_PAD_LEFT), 'patient_gender' => 'female', ]), $start]; } private function futureSlot(): int { return time() + 86_400 + random_int(1, 5_000) * 60; } /** ✅ همان باگی که در دیتابیس واقعی فعال بود. */ public function testTwoDevicesUnderOneSupervisorCanBeBookedAtTheSameHour(): void { [$clinic, $doctor, $address] = $this->clinic(); $first = $this->device($clinic, $address, $doctor, 'لیزر CO2'); $second = $this->device($clinic, $address, $doctor, 'لیزر NdYAG'); $slot = $this->futureSlot(); $this->book($first, $slot); self::assertSame(201, $this->responseCode()); $res = $this->book($second, $slot)[0]; self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); } /** ❌ همان دستگاه، همان ساعت، دو بار — باید رد شود. */ public function testTheSameDeviceCannotBeDoubleBooked(): void { [$clinic, $doctor, $address] = $this->clinic(); $device = $this->device($clinic, $address, $doctor, 'لیزر تک'); $slot = $this->futureSlot(); $this->book($device, $slot); self::assertSame(201, $this->responseCode()); $res = $this->book($device, $slot)[0]; self::assertSame(409, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); } /** ⚠️ اتاق سه‌نفره باید سه رزرو بپذیرد و چهارمی را رد کند. */ public function testARoomWithCapacityThreeTakesThreeBookings(): void { [$clinic, $doctor, $address] = $this->clinic(); $room = $this->device($clinic, $address, $doctor, 'اتاق سه‌نفره', capacity: 3); $slot = $this->futureSlot(); for ($i = 0; $i < 3; $i++) { $res = $this->book($room, $slot)[0]; self::assertSame(201, $this->responseCode(), 'رزرو ' . ($i + 1) . ': ' . json_encode($res, JSON_UNESCAPED_UNICODE)); } $this->book($room, $slot); self::assertSame(409, $this->responseCode()); } /** رزرو منبع‌دار کلید پزشک نمی‌گیرد؛ تضمینش جای دیگری است. */ public function testAResourceBookingCarriesNoDoctorSlotKeyButDoesOccupy(): void { [$clinic, $doctor, $address] = $this->clinic(); $device = $this->device($clinic, $address, $doctor, 'لیزر کلیددار'); $slot = $this->futureSlot(); $res = $this->book($device, $slot)[0]; self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); $appointment = $this->em->getRepository(Appointment::class) ->findOneBy(['uuid' => $res['data']['data']['uuid']]); self::assertNotNull($appointment->getResource()); $occupancies = $this->em->getRepository(ResourceOccupancy::class) ->findBy(['appointmentId' => $appointment->getId()]); self::assertCount(1, $occupancies); self::assertSame(ResourceOccupancy::STATUS_BOOKED, $occupancies[0]->getStatus()); } /** * دو نوبتِ پشت‌سرهم روی یک دستگاه — هیچ هم‌پوشانی واقعی ندارند. * * سطل‌های اشغال پنج‌دقیقه‌ای‌اند، پس نوبتی که ۱۲:۳۵:۰۴ تمام می‌شود چهار ثانیه وارد * سطلِ ۱۲:۳۵ می‌شد و نوبت بعدی که از همان ثانیه شروع می‌کرد سطل مشترک پیدا * می‌کرد. این دقیقاً همان چیزی بود که در دیتابیس واقعی روی نوبت‌های ۷۶ و ۷۷ افتاد. */ public function testBackToBackBookingsOnOneDeviceDoNotCollide(): void { [$clinic, $doctor, $address] = $this->clinic(); $device = $this->device($clinic, $address, $doctor, 'لیزر پشت‌سرهم'); // عمداً روی مرز سطل ننشسته — همان «ثبت خارج از برنامه (ورود دستی ساعت)». $first = $this->futureSlot() + 4; $this->book($device, $first); self::assertSame(201, $this->responseCode()); $res = $this->book($device, $first + 1800)[0]; self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); } /** ساعتِ دستی روی مرز سطل می‌نشیند تا اشغال دقیق بماند. */ public function testAManualTimeIsAlignedToTheBucketGrid(): void { [$clinic, $doctor, $address] = $this->clinic(); $device = $this->device($clinic, $address, $doctor, 'لیزر ترازشونده'); $slot = $this->futureSlot() + 4; $res = $this->book($device, $slot)[0]; self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); $appointment = $this->em->getRepository(Appointment::class) ->findOneBy(['uuid' => $res['data']['data']['uuid']]); self::assertSame(0, $appointment->getSlotStart() % 300); self::assertSame(0, $appointment->getSlotEnd() % 300); } /** رزرو بدون منبع همچنان با کلید و قفل پزشک محافظت می‌شود. */ public function testBookingWithoutAResourceIsStillGuardedByTheDoctorKey(): void { [$clinic, $doctor] = $this->clinic(); $slot = $this->futureSlot(); $payload = static fn (): array => [ 'for_self' => true, 'patient_national_code' => str_pad((string) random_int(0, 9_999_999_999), 10, '0', STR_PAD_LEFT), 'patient_gender' => 'female', ]; $this->authJson('POST', '/api/v1/appointment', $this->createUser(['ROLE_USER']), $payload() + [ 'doctor_uuid' => $doctor->getUuid(), 'slot_start' => $slot, 'slot_end' => $slot + 1800, ]); self::assertSame(201, $this->responseCode()); $this->authJson('POST', '/api/v1/appointment', $this->createUser(['ROLE_USER']), $payload() + [ 'doctor_uuid' => $doctor->getUuid(), 'slot_start' => $slot, 'slot_end' => $slot + 1800, ]); self::assertSame(409, $this->responseCode()); } /** لغو باید صندلی را پس بدهد، وگرنه دستگاه برای همیشه در آن ساعت پر می‌ماند. */ public function testCancellingReleasesTheSeat(): void { [$clinic, $doctor, $address] = $this->clinic(); $device = $this->device($clinic, $address, $doctor, 'لیزر لغوشونده'); $slot = $this->futureSlot(); $res = $this->book($device, $slot)[0]; $uuid = $res['data']['data']['uuid']; $appointment = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]); $this->authJson('PATCH', '/api/v1/appointment/' . $uuid . '/status', $clinic->getUser(), [ 'status' => Appointment::STATUS_CANCELLED_BY_DOCTOR, 'version' => $appointment->getVersion(), ]); self::assertSame(200, $this->responseCode()); $this->em->clear(); $occupancies = $this->em->getRepository(ResourceOccupancy::class) ->findBy(['appointmentId' => $appointment->getId()]); self::assertSame(ResourceOccupancy::STATUS_RELEASED, $occupancies[0]->getStatus()); // و همان ساعت دوباره قابل رزرو است. $this->book($device, $slot); self::assertSame(201, $this->responseCode()); } }