Files
zyt/server/tests/LiveConsultationAuthorizationTest.php
2026-10-30 13:41:35 +08:00

128 lines
7.9 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\cache {
final class AdminAuthCache {
public static array $permissions = ['tcm.diagnosis/aiassistant'];
public function __construct($id) {}
public function getAdminUri(): array { return self::$permissions; }
}
}
namespace app\adminapi\logic\firstvisit {
final class MyPatientLogic {
public static bool $allowed = true;
public static function canAccessDiagnosis(...$args): bool { return self::$allowed; }
}
}
namespace app\adminapi\logic\tcm {
function config(string $key): array { return ['timeout' => 5, 'models' => ['qwen' => ['name' => 'synthetic', 'label' => 'synthetic']]]; }
final class DiagnosisLogic {
public static int $detailCalls = 0;
public static bool $rejectDetail = false;
public static function detail($params, $adminInfo): array {
self::$detailCalls++;
if (self::$rejectDetail) throw new \RuntimeException('In-call hints must not fetch chart details/images');
return ['id' => $params['id'], 'patient_id' => 9, 'age' => 52, 'gender' => '女'];
}
}
final class PatientAiReportLogic {
public static array $calls = [];
public static $onAggregate = null;
public static array $files = [];
public static function contextForAuthorizedDiagnosis(...$args): array {
self::$calls[] = $args;
if (is_callable(self::$onAggregate)) (self::$onAggregate)();
return ['snapshot' => ['clinical_source' => '2020年授权病历:合成既往资料,当前状态未核实。'],
'source_summary' => ['diagnosis_count' => 1, 'snapshot_complete' => true, 'may_be_truncated' => false],
'source_diagnosis_ids' => [7], 'files' => self::$files];
}
}
}
namespace app\common\service {
final class DifyChatService {
public static int $calls = 0;
public static $streamHandler = null;
public static ?string $handlerFailure = null;
public static function chat(...$args): never { self::$calls++; throw new \RuntimeException('No outbound calls allowed in synthetic tests'); }
public static function streamChat(...$args): array {
self::$calls++;
if (!is_callable(self::$streamHandler)) throw new \RuntimeException('No outbound calls allowed in synthetic tests');
self::$handlerFailure = null;
try { return (self::$streamHandler)(...$args); }
catch (\Throwable $e) { self::$handlerFailure = $e->getMessage(); throw $e; }
}
}
}
namespace {
// Reuse the fully in-memory database fixture and pure source/citation checks.
require __DIR__ . '/LiveConsultationContextTest.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic as Logic;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\common\cache\AdminAuthCache;
use app\common\model\tcm\CallRecord;
use app\common\service\DifyChatService;
use app\adminapi\validate\tcm\DiagnosisValidate;
final class SyntheticLiveValidate extends DiagnosisValidate {
protected function checkDiagnosis($value) { return true; }
}
$authorizedEvents = 0;
$options = ['call_record_id' => 91, 'transcription_session_id' => 'session-a',
'on_authorized' => static function () use (&$authorizedEvents): bool { $authorizedEvents++; return true; }];
CallRecord::$records = [$record];
AdminAuthCache::$permissions = [];
$before = CallRecord::$reads;
liveExpect(Logic::prepareAssistant(7, 'live_consultation', '', 8, [], null, $options) === null
&& CallRecord::$reads === $before && $authorizedEvents === 0, 'AI function denial precedes call data and SSE');
AdminAuthCache::$permissions = ['tcm.diagnosis/aiassistant'];
MyPatientLogic::$allowed = false;
liveExpect(Logic::prepareAssistant(7, 'live_consultation', '', 8, [], null, $options) === null
&& CallRecord::$reads === $before && $authorizedEvents === 0, 'diagnosis scope denial precedes call data and SSE');
MyPatientLogic::$allowed = true;
foreach ([array_replace($options, ['call_record_id' => 92]),
array_replace($options, ['transcription_session_id' => 'session-b']),
array_replace($options, ['transcription_session_id' => null])] as $invalidOptions) {
liveExpect(Logic::prepareAssistant(7, 'live_consultation', '', 8, [], null, $invalidOptions) === null
&& Logic::getAssistantErrorCode() === 'LIVE_CONTEXT_UNAVAILABLE' && $authorizedEvents === 0,
'invalid or cross-call identity fails before on_authorized');
}
liveExpect(Logic::prepareAssistant(7, 'live_consultation', '', 9, ['root' => 1], null, $options) === null
&& $authorizedEvents === 0, 'root cannot analyze another doctor call');
liveExpect(Logic::prepareAssistant(7, 'live_review', '', 8, [], null, $options) === null
&& Logic::getAssistantErrorCode() === 'LIVE_CONTEXT_UNAVAILABLE', 'review waits for persisted final status');
liveExpect(\app\adminapi\logic\tcm\PatientAiReportLogic::$calls === [], 'all denied calls precede case aggregation');
$beforeDetails = \app\adminapi\logic\tcm\DiagnosisLogic::$detailCalls;
$livePrepared = Logic::prepareAssistant(7, 'live_consultation', '', 8, [], null, $options);
liveExpect(is_array($livePrepared) && $authorizedEvents === 1 && DifyChatService::$calls === 0,
'authorized call prepares full current snapshot without a model call');
liveExpect(\app\adminapi\logic\tcm\PatientAiReportLogic::$calls === []
&& \app\adminapi\logic\tcm\DiagnosisLogic::$detailCalls === $beforeDetails,
'dialogue-only hints skip chart details, image collection and longitudinal aggregation');
CallRecord::$records = [array_replace($record, ['transcription_status' => 'completed'])];
$reviewPrepared = Logic::prepareAssistant(7, 'live_review', '', 8, [], null, $options);
$caseRead = \app\adminapi\logic\tcm\PatientAiReportLogic::$calls[0];
liveExpect(is_array($reviewPrepared) && $caseRead[0]['id'] === 7 && $caseRead[1] === 8 && $caseRead[2] === [],
'review history builder still receives canonical diagnosis and trusted actor');
CallRecord::$records = [$record];
liveExpect(Logic::assistant(7, 'live_consultation', '', 8, []) === null
&& Logic::getAssistantErrorCode() === 'LIVE_STREAM_REQUIRED', 'non-stream endpoint cannot bypass current-call binding');
$deltas = [];
liveExpect(Logic::streamPreparedAssistant(['task' => 'live_consultation'], static function ($delta) use (&$deltas) { $deltas[] = $delta; }) === null
&& $deltas === [] && DifyChatService::$calls === 0, 'even a forged prepared live task cannot invoke upstream or expose JSON');
$payload = ['id' => 7, 'task' => 'live_consultation', 'call_record_id' => 91, 'transcription_session_id' => 'session-a'];
foreach (['live_consultation', 'live_review'] as $task) {
liveExpect((new SyntheticLiveValidate())->scene('aiAssistantStream')->check(array_replace($payload, ['task' => $task])), 'stream scene accepts bound live task');
}
foreach (['call_record_id', 'transcription_session_id'] as $key) {
$invalid = $payload; unset($invalid[$key]);
liveExpect(!(new SyntheticLiveValidate())->scene('aiAssistantStream')->check($invalid), 'scene rejects absent live identity');
}
foreach ([['call_record_id' => '91suffix'], ['call_record_id' => 1.5], ['transcription_session_id' => []],
['transcription_session_id' => ''], ['transcription_session_id' => str_repeat('x', 129)]] as $invalid)
liveExpect(!(new SyntheticLiveValidate())->scene('aiAssistantStream')->check(array_replace($payload, $invalid)), 'scene rejects malformed live identity');
liveExpect((new SyntheticLiveValidate())->scene('aiAssistantStream')->check(['id' => 7, 'task' => 'summary']), 'existing generic stream remains usable without call fields');
echo "Live consultation authorization / preparation / request validation: {$count} total assertions passed (no DB/network)\n";
}