Files
zyt/server/tests/DiagnosisAiAssistantStreamContractTest.php
T
2026-08-18 14:08:38 +08:00

122 lines
6.0 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\service\AssistantSseProtocol;
use app\adminapi\http\middleware\AuthMiddleware;
function assistantStreamExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
/** @return array{event:string,data:array<string,mixed>} */
function parseAssistantSse(string $frame): array
{
$lines = preg_split('/\r\n|\r|\n/', trim($frame)) ?: [];
$event = '';
$data = '';
foreach ($lines as $line) {
if (str_starts_with($line, 'event: ')) {
$event = substr($line, 7);
} elseif (str_starts_with($line, 'data: ')) {
$data .= substr($line, 6);
}
}
$decoded = json_decode($data, true);
assistantStreamExpect($event !== '' && is_array($decoded), 'SSE frame is parseable');
return ['event' => $event, 'data' => $decoded];
}
$protocol = new AssistantSseProtocol();
assistantStreamExpect($protocol->encode('delta', ['text' => 'early']) === null, 'delta cannot precede start');
$start = parseAssistantSse((string) $protocol->encode('start', ['message' => 'ready']));
assistantStreamExpect($start['event'] === 'start' && $start['data']['seq'] === 1, 'start is the first event with seq 1');
assistantStreamExpect($protocol->encode('start', []) === null, 'start can only be emitted once');
$deltaOne = parseAssistantSse((string) $protocol->encode('delta', ['text' => '你']));
$deltaTwo = parseAssistantSse((string) $protocol->encode('delta', ['text' => '好']));
assistantStreamExpect($deltaOne['data']['seq'] === 2 && $deltaTwo['data']['seq'] === 3, 'delta seq is strictly monotonic');
$done = parseAssistantSse((string) $protocol->encode('done', ['answer' => '你好']));
assistantStreamExpect($done['event'] === 'done' && $done['data']['seq'] === 4, 'done is the terminal event');
assistantStreamExpect($protocol->encode('error', ['message' => 'late']) === null, 'a second terminal event is rejected');
assistantStreamExpect($protocol->encode('delta', ['text' => 'late']) === null, 'delta after terminal is rejected');
$errorProtocol = new AssistantSseProtocol();
$errorProtocol->encode('start', []);
$error = parseAssistantSse((string) $errorProtocol->encode('error', [
'code' => 'AI_ASSISTANT_FAILED',
'message' => 'AI 助手暂时不可用,请稍后重试',
]));
assistantStreamExpect($error['data']['seq'] === 2 && $errorProtocol->isTerminal(), 'error is the unique alternative terminal event');
$controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php');
$logic = file_get_contents(dirname(__DIR__) . '/app/adminapi/logic/tcm/DiagnosisAiLogic.php');
$validate = file_get_contents(dirname(__DIR__) . '/app/adminapi/validate/tcm/DiagnosisValidate.php');
$auth = file_get_contents(dirname(__DIR__) . '/app/adminapi/http/middleware/AuthMiddleware.php');
assistantStreamExpect(is_string($controller) && is_string($logic) && is_string($validate) && is_string($auth), 'stream implementation sources are readable');
$actionStart = strpos($controller, 'public function aiAssistantStream()');
$checkAt = strpos($controller, "goCheck('aiAssistant')", $actionStart);
$prepareAt = strpos($controller, 'DiagnosisAiLogic::prepareAssistant(', $actionStart);
$runAt = strpos($controller, '$this->runAssistantSse($prepared)', $actionStart);
$headerAt = strpos($controller, "header('Content-Type: text/event-stream; charset=utf-8')", $actionStart);
assistantStreamExpect(
$actionStart !== false && $checkAt > $actionStart && $prepareAt > $checkAt && $runAt > $prepareAt && $headerAt > $runAt,
'request validation and authorized preparation occur before every SSE header'
);
assistantStreamExpect(
str_contains($validate, "return \$this->only(['id', 'task', 'prompt']);"),
'stream reuses the strict id/task/prompt assistant scene'
);
assistantStreamExpect(
str_contains($logic, 'self::PERMISSION_ASSISTANT')
&& str_contains($logic, 'MyPatientLogic::canAccessDiagnosis')
&& str_contains($logic, 'streamPreparedAssistant'),
'stream preparation reuses assistant permission and canonical diagnosis row authorization'
);
assistantStreamExpect(
str_contains($auth, "\$accessUri === 'tcm.diagnosis/aiassistantstream'")
&& str_contains($auth, "'tcm.diagnosis/aiassistant', \$adminUris"),
'middleware maps stream access to the old registered assistant permission'
);
$matchPermissionAlias = (new ReflectionClass(AuthMiddleware::class))->getMethod('matchPermissionAlias');
$authMiddleware = new AuthMiddleware();
assistantStreamExpect(
$matchPermissionAlias->invoke(
$authMiddleware,
'tcm.diagnosis/aiassistantstream',
['tcm.diagnosis/aiassistant']
) === true,
'stream permission alias accepts the old assistant grant'
);
assistantStreamExpect(
$matchPermissionAlias->invoke($authMiddleware, 'tcm.diagnosis/aiassistantstream', []) === false,
'stream permission alias rejects an administrator without the old assistant grant'
);
assistantStreamExpect(
str_contains($controller, "'text' => \$delta")
&& str_contains($controller, "'code' => 'AI_ASSISTANT_FAILED'")
&& str_contains($controller, 'ignore_user_abort(true)')
&& str_contains($controller, 'connection_aborted() === 1')
&& !str_contains($controller, "DiagnosisAiLogic::getError()\n ]"),
'delta carries text, disconnects abort upstream, and errors use a generic prompt-free payload'
);
assistantStreamExpect(
str_contains($logic, 'DifyChatService::chat(')
&& str_contains($logic, 'DifyChatService::streamChat('),
'legacy blocking and new streaming paths coexist'
);
$sensitiveNeedles = ['api_key', 'base_url', 'query', 'inputs', 'user'];
foreach ($sensitiveNeedles as $needle) {
assistantStreamExpect(!array_key_exists($needle, $done['data']), "done event excludes internal {$needle}");
assistantStreamExpect(!array_key_exists($needle, $error['data']), "error event excludes internal {$needle}");
}
echo "Diagnosis AI assistant stream contract: OK\n";