Files
clinicpro/tests/Appointment/ServiceBookingCalculatorTest.php
hamedandClaude Opus 5 6afc5c090e refactor(booking): extract ServiceBookingCalculator from the controller
"Allowed duration of a service combination" lived inside
AppointmentController::serviceSlots(). Three upcoming callers need the same
computation (PATCH duration validation, service-aware reschedule, reserve
conversion); copying it would mean four variants with four different edge-case
behaviours.

The extraction is behaviour-preserving: BaseController::error() and
ExceptionSubscriber emit an identical envelope, so returning $this->error() was
replaced by throwing AppException with the same code/message/field.

Tenant ownership now goes through TenantOwnershipChecker::belongsToPair() (the
documented single point) instead of an inline section pair comparison. The repo
property is named itemRepo on purpose: TenantLookupInventoryTest only counts
recognised property names, so any other name would slip past the safety net.

The naive duration sum is kept deliberately — switching to solo/additional
minutes is task 04 and changes one line here.

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:41:13 +03:30

240 lines
10 KiB
PHP

<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\Appointment\Service\ServiceBookingCalculator;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Tests\ApiTestCase;
/**
* `ServiceBookingCalculator` — تنها مرجعِ «مدتِ مجازِ یک ترکیب سرویس».
*
* منطق پیش‌تر داخل `AppointmentController::serviceSlots()` بود؛ این تست تضمین می‌کند
* استخراجش رفتار را عوض نکرده و چهار مسیر خطا با همان کد/پیام/فیلد قبلی برمی‌گردند.
*/
class ServiceBookingCalculatorTest extends ApiTestCase
{
private function calculator(): ServiceBookingCalculator
{
return static::getContainer()->get(ServiceBookingCalculator::class);
}
private function serviceModeDoctor(int $buffer = 0): Doctor
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر محاسبهٔ سرویس');
$this->em->persist($doctor);
$schedule = $this->newWeeklySchedule($doctor, [
'0' => ['sessions' => [[
'active' => true, 'start_time' => '09:00', 'end_time' => '17:00',
'duration_per_patient' => 20, 'location_id' => 1,
]]],
]);
$schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SERVICE, 'buffer_minutes' => $buffer]);
$this->em->persist($schedule);
$this->em->flush();
return $doctor;
}
private function slotModeDoctor(): Doctor
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر اسلاتی');
$this->em->persist($doctor);
$schedule = $this->newWeeklySchedule($doctor, ['0' => ['sessions' => []]]);
$schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SLOT]);
$this->em->persist($schedule);
$this->em->flush();
return $doctor;
}
private function service(Doctor $doctor, string $name, ?int $minutes, bool $bookable = true): ServiceItem
{
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش ' . $name);
$this->em->persist($section);
$item = new ServiceItem($section, $name, 0);
$item->setDurationMinutes($minutes)->setBookable($bookable);
$this->em->persist($item);
$this->em->flush();
return $item;
}
// ── ✅ مسیر موفق ─────────────────────────────────────────────────────────
public function testSumsDurationsAndCarriesBuffer(): void
{
$doctor = $this->serviceModeDoctor(buffer: 10);
$face = $this->service($doctor, 'لیزر صورت', 20);
$bikini = $this->service($doctor, 'لیزر بیکینی', 15);
$result = $this->calculator()->calculate($doctor, null, [$face->getUuid(), $bikini->getUuid()]);
self::assertSame(35, $result->totalMinutes);
self::assertSame(10, $result->bufferMinutes, 'بافر از meta برنامه می‌آید');
self::assertSame([], $result->warnings);
self::assertCount(2, $result->serviceItems);
}
public function testEndForExcludesBuffer(): void
{
$doctor = $this->serviceModeDoctor(buffer: 10);
$item = $this->service($doctor, 'مشاوره', 30);
$result = $this->calculator()->calculate($doctor, null, [$item->getUuid()]);
$start = 1_800_000_000;
self::assertSame($start + 30 * 60, $result->endFor($start), 'بافر جزو مدت نوبت نیست');
}
public function testSecretaryOverrideWinsWithoutTouchingTheService(): void
{
$doctor = $this->serviceModeDoctor();
$item = $this->service($doctor, 'تزریق', 20);
$result = $this->calculator()->calculate(
$doctor, null, [$item->getUuid()], [$item->getUuid() => 45],
);
self::assertSame(45, $result->totalMinutes);
$this->em->refresh($item);
self::assertSame(20, $item->getDurationMinutes(), 'مدت پیش‌فرض سرویس نباید عوض شود');
}
public function testIsServiceModeReadsBookingModeOnly(): void
{
self::assertTrue($this->calculator()->isServiceMode($this->serviceModeDoctor(), null));
self::assertFalse($this->calculator()->isServiceMode($this->slotModeDoctor(), null));
}
// ── ❌ مسیرهای خطا — همان کد/پیام/فیلد قبلی کنترلر ────────────────────────
public function testServiceWithoutDurationIsRejected(): void
{
$doctor = $this->serviceModeDoctor();
$item = $this->service($doctor, 'بدون مدت', null);
try {
$this->calculator()->calculate($doctor, null, [$item->getUuid()]);
self::fail('سرویس بدون مدت باید رد شود');
} catch (AppException $e) {
self::assertSame(ErrorCodes::ERR_VALIDATION_001, $e->getErrorCode());
self::assertSame('مدت سرویس تعریف نشده است', $e->getMessage());
self::assertSame('service_item_uuids', $e->getField());
self::assertSame(422, $e->getHttpStatus());
}
}
public function testInactiveServiceIsRejectedByDefault(): void
{
$doctor = $this->serviceModeDoctor();
$item = $this->service($doctor, 'غیرفعال', 30, bookable: false);
try {
$this->calculator()->calculate($doctor, null, [$item->getUuid()]);
self::fail('سرویس غیرفعال باید رد شود');
} catch (AppException $e) {
self::assertSame('این سرویس برای نوبت‌دهی فعال نیست', $e->getMessage());
self::assertSame(422, $e->getHttpStatus());
}
}
public function testForeignTenantServiceLeaksNothing(): void
{
$mine = $this->serviceModeDoctor();
$other = $this->serviceModeDoctor();
$foreign = $this->service($other, 'سرویس محیط دیگر', 30);
try {
$this->calculator()->calculate($mine, null, [$foreign->getUuid()]);
self::fail('سرویس محیط دیگر باید رد شود');
} catch (AppException $e) {
// پیام نباید وجود، فعال‌بودن یا مدت سرویس را لو بدهد.
self::assertSame('سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', $e->getMessage());
self::assertStringNotContainsString('سرویس محیط دیگر', $e->getMessage());
}
}
public function testUnknownUuidIsIndistinguishableFromForeignService(): void
{
$doctor = $this->serviceModeDoctor();
try {
$this->calculator()->calculate($doctor, null, ['00000000-0000-4000-8000-000000000000']);
self::fail('uuid ناموجود باید رد شود');
} catch (AppException $e) {
self::assertSame('سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', $e->getMessage());
}
}
// ── ⚠️ مرزی ──────────────────────────────────────────────────────────────
public function testAllowInactiveTurnsRejectionIntoWarning(): void
{
$doctor = $this->serviceModeDoctor();
$item = $this->service($doctor, 'لیزر بازنشسته', 30, bookable: false);
$result = $this->calculator()->calculate($doctor, null, [$item->getUuid()], allowInactive: true);
self::assertSame(30, $result->totalMinutes, 'نوبت موجود باید قابل جابه‌جایی بماند');
self::assertCount(1, $result->warnings);
self::assertStringContainsString('لیزر بازنشسته', $result->warnings[0]);
}
public function testAllowInactiveStillRejectsMissingDuration(): void
{
$doctor = $this->serviceModeDoctor();
$item = $this->service($doctor, 'غیرفعال و بی‌مدت', null, bookable: false);
$this->expectException(AppException::class);
$this->expectExceptionMessage('مدت سرویس تعریف نشده است');
$this->calculator()->calculate($doctor, null, [$item->getUuid()], allowInactive: true);
}
public function testEmptySelectionYieldsZeroMinutes(): void
{
$doctor = $this->serviceModeDoctor(buffer: 5);
$result = $this->calculator()->calculate($doctor, null, []);
// صفر یعنی «هیچ سرویسی انتخاب نشده»؛ رد کردنِ فهرست خالی کارِ کنترلر است، نه
// این کلاس — تا مسیر PATCH بتواند نوبتِ بدون سرویس را بدون خطا رد کند.
self::assertSame(0, $result->totalMinutes);
self::assertSame(5, $result->bufferMinutes);
}
public function testNonPositiveOverrideFallsBackToServiceDuration(): void
{
$doctor = $this->serviceModeDoctor();
$item = $this->service($doctor, 'مشاوره', 25);
foreach ([0, -10] as $bad) {
$result = $this->calculator()->calculate(
$doctor, null, [$item->getUuid()], [$item->getUuid() => $bad],
);
self::assertSame(25, $result->totalMinutes, 'override نامعتبر نادیده گرفته می‌شود');
}
}
public function testDoctorWithoutScheduleFallsBackToDefaultMeta(): void
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر بی‌برنامه');
$this->em->persist($doctor);
$this->em->flush();
self::assertFalse($this->calculator()->isServiceMode($doctor, null), 'پیش‌فرض slot است');
self::assertSame(0, $this->calculator()->bufferMinutes($doctor, null));
}
}