Files
clinicpro/tests/Appointment/ServiceSlotExcludeSelfTest.php
T
hamedandClaude Opus 5 b784124b7e feat(booking): let service slot search exclude the appointment being moved
findBusyIntervals() and getServiceStartTimes() gain an optional
excludeAppointmentId, mirroring isSlotTaken($doctor, $start, $end, $excludeId)
which already had it. Without it an appointment being rescheduled sees itself as
busy, so its current time never appears among the candidates and "same hour,
different service" is impossible.

The parameter is optional with a null default and only affects the service-mode
path; no existing call site changes behaviour. SlotModeFrozenTest caught the
signature change immediately while both response contracts stayed green, so the
signature fixture was updated once with a written rationale, as its own header
permits.

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>
2026-07-30 12:51:29 +03:30

152 lines
6.5 KiB
PHP

<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\Appointment\Repository\AppointmentRepository;
use App\Appointment\Service\SlotCalculatorService;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* جابه‌جایی خودِ یک نوبت: بازهٔ همان نوبت نباید اشغال حساب شود، وگرنه زمان فعلی‌اش
* هرگز در فهرست زمان‌های ممکن نمی‌آید و کاربر نمی‌تواند «همان ساعت، سرویس متفاوت» را
* ثبت کند.
*/
class ServiceSlotExcludeSelfTest extends ApiTestCase
{
/** @return array{0:Doctor,1:string,2:int} */
private function serviceDoctor(): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر جابه‌جایی');
$this->em->persist($doctor);
$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' => '15:00', 'end_time' => '17:00',
'duration_per_patient' => 20, 'location_id' => 1,
]]],
]);
$schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SERVICE, 'buffer_minutes' => 0]);
$this->em->persist($schedule);
$this->em->flush();
return [$doctor, $date, (int) strtotime($date . ' 15:00')];
}
private function confirmedAppointment(Doctor $doctor, int $start, int $minutes): Appointment
{
$patient = $this->createUser(['ROLE_USER']);
$appt = $this->newAppointment($doctor, $patient, $start, $start + $minutes * 60);
$appt->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($appt);
$this->em->flush();
return $appt;
}
private function calc(): SlotCalculatorService
{
return static::getContainer()->get(SlotCalculatorService::class);
}
// ── ✅ موفق ──────────────────────────────────────────────────────────────
public function testOwnIntervalReappearsWhenExcluded(): void
{
[$doctor, $date, $start] = $this->serviceDoctor();
$appt = $this->confirmedAppointment($doctor, $start, 30);
$without = array_column($this->calc()->getServiceStartTimes($doctor, $date, 30), 'start_time');
self::assertNotContains('15:00', $without, 'بدون exclude، نوبت خودش را اشغال می‌بیند');
$with = array_column(
$this->calc()->getServiceStartTimes($doctor, $date, 30, null, false, $appt->getId()),
'start_time',
);
self::assertContains('15:00', $with, 'با exclude، زمان فعلیِ خود نوبت در فهرست می‌آید');
}
public function testExcludingAllowsALongerDurationAtTheSameStart(): void
{
[$doctor, $date, $start] = $this->serviceDoctor();
$appt = $this->confirmedAppointment($doctor, $start, 30);
// همان ساعت ۱۵:۰۰ ولی سرویس بلندتر (۶۰ دقیقه) — سناریوی «سرویس را عوض کردم».
$times = array_column(
$this->calc()->getServiceStartTimes($doctor, $date, 60, null, false, $appt->getId()),
'start_time',
);
self::assertContains('15:00', $times);
}
// ── ❌ خطا / مسیر نادرست ─────────────────────────────────────────────────
public function testOtherAppointmentsStayBlockingWhenOneIsExcluded(): void
{
[$doctor, $date, $start] = $this->serviceDoctor();
$mine = $this->confirmedAppointment($doctor, $start, 30);
$others = $this->confirmedAppointment($doctor, $start + 30 * 60, 30); // ۱۵:۳۰
$times = array_column(
$this->calc()->getServiceStartTimes($doctor, $date, 30, null, false, $mine->getId()),
'start_time',
);
self::assertContains('15:00', $times, 'نوبت خودم آزاد شد');
self::assertNotContains('15:30', $times, 'نوبت دیگری همچنان اشغال است');
self::assertNotNull($others->getId());
}
// ── ⚠️ مرزی ──────────────────────────────────────────────────────────────
public function testNullExcludeIdBehavesExactlyLikeBefore(): void
{
[$doctor, $date, $start] = $this->serviceDoctor();
$this->confirmedAppointment($doctor, $start, 30);
$implicit = $this->calc()->getServiceStartTimes($doctor, $date, 30);
$explicit = $this->calc()->getServiceStartTimes($doctor, $date, 30, null, false, null);
self::assertSame($implicit, $explicit, 'پیش‌فرض null نباید رفتار موجود را عوض کند');
}
public function testRepositoryExcludeIdFiltersOnlyThatRow(): void
{
[$doctor, , $start] = $this->serviceDoctor();
$appt = $this->confirmedAppointment($doctor, $start, 30);
$repo = static::getContainer()->get(AppointmentRepository::class);
$from = $start - 3600;
$to = $start + 7200;
self::assertNotEmpty($repo->findBusyIntervals($doctor, $from, $to));
self::assertSame([], $repo->findBusyIntervals($doctor, $from, $to, $appt->getId()));
}
public function testExcludingAReserveEntryChangesNothing(): void
{
[$doctor, $date, $start] = $this->serviceDoctor();
// نوبت رزرو هیچ بازه‌ای اشغال نمی‌کند، پس exclude کردنش هم بی‌اثر است.
$patient = $this->createUser(['ROLE_USER']);
$dayMid = (int) strtotime($date . ' 00:00:00');
$reserve = $this->newAppointment($doctor, $patient, $dayMid, $dayMid);
$reserve->rescheduleTo($dayMid, $dayMid, true);
$this->em->persist($reserve);
$this->em->flush();
$before = $this->calc()->getServiceStartTimes($doctor, $date, 30);
$after = $this->calc()->getServiceStartTimes($doctor, $date, 30, null, false, $reserve->getId());
self::assertSame($before, $after);
self::assertSame($start, (int) strtotime($date . ' 15:00'));
}
}