createUser(['ROLE_DOCTOR']); $doctor = new Doctor($owner, 'دکتر ویرایش'); $this->em->persist($doctor); $this->em->flush(); $date = date('Y-m-d', strtotime('+2 days')); $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): ServiceItem { $item = new ServiceItem($section, $name, 0); $item->setDurationMinutes($minutes)->setBookable(true); $this->em->persist($item); $this->em->flush(); return $item; } /** نوبت زمان‌دارِ تأییدشده با مدت دلخواه. */ private function appointment(Doctor $doctor, int $minutes, array $items = []): Appointment { $patient = $this->createUser(['ROLE_USER']); $start = (int) strtotime(date('Y-m-d', strtotime('+2 days')) . ' 10:00'); $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 patch(Appointment $appt, \App\Auth\Entity\User $actor, array $body): array { return $this->authJson('PATCH', '/api/v1/appointment/' . $appt->getUuid(), $actor, $body + [ 'version' => $appt->getVersion(), ]); } private function at(string $time): int { return (int) strtotime(date('Y-m-d', strtotime('+2 days')) . ' ' . $time); } // ── ✅ موفق ────────────────────────────────────────────────────────────── public function testReplacingServicesRecomputesTheStoredDuration(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE, buffer: 10); $face = $this->service($section, 'لیزر صورت', 20); $bikini = $this->service($section, 'لیزر بیکینی', 15); $appt = $this->appointment($doctor, 20, [$face]); $res = $this->patch($appt, $owner, [ 'slot_start' => $this->at('11:00'), 'slot_end' => $this->at('11:35'), // ۳۵ = ۲۰ + ۱۵ 'service_item_uuids' => [$face->getUuid(), $bikini->getUuid()], ]); self::assertSame(200, $this->responseCode()); self::assertSame(35, $res['data']['data']['service_total_minutes']); self::assertSame(10, $res['data']['data']['service_buffer_minutes']); self::assertCount(2, $res['data']['data']['service_items']); } public function testTheLegacySingleServiceFollowsTheList(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $old = $this->service($section, 'سرویس قدیمی', 20); $new = $this->service($section, 'سرویس جدید', 30); $appt = $this->appointment($doctor, 20, [$old]); $res = $this->patch($appt, $owner, [ 'slot_start' => $this->at('11:00'), 'slot_end' => $this->at('11:30'), 'service_item_uuids' => [$new->getUuid()], ]); self::assertSame(200, $this->responseCode()); self::assertSame($new->getUuid(), $res['data']['data']['service_item']['uuid'], 'ستون تکی باید با فهرست هم‌گام شود، وگرنه لیست‌ها نام سرویس قدیمی را نشان می‌دهند'); } public function testNoteOnlyPatchSkipsDurationValidation(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $item = $this->service($section, 'مشاوره', 30); // نوبتی که مدتش با سرویسش نمی‌خواند (دادهٔ قدیمی). $appt = $this->appointment($doctor, 20, [$item]); $this->patch($appt, $owner, ['note' => 'یادداشت تازه']); self::assertSame(200, $this->responseCode(), 'PATCH بدون slot نباید مدت را بسنجد'); } public function testReserveEntryStoresServicesWithoutDurationCheck(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $item = $this->service($section, 'لیزر', 45); $appt = $this->appointment($doctor, 30, [$item]); $midnight = $this->at('00:00'); $res = $this->patch($appt, $owner, [ 'slot_start' => $midnight, 'slot_end' => $midnight, // رزرو: start == end 'is_reserve' => true, 'service_item_uuids' => [$item->getUuid()], ]); self::assertSame(200, $this->responseCode(), 'نوبت رزرو از بررسی مدت معاف است'); self::assertTrue($res['data']['data']['is_reserve']); self::assertSame(45, $res['data']['data']['service_total_minutes'], 'مدت برای تبدیل بعدیِ رزرو به نوبت زمان‌دار ذخیره می‌شود'); } public function testReserveConvertsToATimedAppointmentThroughPatch(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $item = $this->service($section, 'لیزر', 30); $patient = $this->createUser(['ROLE_USER']); $midnight = $this->at('00:00'); $reserve = $this->newAppointment($doctor, $patient, $midnight, $midnight); $reserve->rescheduleTo($midnight, $midnight, true); $reserve->replaceServiceItems([$item]); $reserve->transitionTo(Appointment::STATUS_CONFIRMED); $this->em->persist($reserve); $this->em->flush(); $res = $this->patch($reserve, $owner, [ 'slot_start' => $this->at('12:00'), 'slot_end' => $this->at('12:30'), 'is_reserve' => false, ]); self::assertSame(200, $this->responseCode(), 'تبدیل رزرو با همان PATCH انجام می‌شود — endpoint جدید لازم نیست'); self::assertFalse($res['data']['data']['is_reserve']); self::assertSame(30, $res['data']['data']['service_total_minutes']); } // ── ❌ خطا ─────────────────────────────────────────────────────────────── public function testMismatchedDurationIsRejectedWithTheCorrectMinutes(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $item = $this->service($section, 'لیزر', 45); $appt = $this->appointment($doctor, 45, [$item]); $res = $this->patch($appt, $owner, [ 'slot_start' => $this->at('11:00'), 'slot_end' => $this->at('11:20'), // ۲۰ دقیقه به‌جای ۴۵ ]); self::assertSame(422, $this->responseCode()); self::assertSame(ErrorCodes::ERR_APPOINTMENT_003, $res['errors'][0]['code']); self::assertSame('slot_end', $res['errors'][0]['field']); self::assertStringContainsString('45', $res['errors'][0]['message'], 'پیام باید مدت درست را بگوید'); } public function testForeignServiceIsRejected(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $mine = $this->service($section, 'سرویس خودم', 30); $appt = $this->appointment($doctor, 30, [$mine]); [, $otherDoctor, $otherSection] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $foreign = $this->service($otherSection, 'سرویس بیگانه', 30); self::assertNotNull($otherDoctor->getId()); $res = $this->patch($appt, $owner, [ 'slot_start' => $this->at('11:00'), 'slot_end' => $this->at('11:30'), 'service_item_uuids' => [$foreign->getUuid()], ]); self::assertSame(422, $this->responseCode()); self::assertSame('سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', $res['errors'][0]['message']); } // ── ⚠️ مرزی — خط سرخ ───────────────────────────────────────────────────── public function testSlotModeAppointmentKeepsAcceptingAnyDuration(): void { [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SLOT); $item = $this->service($section, 'سرویس اسلاتی', 45); $appt = $this->appointment($doctor, 20, [$item]); $res = $this->patch($appt, $owner, [ 'slot_start' => $this->at('11:00'), 'slot_end' => $this->at('11:20'), // با مدت سرویس نمی‌خواند — و باید بخورد ]); self::assertSame(200, $this->responseCode(), 'حالت اسلاتی نباید هیچ اعتبارسنجی سرویسی بگیرد'); self::assertNull($res['data']['data']['service_total_minutes'], 'ستون سرویسی در حالت اسلاتی null می‌ماند'); } public function testServiceModeAppointmentWithoutAnyServiceIsNotBlocked(): void { [$owner, $doctor] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); $appt = $this->appointment($doctor, 20); // دادهٔ قدیمی: بدون سرویس $res = $this->patch($appt, $owner, [ 'slot_start' => $this->at('11:00'), 'slot_end' => $this->at('11:20'), ]); self::assertSame(200, $this->responseCode(), 'نوبت سرویسی بدون سرویس نباید قفل شود'); self::assertNull($res['data']['data']['service_total_minutes']); } }