Files
zyt/server/tests/MobileRecordingMediaTest.php
T
2026-11-09 11:56:28 +08:00

272 lines
22 KiB
PHP

<?php
declare(strict_types=1);
/** Synthetic files and actors only. Never initializes the app or loads .env. */
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\adminapi\logic\tcm\MobileRecordingLogic as Binding;
use app\adminapi\logic\tcm\MobileRecordingMediaLogic as Media;
use app\adminapi\controller\tcm\MobileRecordingMediaController;
use think\Container;
use think\facade\Db;
$app = new think\App();
$manager = new think\DbManager();
$driver = 'sqlite';
$pdo = null;
if (isset($argv[1])) {
$state = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR);
if ($state['schema'] !== 'synthetic_mobile_media_test' || (int) $state['port'] === 3306
|| !str_contains(str_replace('\\', '/', $state['run_root']), '/patient-media-mysql-20261109-')) {
throw new RuntimeException('Refusing non-synthetic MySQL target');
}
$pdo = new PDO('mysql:host=127.0.0.1;port=' . (int) $state['port'], 'root', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$actual = strtolower(rtrim(str_replace('\\', '/', (string) $pdo->query('SELECT @@datadir')->fetchColumn()), '/'));
if ($actual !== strtolower(str_replace('\\', '/', $state['run_root']) . '/data')) {
throw new RuntimeException('Refusing non-scratch datadir');
}
$pdo->exec('DROP DATABASE IF EXISTS synthetic_mobile_media_test');
$pdo->exec('CREATE DATABASE synthetic_mobile_media_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
$pdo->exec('USE synthetic_mobile_media_test');
$driver = 'mysql';
$connection = ['type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => (int) $state['port'],
'database' => 'synthetic_mobile_media_test', 'username' => 'root', 'password' => '',
'charset' => 'utf8mb4', 'prefix' => 'zyt_', 'fields_strict' => true, 'fields_cache' => false, 'trigger_sql' => false];
} else {
if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) { throw new RuntimeException('SQLite PDO required; no production fallback'); }
$connection = ['type' => 'sqlite', 'database' => ':memory:', 'prefix' => 'zyt_'];
}
$manager->setConfig(['default' => $driver, 'auto_timestamp' => false, 'datetime_format' => false, 'connections' => [$driver => $connection]]);
Container::getInstance()->instance('think\DbManager', $manager);
$storage = sys_get_temp_dir() . '/mobile-media-synthetic-' . bin2hex(random_bytes(8));
$config = new think\Config();
$config->set(['data_scope' => ['enabled' => false]], 'project');
$config->set(['storage_root' => $storage], 'mobile_recording_media');
Container::getInstance()->instance('config', $config);
$checks = [];
$expect = static function (bool $ok, string $why) use (&$checks): void {
if (!$ok) { throw new RuntimeException($why); }
$checks[] = $why;
};
$denied = static function (callable $call, string $why) use ($expect): void {
try { $call(); } catch (DomainException $e) { $expect(true, $why); return; }
throw new RuntimeException('Expected denial: ' . $why);
};
$controller = static function (string $action, array $params, bool $post = true, string $body = '', array $headers = []) use ($app) {
$request = new think\Request();
$request->withServer(['REQUEST_METHOD' => $post ? 'POST' : 'GET']);
if ($action === 'chunk' || !$post) { $request->withGet($params); } else { $request->withPost($params); }
$request->withInput($body)->withHeader($headers);
$request->adminInfo = ['admin_id' => 11, 'root' => 0];
$app->instance('request', $request);
return (new MobileRecordingMediaController($app))->$action();
};
$capture = static function ($response): string {
ob_start();
try { $response->send(); return (string) ob_get_contents(); }
finally { ob_end_clean(); }
};
try {
foreach ([
'CREATE TABLE zyt_admin (id INT PRIMARY KEY, root INT, name VARCHAR(100), disable INT, delete_time INT NULL)',
'CREATE TABLE zyt_admin_role (admin_id INT, role_id INT)',
'CREATE TABLE zyt_system_role_menu (role_id INT, menu_id INT)',
'CREATE TABLE zyt_system_menu (id INT PRIMARY KEY, perms VARCHAR(128), is_disable INT)',
'CREATE TABLE zyt_tcm_diagnosis (id INT PRIMARY KEY, patient_id INT, patient_name VARCHAR(100), phone VARCHAR(30), assistant_id INT, status INT, delete_time INT NULL)',
'CREATE TABLE zyt_doctor_appointment (id INT PRIMARY KEY, patient_id INT, doctor_id INT, status INT)',
] as $sql) { Db::execute($sql . ($driver === 'mysql' ? ' ENGINE=InnoDB' : '')); }
if ($driver === 'mysql') {
$pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_06_mobile_recording_binding.sql'));
$pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_09_mobile_recording_media.sql'));
$pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_09_mobile_recording_media.sql'));
$expect(true, 'real upgrade migration executes and is idempotent on isolated MySQL');
} else {
Db::execute('CREATE TABLE zyt_mobile_recording_binding (id INTEGER PRIMARY KEY AUTOINCREMENT, admin_id INT NOT NULL,
installation_id TEXT NOT NULL, recording_key TEXT NOT NULL, diagnosis_id INT NOT NULL, source_platform TEXT NOT NULL,
recorded_at INT NOT NULL, duration_ms INT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL,
UNIQUE(admin_id,installation_id,recording_key))');
require __DIR__ . '/fixtures/mobile_recording_media_schema.php';
}
Db::name('admin')->insertAll([
['id' => 1, 'root' => 1, 'name' => 'Synthetic root', 'disable' => 0],
['id' => 11, 'root' => 0, 'name' => 'Synthetic assistant', 'disable' => 0],
['id' => 12, 'root' => 0, 'name' => 'Synthetic other', 'disable' => 0],
['id' => 13, 'root' => 0, 'name' => 'Synthetic doctor', 'disable' => 0],
['id' => 15, 'root' => 0, 'name' => 'Synthetic no-grant', 'disable' => 0],
]);
Db::name('admin_role')->insertAll([['admin_id' => 11, 'role_id' => 2], ['admin_id' => 12, 'role_id' => 2], ['admin_id' => 13, 'role_id' => 1]]);
Db::name('system_role_menu')->insertAll([['role_id' => 2, 'menu_id' => 1], ['role_id' => 1, 'menu_id' => 1]]);
Db::name('system_menu')->insert(['id' => 1, 'perms' => Binding::PAGE_PERMISSION, 'is_disable' => 0]);
Db::name('tcm_diagnosis')->insertAll([
['id' => 101, 'patient_id' => 501, 'patient_name' => 'Synthetic A', 'phone' => '', 'assistant_id' => 11, 'status' => 1],
['id' => 102, 'patient_id' => 502, 'patient_name' => 'Synthetic B', 'phone' => '', 'assistant_id' => 11, 'status' => 1],
['id' => 103, 'patient_id' => 503, 'patient_name' => 'Synthetic C', 'phone' => '', 'assistant_id' => 12, 'status' => 1],
]);
Db::name('doctor_appointment')->insert(['id' => 1, 'patient_id' => 101, 'doctor_id' => 13, 'status' => 3]);
$input = ['installation_id' => '12345678-1234-4123-8123-123456789abc', 'recording_key' => str_repeat('a', 64),
'diagnosis_id' => 101, 'source_platform' => 'android_bcr', 'recorded_at' => 1700000000000, 'duration_ms' => 65000];
$identity = array_intersect_key($input, array_flip(['installation_id', 'recording_key']));
$target = $identity + ['diagnosis_id' => 101];
$pcm = str_repeat("\x00\x00\x01\x00", 300000); // synthetic silence/tiny impulse, >1 MiB to exercise resume
$wav = 'RIFF' . pack('V', 36 + strlen($pcm)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 8000, 16000, 2, 16)
. 'data' . pack('V', strlen($pcm)) . $pcm;
$prepare = $target + ['kind' => 'audio', 'size_bytes' => strlen($wav), 'sha256' => hash('sha256', $wav), 'media_type' => 'audio/wav'];
$denied(fn () => Media::prepare($prepare, 11), 'unbound recording cannot upload');
$binding = Binding::bind($input, 11)['binding'];
$id = $binding['id'];
$expect(Binding::patientLists(['diagnosis_id' => 101], 11)['lists'][0]['upload_state'] === 'not_uploaded', 'old metadata row remains compatible before upload');
foreach ([['file_name' => 'private.wav'], ['path' => '/tmp/secret'], ['admin_id' => 1], ['token' => 'secret']] as $extra) {
$denied(fn () => Media::prepare($prepare + $extra, 11), 'reject filename/path/actor/token extra field');
}
$denied(fn () => Media::prepare(array_replace($prepare, ['size_bytes' => Media::AUDIO_MAX + 1]), 11), 'audio limit enforced before storage');
$denied(fn () => Media::prepare(array_replace($prepare, ['kind' => 'transcript', 'media_type' => 'text/plain', 'size_bytes' => Media::TEXT_MAX + 1]), 11), 'text limit enforced');
$denied(fn () => Media::prepare($prepare, 12), 'same device and key cannot upload another account binding');
$denied(fn () => Media::prepare($prepare, 15), 'missing live page grant denied');
$denied(fn () => Media::prepare(array_replace($prepare, ['diagnosis_id' => 103]), 11), 'wrong diagnosis denied');
$rejected = ['error_code' => 'request_rejected', 'retryable' => false];
$resync = ['error_code' => 'upload_resync', 'retryable' => true];
$scopeDenial = $controller('prepare', array_replace($prepare, ['diagnosis_id' => 103]))->getData();
$expect($scopeDenial['code'] === 0 && $scopeDenial['data'] === $rejected, 'patient scope error is machine-readable and never automatically retryable');
$methodError = $controller('prepare', $prepare, false)->getData();
$expect($methodError['code'] === 0 && $methodError['data'] === $rejected, 'wrong method remains non-retryable');
$session = $controller('prepare', $prepare)->getData();
$expect($session['code'] === 1, 'actual controller prepare succeeds');
$session = $session['data'];
$upload = ['upload_id' => $session['upload_id'], 'diagnosis_id' => 101];
$expect(Media::prepare($prepare, 11)['upload_id'] === $session['upload_id'], 'repeat prepare reuses upload');
$denied(fn () => Media::complete($upload, 11), 'incomplete upload cannot publish');
$incomplete = $controller('complete', $upload)->getData();
$expect($incomplete['code'] === 0 && $incomplete['msg'] === '文件尚未上传完整' && $incomplete['data'] === $resync,
'incomplete complete preserves message and requests cursor resynchronization');
$first = substr($wav, 0, Media::CHUNK_SIZE);
$chunk = $upload + ['offset' => 0, 'chunk_sha256' => hash('sha256', $first)];
$denied(fn () => Media::chunk(array_replace($chunk, ['chunk_sha256' => str_repeat('0', 64)]), $first, 11), 'wrong chunk hash rejected');
$hashError = $controller('chunk', array_replace($chunk, ['chunk_sha256' => str_repeat('0', 64)]), true, $first,
['content-type' => 'application/octet-stream'])->getData();
$expect($hashError['code'] === 0 && $hashError['data'] === $rejected, 'chunk integrity error is not automatically retryable');
$denied(fn () => Media::chunk($chunk, $first . 'x', 11), 'chunk over one MiB rejected');
$expect($controller('chunk', $chunk, true, $first, ['content-type' => 'text/plain'])->getData()['code'] === 0, 'binary content type enforced');
$response = $controller('chunk', $chunk, true, $first, ['content-type' => 'application/octet-stream'])->getData();
$expect($response['code'] === 1 && $response['data']['received_bytes'] === Media::CHUNK_SIZE, 'actual binary controller receives first chunk');
$expect(Media::chunk($chunk, $first, 11)['received_bytes'] === Media::CHUNK_SIZE, 'duplicate chunk is idempotent');
$badFirst = 'X' . substr($first, 1);
$denied(fn () => Media::chunk(array_replace($chunk, ['chunk_sha256' => hash('sha256', $badFirst)]), $badFirst, 11), 'conflicting replay rejected');
$conflict = $controller('chunk', array_replace($chunk, ['chunk_sha256' => hash('sha256', $badFirst)]), true, $badFirst,
['content-type' => 'application/octet-stream'])->getData();
$expect($conflict['code'] === 0 && $conflict['data'] === $resync, 'conflicting replay has structured upload_resync code');
$gap = $controller('chunk', $upload + ['offset' => Media::CHUNK_SIZE + 1, 'chunk_sha256' => hash('sha256', 'x')],
true, 'x', ['content-type' => 'application/octet-stream'])->getData();
$expect($gap['code'] === 0 && $gap['data'] === $resync, 'noncontiguous cursor has structured upload_resync code');
$denied(fn () => Media::chunk(array_replace($chunk, ['offset' => Media::CHUNK_SIZE + 1]), $first, 11), 'gap or size overflow rejected');
$expect(Media::status($target, 11)['audio']['received_bytes'] === Media::CHUNK_SIZE, 'interruption resumes with server cursor');
file_put_contents($storage . '/' . $session['upload_id'] . '.bin', 'uncommitted', FILE_APPEND);
$expect(Media::prepare($prepare, 11)['received_bytes'] === Media::CHUNK_SIZE, 'crash after disk write before DB commit rolls back extra bytes');
$expect(filesize($storage . '/' . $session['upload_id'] . '.bin') === Media::CHUNK_SIZE, 'uncommitted trailing bytes removed');
$last = substr($wav, Media::CHUNK_SIZE);
Media::chunk($upload + ['offset' => Media::CHUNK_SIZE, 'chunk_sha256' => hash('sha256', $last)], $last, 11);
$expect(Media::complete($upload, 11)['state'] === 'complete', 'full size hash and WAV signature validated');
$expect(Media::complete($upload, 11)['state'] === 'complete', 'repeat complete idempotent');
$expect(Media::prepare($prepare, 11)['state'] === 'complete', 'repeat completed prepare reuses object');
$expect(Binding::patientLists(['diagnosis_id' => 101], 11)['lists'][0]['upload_state'] === 'partial', 'audio alone is partial and available');
$full = Media::audio(['binding_id' => $id, 'diagnosis_id' => 101], 11);
$expect($full->getHeader('Cache-Control') === 'private, no-store, max-age=0' && $full->getHeader('X-Content-Type-Options') === 'nosniff', 'private response headers');
$expect($capture($full) === $wav, 'streamed full audio matches synthetic bytes');
foreach (['bytes=0-31' => substr($wav, 0, 32), 'bytes=-10' => substr($wav, -10), 'bytes=1200030-' => substr($wav, 1200030)] as $range => $expected) {
$stream = $controller('audio', ['binding_id' => $id, 'diagnosis_id' => 101], false, '', ['range' => $range]);
$expect($stream->getCode() === 206 && $capture($stream) === $expected, 'authenticated Range ' . $range);
}
foreach (['bytes=9999999-', 'bytes=9-2', 'bytes=0-1,3-4', 'bytes=-0', 'garbage'] as $range) {
$stream = Media::audio(['binding_id' => $id], 11, $range);
$expect($stream->getCode() === 416 && $capture($stream) === '', 'invalid range safely returns 416');
}
$denied(fn () => Media::audio(['binding_id' => $id], 12), 'out-of-scope audio denied');
$expect($capture(Media::audio(['binding_id' => $id], 13, 'bytes=0-3')) === 'RIFF', 'doctor with current patient scope can read');
$denied(fn () => Media::chunk($chunk, $first, 13), 'reader cannot write another actor upload');
$denied(fn () => Media::audio(['binding_id' => $id, 'diagnosis_id' => 102], 11), 'archive diagnosis anchor rejects stale page');
Db::name('system_menu')->where('id', 1)->update(['is_disable' => 1]);
$permissionError = $controller('status', $target, false)->getData();
$expect($permissionError['code'] === 0 && $permissionError['data'] === $rejected, 'revoked page permission is explicitly non-retryable');
$denied(fn () => Media::status($target, 11), 'revoked grant checked on status');
$denied(fn () => Media::audio(['binding_id' => $id], 11), 'revoked grant checked on read');
Db::name('system_menu')->where('id', 1)->update(['is_disable' => 0]);
Db::name('admin')->where('id', 11)->update(['disable' => 1]);
$denied(fn () => Media::complete($upload, 11), 'disabled account checked on every request');
Db::name('admin')->where('id', 11)->update(['disable' => 0]);
Db::name('tcm_diagnosis')->where('id', 101)->update(['delete_time' => 1]);
$denied(fn () => Media::audio(['binding_id' => $id], 11), 'deleted diagnosis blocks content');
Db::name('tcm_diagnosis')->where('id', 101)->update(['delete_time' => null]);
$sendText = static function (string $text, ?string $sha = null) use ($target): array {
$session = Media::prepare($target + ['kind' => 'transcript', 'size_bytes' => strlen($text), 'sha256' => $sha ?? hash('sha256', $text), 'media_type' => 'text/plain'], 11);
$params = ['upload_id' => $session['upload_id'], 'diagnosis_id' => 101];
if ($text !== '') { Media::chunk($params + ['offset' => 0, 'chunk_sha256' => hash('sha256', $text)], $text, 11); }
return $params;
};
$textUpload = $sendText("合成转写\n<script>never render HTML</script>");
Media::complete($textUpload, 11);
$text = Media::transcript(['binding_id' => $id, 'diagnosis_id' => 101], 11);
$expect(str_contains($text['text'], '<script>') && strlen($text['text']) === $text['size_bytes'], 'UTF8 transcript round trips as plain text');
$expect(Binding::patientLists(['diagnosis_id' => 101], 11)['lists'][0]['upload_state'] === 'complete', 'both assets report complete');
$invalid = $sendText("\xff\xfe");
$expect(Media::transcript(['binding_id' => $id], 11)['text'] === $text['text'], 'pending replacement preserves last completed transcript');
$denied(fn () => Media::complete($invalid, 11), 'non-UTF8 rejected');
$expect(Media::status($target, 11)['transcript']['received_bytes'] === 0, 'validation failure resets retry cursor');
$badHash = $sendText('abc', str_repeat('0', 64));
$denied(fn () => Media::complete($badHash, 11), 'wrong whole-file SHA rejected');
$empty = $sendText('');
Media::complete($empty, 11);
$expect(Media::transcript(['binding_id' => $id], 11)['text'] === '', 'empty UTF8 transcript supported');
$expect(!is_file($storage . '/' . $textUpload['upload_id'] . '.bin'), 'replaced completed content cleaned up');
$invalidAudio = Media::prepare(array_replace($prepare, ['size_bytes' => 12, 'sha256' => hash('sha256', 'not an audio')]), 11);
$badAudioParams = ['upload_id' => $invalidAudio['upload_id'], 'diagnosis_id' => 101];
Media::chunk($badAudioParams + ['offset' => 0, 'chunk_sha256' => hash('sha256', 'not an audio')], 'not an audio', 11);
$formatError = $controller('complete', $badAudioParams)->getData();
$expect($formatError['code'] === 0 && $formatError['data'] === $rejected, 'claimed WAV with invalid signature rejected without automatic retry');
$expect($capture(Media::audio(['binding_id' => $id], 11, 'bytes=0-3')) === 'RIFF', 'invalid replacement never replaces published audio');
Binding::bind(array_replace($input, ['diagnosis_id' => 102]), 11);
$denied(fn () => Media::complete($badAudioParams, 11), 'old pending rejected after reassignment');
$denied(fn () => Media::status($target, 11), 'old diagnosis upload target rejected');
$denied(fn () => Media::audio(['binding_id' => $id, 'diagnosis_id' => 101], 11), 'old archive anchor rejected after reassignment');
$denied(fn () => Media::audio(['binding_id' => $id], 13), 'old patient doctor cannot read moved media');
$expect($capture(Media::audio(['binding_id' => $id, 'diagnosis_id' => 102], 11, 'bytes=0-3')) === 'RIFF', 'completed media moves with current binding');
Binding::bind($input, 11);
$denied(fn () => Media::complete($badAudioParams, 11), 'A to B to A cannot resurrect old pending');
$expect(!is_file($storage . '/' . $badAudioParams['upload_id'] . '.bin'), 'reassignment cleans cancelled pending bytes');
Binding::unbind($identity, 12);
$expect(Media::status($target, 11)['audio']['state'] === 'complete', 'other account unbind cannot remove assets');
touch($storage . '/' . $session['upload_id'] . '.bin', time() - 90000);
$protected = \app\common\service\mobilerecording\MobileRecordingStorage::cleanupOrphans(false);
$expect($protected['eligible'] === 0 && is_file($storage . '/' . $session['upload_id'] . '.bin'), 'orphan cleanup preserves referenced patient content regardless of age');
Binding::unbind($identity, 11);
$denied(fn () => Media::audio(['binding_id' => $id], 11), 'unbind immediately revokes content reads');
$denied(fn () => Media::chunk($chunk, $first, 11), 'unbind immediately revokes pending writes');
$expect(Db::name('mobile_recording_upload')->count() === 0 && Db::name('mobile_recording_asset')->count() === 0, 'unbind removes all media DB references');
$expect((glob($storage . '/*.bin') ?: []) === [], 'unbind removes private files');
$orphan = $storage . '/' . str_repeat('f', 32) . '.bin';
file_put_contents($orphan, 'synthetic orphan');
touch($orphan, time() - 90000);
$cleanup = \app\common\service\mobilerecording\MobileRecordingStorage::cleanupOrphans(true);
$expect($cleanup['eligible'] === 1 && is_file($orphan), 'orphan cleanup dry run preserves files');
$cleanup = \app\common\service\mobilerecording\MobileRecordingStorage::cleanupOrphans(false);
$expect($cleanup['deleted'] === 1 && !is_file($orphan), 'orphan cleanup removes only aged unreferenced objects');
Binding::bind($input, 11);
Db::execute('DROP TABLE zyt_mobile_recording_upload');
$missingSchema = $controller('prepare', $prepare)->getData();
$expect($missingSchema['code'] === 0 && !str_contains(json_encode($missingSchema), $storage), 'missing migration fails closed without SQL or storage path');
$expect($missingSchema['msg'] === '手机录音上传服务暂不可用,请稍后重试或联系管理员'
&& $missingSchema['data'] === ['error_code' => 'temporarily_unavailable', 'retryable' => true],
'Throwable preserves code0 and message while allowing bounded automatic retry');
$expect(Db::name('mobile_recording_binding')->count() === 1, 'media deployment failure preserves original metadata binding');
$middleware = file_get_contents(dirname(__DIR__) . '/app/adminapi/http/middleware/AuthMiddleware.php');
$expect(strpos($middleware, "'tcm.mobilerecordingmedia/'") < strpos($middleware, '$allUri ='), 'media group protected before unregistered-route bypass');
echo json_encode(['status' => 'passed', 'driver' => $driver, 'checks' => count($checks), 'evidence' => $checks,
'storage' => 'temporary synthetic objects cleaned in finally', 'production_connected' => false], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n";
} finally {
// Only direct objects in the unique directory created above; never walk unrelated directories.
foreach (glob($storage . '/*.bin') ?: [] as $file) { if (!is_link($file)) { @unlink($file); } }
if (is_dir($storage)) { @rmdir($storage); }
}