'; private const UUID_PLACEHOLDER = ''; /** روزِ مبنا: نه امروز (تا `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' => ''], ); 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' => '', 'month' => '', 'enabled_dates' => '', 'disabled_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> */ 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 $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 */ 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; } }