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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user