POST /api/v1/appointment/{uuid}/service-reschedule takes only a start time and
derives the length from the appointment's services. PATCH also validates the
duration, but the client must already know the correct slot_end; not needing that
knowledge is what lets the edit form drop its manual time inputs.
The start must be a member of getServiceStartTimes(), not merely free:
isSlotTaken() reports collisions with other appointments, while the offered list
also applies shift bounds, holidays, date overrides, the booking window and the
buffer. Without it a secretary could park an appointment at 3am.
forManagement comes from canManageContext(), not canManage(): a patient moving
their own appointment must still respect the public booking window.
Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
311 lines
13 KiB
PHP
311 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Appointment;
|
|
|
|
use App\Appointment\Entity\Appointment;
|
|
use App\Appointment\Entity\WeeklySchedule;
|
|
use App\ClinicService\Entity\ServiceItem;
|
|
use App\ClinicService\Entity\ServiceSection;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Shared\Constant\ErrorCodes;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* `POST /api/v1/appointment/{uuid}/service-reschedule` — کلاینت مدت نمیفرستد.
|
|
*
|
|
* تفاوتش با `PATCH`: آنجا کلاینت باید `slot_end` درست را از قبل بداند؛ اینجا فقط
|
|
* `start` میدهد. همین است که ورودی دستیِ ساعت را از فرم ویرایش حذف میکند.
|
|
*/
|
|
class ServiceRescheduleTest extends ApiTestCase
|
|
{
|
|
private const DAY_OFFSET = '+2 days';
|
|
|
|
/** @return array{0:\App\Auth\Entity\User,1:Doctor,2:ServiceSection} */
|
|
private function doctorInMode(string $mode, int $buffer = 0): array
|
|
{
|
|
$owner = $this->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());
|
|
}
|
|
}
|