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>
238 lines
9.2 KiB
PHP
238 lines
9.2 KiB
PHP
<?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;
|
||
}
|
||
}
|