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>
This commit is contained in:
hamed
2026-07-30 12:31:57 +03:30
co-authored by Claude Opus 5
parent f2ecaf201a
commit c37c0cee39
6 changed files with 931 additions and 11 deletions
+237
View File
@@ -0,0 +1,237 @@
<?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;
}
}