createUser(['ROLE_DOCTOR']); $doctor = new Doctor($owner, 'دکتر جابه‌جایی سرویسی'); $this->em->persist($doctor); $this->em->flush(); $date = date('Y-m-d', strtotime(self::DAY_OFFSET)); $dayKey = (string) (((int) date('w', strtotime($date)) + 1) % 7); $schedule = $this->newWeeklySchedule($doctor, [ $dayKey => ['sessions' => [[ 'active' => true, 'start_time' => '09:00', 'end_time' => '18:00', 'duration_per_patient' => 20, 'location_id' => 1, ]]], ]); $schedule->setMeta(['booking_mode' => $mode, 'buffer_minutes' => $buffer]); $this->em->persist($schedule); $section = new ServiceSection('doctor', $doctor->getId(), 'بخش جابه‌جایی'); $this->em->persist($section); $this->em->flush(); return [$owner, $doctor, $section]; } private function service(ServiceSection $section, string $name, int $minutes, bool $bookable = true): ServiceItem { $item = new ServiceItem($section, $name, 0); $item->setDurationMinutes($minutes)->setBookable($bookable); $this->em->persist($item); $this->em->flush(); return $item; } private function at(string $time): int { return (int) strtotime(date('Y-m-d', strtotime(self::DAY_OFFSET)) . ' ' . $time); } private function appointment(Doctor $doctor, string $time, int $minutes, array $items = []): Appointment { $patient = $this->createUser(['ROLE_USER']); $start = $this->at($time); $appt = $this->newAppointment($doctor, $patient, $start, $start + $minutes * 60); $appt->transitionTo(Appointment::STATUS_CONFIRMED); if ($items !== []) { $appt->replaceServiceItems($items); } $this->em->persist($appt); $this->em->flush(); return $appt; } private function move(Appointment $appt, \App\Auth\Entity\User $actor, array $body): array { return $this->authJson('POST', '/api/v1/appointment/' . $appt->getUuid() . '/service-reschedule', $actor, $body); } // ── ✅ موفق ────────────────────────────────────────────────────────────── public function testMovingKeepsDurationWithoutTheClientSendingIt(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $item = $this->service($section, 'لیزر', 30); $appt = $this->appointment($doctor, '10:00', 30, [$item]); $res = $this->move($appt, $owner, ['start' => $this->at('14:00')]); self::assertSame(200, $this->responseCode()); self::assertSame(30, $res['data']['total_duration_minutes']); self::assertSame($this->at('14:00'), $res['data']['slot_start']); self::assertSame($this->at('14:30'), $res['data']['slot_end'], 'مدت را سرور حساب کرد'); } public function testDroppingAServiceShrinksTheDurationAutomatically(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $face = $this->service($section, 'صورت', 20); $bikini = $this->service($section, 'بیکینی', 15); $appt = $this->appointment($doctor, '10:00', 35, [$face, $bikini]); $res = $this->move($appt, $owner, [ 'start' => $this->at('14:00'), 'service_item_uuids' => [$face->getUuid()], ]); self::assertSame(200, $this->responseCode()); self::assertSame(20, $res['data']['total_duration_minutes'], 'کلاینت هیچ عددی نفرستاد'); self::assertSame($this->at('14:20'), $res['data']['slot_end']); } public function testCurrentTimeIsOfferedBackToTheAppointmentItself(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $short = $this->service($section, 'کوتاه', 30); $long = $this->service($section, 'بلند', 60); $appt = $this->appointment($doctor, '10:00', 30, [$short]); // همان ساعت ۱۰:۰۰، سرویس بلندتر — بدون exclude، نوبت خودش را اشغال می‌دید. $res = $this->move($appt, $owner, [ 'start' => $this->at('10:00'), 'service_item_uuids' => [$long->getUuid()], ]); self::assertSame(200, $this->responseCode()); self::assertSame(60, $res['data']['total_duration_minutes']); self::assertSame($this->at('11:00'), $res['data']['slot_end']); } public function testInactiveServiceOfAnExistingAppointmentYieldsAWarningNotAnError(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $item = $this->service($section, 'لیزر بازنشسته', 30); $appt = $this->appointment($doctor, '10:00', 30, [$item]); // کلینیک سرویس را غیرفعال می‌کند؛ نوبت موجود باید همچنان جابه‌جا شود. $item->setBookable(false); $this->em->flush(); $res = $this->move($appt, $owner, ['start' => $this->at('14:00')]); self::assertSame(200, $this->responseCode(), 'نوبت موجود نباید برای همیشه قفل شود'); self::assertCount(1, $res['data']['warnings']); self::assertStringContainsString('لیزر بازنشسته', $res['data']['warnings'][0]); } // ── ❌ خطا ─────────────────────────────────────────────────────────────── public function testSlotModeAppointmentIsRejected(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SLOT); $item = $this->service($section, 'سرویس', 30); $appt = $this->appointment($doctor, '10:00', 20, [$item]); $res = $this->move($appt, $owner, ['start' => $this->at('14:00')]); self::assertSame(422, $this->responseCode()); self::assertSame(ErrorCodes::ERR_APPOINTMENT_004, $res['errors'][0]['code']); } public function testTimeOutsideTheOfferedListIsRejected(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $item = $this->service($section, 'لیزر', 30); $appt = $this->appointment($doctor, '10:00', 30, [$item]); // ۰۳:۰۰ بیرون از شیفت ۰۹:۰۰–۱۸:۰۰ است. `isSlotTaken` تنها این را نمی‌گرفت. $res = $this->move($appt, $owner, ['start' => $this->at('03:00')]); self::assertSame(422, $this->responseCode()); self::assertSame(ErrorCodes::ERR_APPOINTMENT_001, $res['errors'][0]['code']); self::assertSame('start', $res['errors'][0]['field']); } public function testReserveEntryIsRejectedWithAPointerToPatch(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $item = $this->service($section, 'لیزر', 30); $midnight = $this->at('00:00'); $patient = $this->createUser(['ROLE_USER']); $reserve = $this->newAppointment($doctor, $patient, $midnight, $midnight); $reserve->rescheduleTo($midnight, $midnight, true); $reserve->replaceServiceItems([$item]); $this->em->persist($reserve); $this->em->flush(); $res = $this->move($reserve, $owner, ['start' => $this->at('14:00')]); self::assertSame(422, $this->responseCode()); self::assertStringContainsString('ویرایش نوبت', $res['errors'][0]['message']); } public function testMissingStartIsRejected(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $item = $this->service($section, 'لیزر', 30); $appt = $this->appointment($doctor, '10:00', 30, [$item]); $res = $this->move($appt, $owner, []); self::assertSame(422, $this->responseCode()); self::assertSame('start', $res['errors'][0]['field']); } public function testForeignServiceIsRejected(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $mine = $this->service($section, 'خودم', 30); $appt = $this->appointment($doctor, '10:00', 30, [$mine]); [, , $otherSection] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $foreign = $this->service($otherSection, 'بیگانه', 30); $res = $this->move($appt, $owner, [ 'start' => $this->at('14:00'), 'service_item_uuids' => [$foreign->getUuid()], ]); self::assertSame(422, $this->responseCode()); self::assertSame('سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', $res['errors'][0]['message']); } public function testAddingAnInactiveServiceExplicitlyIsRejected(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $active = $this->service($section, 'فعال', 30); $inactive = $this->service($section, 'غیرفعال', 20, bookable: false); $appt = $this->appointment($doctor, '10:00', 30, [$active]); $res = $this->move($appt, $owner, [ 'start' => $this->at('14:00'), 'service_item_uuids' => [$active->getUuid(), $inactive->getUuid()], ]); self::assertSame(422, $this->responseCode(), 'افزودن سرویس غیرفعال تازه مجاز نیست'); self::assertSame('این سرویس برای نوبت‌دهی فعال نیست', $res['errors'][0]['message']); } // ── ⚠️ مرزی ────────────────────────────────────────────────────────────── public function testPastStartIsRejected(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $item = $this->service($section, 'لیزر', 30); $appt = $this->appointment($doctor, '10:00', 30, [$item]); $res = $this->move($appt, $owner, ['start' => time() - 3600]); self::assertSame(422, $this->responseCode()); self::assertSame('زمان انتخابی در گذشته است', $res['errors'][0]['message']); } public function testAppointmentWithoutAnyServiceIsRejectedWithAClearReason(): void { [$owner, $doctor] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $appt = $this->appointment($doctor, '10:00', 30); $res = $this->move($appt, $owner, ['start' => $this->at('14:00')]); self::assertSame(422, $this->responseCode()); self::assertStringContainsString('سرویسی ندارد', $res['errors'][0]['message']); } public function testStaleVersionYieldsConflict(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $item = $this->service($section, 'لیزر', 30); $appt = $this->appointment($doctor, '10:00', 30, [$item]); $res = $this->move($appt, $owner, [ 'start' => $this->at('14:00'), 'version' => $appt->getVersion() + 5, ]); self::assertSame(409, $this->responseCode()); self::assertSame(ErrorCodes::ERR_CONFLICT_001, $res['errors'][0]['code']); } public function testAnotherUsersAppointmentIsForbidden(): void { [, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $item = $this->service($section, 'لیزر', 30); $appt = $this->appointment($doctor, '10:00', 30, [$item]); $stranger = $this->createUser(['ROLE_USER']); $this->move($appt, $stranger, ['start' => $this->at('14:00')]); self::assertSame(403, $this->responseCode()); } public function testUnknownAppointmentIsNotFound(): void { [$owner] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $this->authJson( 'POST', '/api/v1/appointment/00000000-0000-4000-8000-000000000000/service-reschedule', $owner, ['start' => $this->at('14:00')], ); self::assertSame(404, $this->responseCode()); } }