getConstant('PERMISSION_ANALYSIS') === 'tcm.diagnosis/aianalysis', 'logic enforces the exact normalized aiAnalysis permission' ); analysisContractExpect( strtolower(Str::camel('tcm.diagnosis/aiAnalysis')) === 'tcm.diagnosis/aianalysis', 'middleware normalization matches the logic permission' ); $hasPermission = $logicReflection->getMethod('hasPermission'); analysisContractExpect( $hasPermission->invoke(null, 0, ['root' => 1], 'tcm.diagnosis/aianalysis') === true, 'super administrator remains compatible without a role-menu row' ); $analysisMethod = $logicReflection->getMethod('analysis'); $analysisParameters = $analysisMethod->getParameters(); analysisContractExpect(count($analysisParameters) === 4, 'analysis accepts an optional model key'); analysisContractExpect( $analysisParameters[3]->getName() === 'modelKey' && $analysisParameters[3]->isDefaultValueAvailable() && $analysisParameters[3]->getDefaultValue() === 'qwen', 'internal legacy calls also default to qwen' ); $logicLines = file($logicReflection->getFileName()); analysisContractExpect(is_array($logicLines), 'logic source is readable'); $logicSource = implode('', $logicLines); $analysisSource = implode('', array_slice( $logicLines, $analysisMethod->getStartLine() - 1, $analysisMethod->getEndLine() - $analysisMethod->getStartLine() + 1 )); analysisContractExpect( substr_count($analysisSource, 'DifyChatService::chat(') === 1, 'one analysis request performs exactly one upstream chat call' ); foreach ([ "'diagnosis_advice'", "'risk_assessment'", "'treatment_advice'", ] as $field) { analysisContractExpect(str_contains($logicSource, $field), "response contains {$field}"); } foreach ([ "'model_key'", "'model_label'", "'model_name'", "'generated_at'", ] as $field) { analysisContractExpect(str_contains($analysisSource, $field), "response contains {$field}"); } $controller = file_get_contents( dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php' ); analysisContractExpect(is_string($controller), 'controller source is readable'); analysisContractExpect( str_contains($controller, 'public function aiAnalysis()'), 'POST action name is aiAnalysis' ); analysisContractExpect( str_contains($controller, "goCheck('aiAnalysis')"), 'aiAnalysis uses its strict validation scene' ); analysisContractExpect( str_contains($controller, 'DiagnosisAiLogic::analysis('), 'controller delegates to structured analysis logic' ); analysisContractExpect( str_contains($controller, "\$params['model'] ?? 'qwen'"), 'legacy requests without model default to qwen at the endpoint boundary' ); $validator = new DiagnosisValidate(); $payloadCheck = (new ReflectionClass($validator))->getMethod('checkAiAnalysisPayload'); analysisContractExpect( $payloadCheck->invoke($validator, 7, '', ['id' => 7]) === true, 'request accepts exactly id' ); analysisContractExpect( $payloadCheck->invoke($validator, 7, '', ['id' => 7, 'model' => 'qwen']) === true, 'request accepts explicit qwen model key' ); analysisContractExpect( $payloadCheck->invoke($validator, 7, '', ['id' => 7, 'model' => 'openai']) === true, 'request accepts explicit openai model key' ); foreach (['provider', 'profile', 'key', 'api_key', 'base_url', 'prompt'] as $forbiddenField) { analysisContractExpect( $payloadCheck->invoke( $validator, 7, '', ['id' => 7, 'model' => 'qwen', $forbiddenField => 'client-controlled'] ) !== true, "request rejects forbidden {$forbiddenField} field" ); } foreach (['', 'QWEN', ' qwen', 'gpt-5.6-sol', 'other'] as $invalidModel) { analysisContractExpect( $payloadCheck->invoke($validator, 7, '', ['id' => 7, 'model' => $invalidModel]) !== true, "request rejects non-whitelisted model value {$invalidModel}" ); } foreach ([null, 0, true, []] as $invalidModelType) { analysisContractExpect( $payloadCheck->invoke($validator, 7, '', ['id' => 7, 'model' => $invalidModelType]) !== true, 'request rejects non-string model value of type ' . get_debug_type($invalidModelType) ); } $validScene = (new DiagnosisValidate())->scene('aiAnalysis'); analysisContractExpect($validScene->check(['id' => 7]), 'full validation scene accepts an integer id'); $qwenScene = (new DiagnosisValidate())->scene('aiAnalysis'); analysisContractExpect( $qwenScene->check(['id' => 7, 'model' => 'qwen']), 'full validation scene accepts qwen' ); $openAiScene = (new DiagnosisValidate())->scene('aiAnalysis'); analysisContractExpect( $openAiScene->check(['id' => 7, 'model' => 'openai']), 'full validation scene accepts openai' ); $invalidScene = (new DiagnosisValidate())->scene('aiAnalysis'); analysisContractExpect( !$invalidScene->check(['id' => 7, 'model' => 'QWEN']), 'full validation scene enforces exact lowercase model keys' ); foreach ([null, 0, true, []] as $invalidModelType) { $typedInvalidScene = (new DiagnosisValidate())->scene('aiAnalysis'); analysisContractExpect( !$typedInvalidScene->check(['id' => 7, 'model' => $invalidModelType]), 'full validation scene rejects model type ' . get_debug_type($invalidModelType) ); } $forbiddenScene = (new DiagnosisValidate())->scene('aiAnalysis'); analysisContractExpect( !$forbiddenScene->check(['id' => 7, 'model' => 'qwen', 'base_url' => 'https://client.invalid']), 'full validation scene rejects client upstream configuration' ); $migration = file_get_contents( dirname(__DIR__) . '/sql/1.9.20260813/add_diagnosis_ai_report.sql' ); analysisContractExpect(is_string($migration), 'permission migration is readable'); analysisContractExpect( str_contains($migration, "'tcm.diagnosis/aiAnalysis'"), 'exact action permission is registered' ); analysisContractExpect( str_contains($migration, "WHERE NOT EXISTS (\n SELECT 1 FROM `zyt_system_menu`\n WHERE `perms` = 'tcm.diagnosis/aiAnalysis'"), 'permission insertion is idempotent' ); analysisContractExpect( str_contains($migration, '@diagnosis_ai_analysis_menu_id'), 'eligible roles receive the exact permission node' ); echo "Diagnosis AI analysis contract: OK\n";