*/ public static array $uris = []; public function __construct(int $adminId = 0) { } /** @return array */ public function getAdminUri(): array { return self::$uris; } } function patientOptionsExpect(bool $condition, string $message): void { if (!$condition) { fwrite(STDERR, "FAIL: {$message}\n"); exit(1); } } patientOptionsExpect( class_alias(DiagnosisAiPatientOptionsAuthCacheDouble::class, 'app\\common\\cache\\AdminAuthCache'), 'permission cache double is installed before logic autoload' ); $reflection = new ReflectionClass(DiagnosisAiLogic::class); $source = file_get_contents($reflection->getFileName()); patientOptionsExpect(is_string($source), 'logic source is readable'); $hasPermission = $reflection->getMethod('hasPermission'); DiagnosisAiPatientOptionsAuthCacheDouble::$uris = ['tcm.diagnosis/aiAssistant']; patientOptionsExpect( $hasPermission->invoke(null, 7, ['root' => 0], 'tcm.diagnosis/aiassistant') === true, 'roles with aiAssistant pass the explicit logic permission check' ); DiagnosisAiPatientOptionsAuthCacheDouble::$uris = ['tcm.diagnosis/aiReports']; patientOptionsExpect( $hasPermission->invoke(null, 7, ['root' => 0], 'tcm.diagnosis/aiassistant') === false, 'adjacent AI permissions do not grant patient options access' ); patientOptionsExpect( $hasPermission->invoke(null, 1, ['root' => 1], 'tcm.diagnosis/aiassistant') === true, 'root permission compatibility is preserved' ); $normalize = $reflection->getMethod('normalizePatientOptionsParams'); $defaults = $normalize->invoke(null, []); patientOptionsExpect( $defaults === ['keyword' => '', 'page_no' => 1, 'page_size' => 20], 'pagination defaults are stable' ); $bounded = $normalize->invoke(null, ['keyword' => ' 张三 ', 'page_no' => -8, 'page_size' => 500]); patientOptionsExpect( $bounded === ['keyword' => '张三', 'page_no' => 1, 'page_size' => 50], 'keyword is trimmed and pagination is bounded' ); patientOptionsExpect( $normalize->invoke(null, ['keyword' => str_repeat('患', 65)]) === null, 'keywords longer than 64 characters fail validation' ); $format = $reflection->getMethod('formatPatientOptionRow'); $dto = $format->invoke(null, [ 'diagnosis_id' => 18, 'source_patient_id' => 77, 'patient_name' => '张三', 'gender' => 1, 'age' => 42, 'phone_value' => '13812345678', 'id_card' => '11010519491231002X', 'diagnosis_date' => 1787155200, 'diagnosis_summary' => '气阴两虚', 'last_visit_at' => '2026-08-18 09:30:00', 'next_appointment_at' => '2026-08-22 14:00:00', ]); patientOptionsExpect( array_keys($dto) === [ 'diagnosis_id', 'source_patient_id', 'patient_name', 'gender', 'age', 'phone_masked', 'diagnosis_date', 'diagnosis_summary', 'last_visit_at', 'next_appointment_at', ], 'diagnosis option DTO exposes only the minimal allowlist' ); patientOptionsExpect($dto['phone_masked'] === '138****5678', 'phone is masked'); patientOptionsExpect($dto['diagnosis_summary'] === '气阴两虚', 'safe diagnosis summary is retained'); $encodedDto = json_encode($dto, JSON_UNESCAPED_UNICODE); patientOptionsExpect(is_string($encodedDto), 'DTO is JSON encodable'); patientOptionsExpect(!str_contains($encodedDto, '13812345678'), 'plain phone is absent'); patientOptionsExpect(!str_contains($encodedDto, '11010519491231002X'), 'ID card is absent'); $patientOptions = $reflection->getMethod('patientOptions'); $sourceLines = file($reflection->getFileName()); $methodSource = is_array($sourceLines) ? implode('', array_slice( $sourceLines, $patientOptions->getStartLine() - 1, $patientOptions->getEndLine() - $patientOptions->getStartLine() + 1 )) : ''; patientOptionsExpect( str_contains($methodSource, 'self::hasPermission($adminId, $adminInfo, self::PERMISSION_ASSISTANT)'), 'endpoint explicitly checks the existing AI assistant permission' ); patientOptionsExpect( str_contains($methodSource, 'MyPatientLogic::applyScope($query, $adminId, $adminInfo)'), 'query applies the shared patient data scope' ); patientOptionsExpect( str_contains($methodSource, "->where('d.status', 1)") && str_contains($methodSource, "->whereNull('d.delete_time')"), 'query only exposes enabled, non-deleted diagnoses' ); patientOptionsExpect( str_contains($methodSource, 'patient_option_apt.status = 3') && str_contains($methodSource, 'patient_option_apt.status = 1') && str_contains($methodSource, "->order('d.id', 'desc')"), 'appointment summaries and deterministic diagnosis ordering are present' ); patientOptionsExpect( substr_count($methodSource, '->select()') === 1, 'page data is fetched in one query without row-level lookups' ); $controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php'); patientOptionsExpect(is_string($controller), 'controller source is readable'); patientOptionsExpect( str_contains($controller, 'public function aiPatientOptions()') && str_contains($controller, 'DiagnosisAiLogic::patientOptions('), 'GET controller action delegates to the scoped logic' ); $migration = file_get_contents( dirname(__DIR__) . '/database/migrations/2026_08_20_diagnosis_ai_patient_options_permission.sql' ); patientOptionsExpect(is_string($migration), 'permission migration is readable'); patientOptionsExpect( substr_count($migration, "WHERE `perms` = 'tcm.diagnosis/aiPatientOptions'") >= 2, 'endpoint permission registration is idempotent and addressable' ); patientOptionsExpect( str_contains($migration, "`menu`.`perms` = 'tcm.diagnosis/aiAssistant'") && str_contains($migration, 'INSERT IGNORE INTO `zyt_system_role_menu`'), 'roles owning aiAssistant inherit the endpoint permission idempotently' ); echo "Diagnosis AI patient options contract: OK\n";