Files
zyt/server/tests/DiagnosisAiPatientOptionsContractTest.php
2026-08-22 08:51:35 +08:00

164 lines
6.1 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic;
final class DiagnosisAiPatientOptionsAuthCacheDouble
{
/** @var array<int,string> */
public static array $uris = [];
public function __construct(int $adminId = 0)
{
}
/** @return array<int,string> */
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";