Files
clinicpro/tests/Appointment/SlotModeFrozenTest.php
T
hamedandClaude Opus 5 c37c0cee39 test(booking): freeze slot-mode contract before service-mode work
Adds SlotModeFrozenTest (#[Group('slot-mode-frozen')]) locking three things
against the multi-resource booking phase:
  - GET /api/v1/appointment-slots response shape
  - GET /api/v1/appointment-settings/month-availability/{uuid} response shape
  - public method signatures of SlotCalculatorService

Fixtures are structural, not raw snapshots: a fixed past date is rejected by
isWithinBookingWindow so an empty snapshot would prove nothing. Instead a
deterministic schedule on a computed near-future date, with epoch/uuid values
normalized to placeholders. What stays locked is the contract itself: keys,
ordering, types and local times.

No production code touched.

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green, 3 tests)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:31:57 +03:30

238 lines
9.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\Appointment\Service\SlotCalculatorService;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
use PHPUnit\Framework\Attributes\Group;
/**
* قرارداد نوبت‌دهی اسلاتی — منجمد.
*
* حالت `booking_mode = slot` منطق تولیدیِ زنده است و در فاز موتور چندمنبعی
* ({@see docs/new_feture/taskes/_shared/red-lines.md}) هیچ تسکی اجازهٔ تغییرش را ندارد.
* این تست سه چیز را قفل می‌کند:
*
* ۱. شکل پاسخ `GET /api/v1/appointment-slots`
* ۲. شکل پاسخ `GET /api/v1/appointment-settings/month-availability/{doctorUuid}`
* ۳. امضای متدهای عمومی `SlotCalculatorService`
*
* ⛔ سه فایل `fixtures/slot-mode-*` و `fixtures/month-availability-*` و
* `fixtures/slot-calculator-signatures.php` پس از تسک ۰۰ read-only اند. اگر این تست
* قرمز شد، **کد باید برگردد، نه fixture**.
*
* چرا fixture ساختاری است و نه snapshot خام: `buildAllSessions()` تاریخ گذشته را رد
* می‌کند (`isWithinBookingWindow`) و `getAllSlotsWithAvailability()` مقدار
* `is_available` را با `time()` می‌سنجد. پس snapshot با تاریخ ثابتِ گذشته همیشه خالی
* است و چیزی را تضمین نمی‌کند. به‌جایش برنامهٔ قطعی روی تاریخِ محاسبه‌شدهٔ نزدیک ساخته
* می‌شود و مقادیر epoch با placeholder جایگزین می‌شوند؛ آنچه می‌ماند دقیقاً همان چیزی
* است که قرارداد را می‌سازد: کلیدها، ترتیب، نوع‌ها و ساعت‌های محلی.
*/
#[Group('slot-mode-frozen')]
class SlotModeFrozenTest extends ApiTestCase
{
private const EPOCH_PLACEHOLDER = '<epoch>';
private const UUID_PLACEHOLDER = '<uuid>';
/** روزِ مبنا: نه امروز (تا `is_available` با گذر ساعت نلرزد) و داخل بازهٔ رزرو. */
private const OFFSET_DAYS = 3;
/**
* برنامهٔ هفتگیِ قطعی: هر هفت روز یک شیفت یکسان، تا نتیجه به روزِ هفتهٔ اجرای تست
* وابسته نباشد.
*/
private function makeSlotModeDoctor(): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر قرارداد اسلاتی');
$this->em->persist($doctor);
$day = [
'sessions' => [[
'active' => true,
'start_time' => '09:00',
'end_time' => '11:00',
'duration_per_patient' => 30,
'has_rest' => false,
'patient_limit' => null,
'location_id' => 1,
]],
];
$setting = [];
foreach (range(0, 6) as $dayKey) {
$setting[(string) $dayKey] = $day;
}
$schedule = $this->newWeeklySchedule($doctor, $setting);
$schedule->setMeta([
'booking_mode' => WeeklySchedule::MODE_SLOT,
'online_booking_enabled' => true,
'booking_window_value' => 3,
'booking_window_unit' => 'month',
'buffer_minutes' => 0,
]);
$this->em->persist($schedule);
$this->em->flush();
return [$doctor, date('Y-m-d', strtotime('+' . self::OFFSET_DAYS . ' days'))];
}
public function testAppointmentSlotsContractIsFrozen(): void
{
[$doctor, $date] = $this->makeSlotModeDoctor();
$this->client->request('GET', '/api/v1/appointment-slots?' . http_build_query([
'doctor_uuid' => $doctor->getUuid(),
'date' => $date,
]));
self::assertSame(200, $this->client->getResponse()->getStatusCode());
$actual = $this->normalize(
json_decode($this->client->getResponse()->getContent(), true),
['date' => '<date>'],
);
self::assertSame(
$this->fixture('slot-mode-contract.json'),
$actual,
'قرارداد appointment-slots عوض شده است. کد را برگردان، نه fixture را.',
);
}
public function testMonthAvailabilityContractIsFrozen(): void
{
[$doctor, $date] = $this->makeSlotModeDoctor();
$ts = (int) strtotime($date);
$this->client->request('GET', sprintf(
'/api/v1/appointment-settings/month-availability/%s?year=%d&month=%d',
$doctor->getUuid(),
(int) date('Y', $ts),
(int) date('n', $ts),
));
self::assertSame(200, $this->client->getResponse()->getStatusCode());
$body = json_decode($this->client->getResponse()->getContent(), true);
// فهرست روزها داده است نه قرارداد (به «امروز» وابسته است)؛ قرارداد این است که
// هر روزِ ماه دقیقاً در یکی از دو فهرست باشد.
$daysInMonth = (int) date('t', $ts);
self::assertCount(
$daysInMonth,
array_merge($body['data']['enabled_dates'], $body['data']['disabled_dates']),
'هر روزِ ماه باید دقیقاً در یکی از enabled/disabled باشد',
);
$actual = $this->normalize($body, [
'year' => '<year>',
'month' => '<month>',
'enabled_dates' => '<dates>',
'disabled_dates' => '<dates>',
]);
self::assertSame(
$this->fixture('month-availability-contract.json'),
$actual,
'قرارداد month-availability عوض شده است. کد را برگردان، نه fixture را.',
);
}
/**
* امضای متدهای عمومیِ SlotCalculatorService. افزودن پارامتر اختیاری مجاز است ولی
* باید **یک بار** در fixture ثبت شود؛ تغییر یا حذف پارامتر موجود ممنوع.
*/
public function testSlotCalculatorPublicApiIsFrozen(): void
{
self::assertSame(
require __DIR__ . '/fixtures/slot-calculator-signatures.php',
$this->publicSignaturesOf(SlotCalculatorService::class),
'امضای عمومی SlotCalculatorService عوض شده است. کد را برگردان، نه fixture را.',
);
}
/** @return array<string, array<string, string>> */
private function publicSignaturesOf(string $class): array
{
$out = [];
foreach ((new \ReflectionClass($class))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
if ($method->isConstructor() || $method->getDeclaringClass()->getName() !== $class) {
continue;
}
$params = [];
foreach ($method->getParameters() as $p) {
$params[$p->getName()] = sprintf(
'%s%s',
(string) ($p->getType() ?? 'mixed'),
$p->isDefaultValueAvailable()
? ' = ' . var_export($p->getDefaultValue(), true)
: '',
);
}
$out[$method->getName()] = [
'returns' => (string) ($method->getReturnType() ?? 'mixed'),
'params' => $params,
];
}
ksort($out);
return $out;
}
/**
* مقادیر داده‌ایِ وابسته به زمان را با placeholder جایگزین می‌کند تا آنچه مقایسه
* می‌شود فقط قرارداد بماند: کلیدها، ترتیب، نوع‌ها و ساعت‌های محلی.
*
* @param array<string, string> $overrides کلید → placeholder
*/
private function normalize(mixed $value, array $overrides = [], ?string $key = null): mixed
{
if ($key !== null && array_key_exists($key, $overrides)) {
return $overrides[$key];
}
if (is_array($value)) {
$out = [];
foreach ($value as $k => $v) {
$out[$k] = $this->normalize($v, $overrides, is_string($k) ? $k : $key);
}
return $out;
}
if (is_int($value) && $value > 1_000_000_000) {
return self::EPOCH_PLACEHOLDER;
}
if (is_string($value) && preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-/', $value) === 1) {
return self::UUID_PLACEHOLDER;
}
return $value;
}
/**
* fixture را می‌خواند و کلید `_readme` (هشدار read-only بودن فایل) را کنار می‌گذارد
* تا مقایسه فقط روی قرارداد انجام شود.
*
* @return array<mixed>
*/
private function fixture(string $name): array
{
$path = __DIR__ . '/fixtures/' . $name;
self::assertFileExists($path, 'fixture قرارداد وجود ندارد: ' . $name);
$decoded = json_decode(file_get_contents($path), true, flags: JSON_THROW_ON_ERROR);
unset($decoded['_readme']);
return $decoded;
}
}