feat: add reviewed follow-up audio patient enrichment
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\followupaudio {
|
||||
// This test exercises the real file/DB pipeline, with a deterministic synthetic row-scope boundary.
|
||||
final class FollowupAudioAccess
|
||||
{
|
||||
public static function diagnosis(int $id, int $actor, array $info, bool $daily = false): array
|
||||
{
|
||||
if ($actor !== 7 || $id !== 91) {
|
||||
throw new \DomainException('synthetic row scope denied');
|
||||
}
|
||||
return ['id' => 91, 'patient_id' => 101];
|
||||
}
|
||||
}
|
||||
}
|
||||
namespace {
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
$app = new \think\App(dirname(__DIR__) . '/');
|
||||
$app->initialize();
|
||||
$app->config->set([
|
||||
'default' => 'mysql', 'connections' => ['mysql' => [
|
||||
'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => 23316,
|
||||
'database' => 'followup_audio_test', 'username' => 'root', 'password' => 'followup_audio_isolated_test',
|
||||
'charset' => 'utf8mb4', 'prefix' => 'far_', 'debug' => false,
|
||||
]],
|
||||
], 'database');
|
||||
set_exception_handler(static function (\Throwable $e): void { fwrite(STDERR, get_class($e) . ': ' . $e->getMessage() . PHP_EOL . $e->getTraceAsString() . PHP_EOL); exit(1); });
|
||||
$dir = sys_get_temp_dir() . '/followup-upload-test-' . bin2hex(random_bytes(8));
|
||||
mkdir($dir, 0700, true);
|
||||
$app->config->set(['private_dir' => $dir . '/private', 'max_bytes' => 524288000,
|
||||
'max_seconds' => 3600, 'chunk_bytes' => 65536, 'ffprobe' => '/opt/homebrew/bin/ffprobe'], 'followup_audio');
|
||||
$db = \think\facade\Db::class;
|
||||
$db::execute('CREATE TABLE IF NOT EXISTS far_followup_audio_upload (
|
||||
id VARCHAR(64) PRIMARY KEY, diagnosis_id BIGINT NOT NULL, actor_id BIGINT NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL, extension VARCHAR(10) NOT NULL, total_bytes BIGINT NOT NULL,
|
||||
received_bytes BIGINT NOT NULL DEFAULT 0, sha256 VARCHAR(64) NOT NULL DEFAULT "",
|
||||
duration_seconds DECIMAL(12,3) NOT NULL DEFAULT 0, status VARCHAR(32) NOT NULL,
|
||||
created_at BIGINT NOT NULL, expires_at BIGINT NOT NULL) ENGINE=InnoDB');
|
||||
$checks = 0;
|
||||
$ids = [];
|
||||
$ok = function (bool $condition, string $message) use (&$checks): void {
|
||||
if (!$condition) {
|
||||
throw new \RuntimeException($message);
|
||||
}
|
||||
++$checks;
|
||||
};
|
||||
$reject = function (callable $f, string $message) use ($ok): void {
|
||||
try { $f(); } catch (\DomainException $e) { $ok(true, $message); return; }
|
||||
$ok(false, $message);
|
||||
};
|
||||
$upload = \app\common\service\followupaudio\FollowupAudioUpload::class;
|
||||
try {
|
||||
foreach (['../a.wav', 'a/b.wav', 'a\\b.wav', "x\0.mp3", 'x.php', 'x.MP4'] as $bad) {
|
||||
$reject(fn () => $upload::fileName($bad), 'bad filename was accepted');
|
||||
}
|
||||
$ok($upload::fileName('回访.WAV')[1] === 'wav', 'uppercase extension normalization');
|
||||
$reject(fn () => $upload::createSession(92, 'x.wav', 100, 7, []), 'wrong diagnosis');
|
||||
$reject(fn () => $upload::createSession(91, 'x.wav', 0, 7, []), 'empty file');
|
||||
$reject(fn () => $upload::createSession(91, 'x.wav', 524288001, 7, []), 'oversized file');
|
||||
// 3 seconds of valid 16 kHz PCM audio, with actual RIFF metadata (multiple 64KiB test chunks).
|
||||
$pcm = str_repeat(pack('v', 1000), 16000 * 3);
|
||||
$wave = 'RIFF' . pack('V', 36 + strlen($pcm)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16)
|
||||
. 'data' . pack('V', strlen($pcm)) . $pcm;
|
||||
file_put_contents($dir . '/source.wav', $wave);
|
||||
$meta = $upload::inspect($dir . '/source.wav', 'wav');
|
||||
$ok(abs($meta['duration_seconds'] - 3) < 0.01, 'real ffprobe duration');
|
||||
$reject(fn () => $upload::inspect($dir . '/source.wav', 'mp3'), 'extension/content mismatch');
|
||||
file_put_contents($dir . '/fake.wav', '<?php echo "not audio";');
|
||||
$reject(fn () => $upload::inspect($dir . '/fake.wav', 'wav'), 'fake audio was accepted');
|
||||
$session = $upload::createSession(91, '回访.wav', strlen($wave), 7, []);
|
||||
$id = $session['upload_id']; $ids[] = $id;
|
||||
$ok((bool) preg_match('/^[a-f0-9]{48}$/D', $id), 'unguessable session ID');
|
||||
$reject(fn () => $upload::owned($id, 8, []), 'foreign actor');
|
||||
$reject(fn () => $upload::session('../outside'), 'path traversal');
|
||||
$reject(fn () => $upload::complete($id, 7, []), 'incomplete merge');
|
||||
$chunks = str_split($wave, $session['chunk_bytes']);
|
||||
foreach ($chunks as $index => $contents) {
|
||||
file_put_contents($dir . '/part', $contents);
|
||||
$upload::putChunk($id, $index, $dir . '/part', 7, []);
|
||||
$upload::putChunk($id, $index, $dir . '/part', 7, []); // Exact repeat is idempotent.
|
||||
}
|
||||
$ok((int) $upload::session($id)['received_bytes'] === strlen($wave), 'repeated chunks counted twice');
|
||||
file_put_contents($dir . '/part', str_repeat('x', strlen($chunks[0])));
|
||||
$reject(fn () => $upload::putChunk($id, 0, $dir . '/part', 7, []), 'conflicting chunk');
|
||||
$reject(fn () => $upload::putChunk($id, 999, $dir . '/part', 7, []), 'invalid chunk index');
|
||||
$result = $upload::complete($id, 7, []);
|
||||
$ok($result['sha256'] === hash('sha256', $wave), 'assembled bytes differ');
|
||||
$ok($upload::complete($id, 7, []) === $result, 'complete not idempotent');
|
||||
$row = $upload::session($id);
|
||||
$path = $upload::path($row);
|
||||
$ok(file_get_contents($path) === $wave, 'private file mismatch');
|
||||
$response = new \app\common\service\followupaudio\FollowupAudioStream($path, 'wav');
|
||||
$ok($response->getHeader('Cache-Control') === 'private, no-store, max-age=0', 'private response cached');
|
||||
$send = new \ReflectionMethod($response, 'sendData');
|
||||
$send->setAccessible(true); ob_start(); $send->invoke($response, ''); $bytes = ob_get_clean();
|
||||
$ok($bytes === $wave, 'streamed response differs');
|
||||
$reject(fn () => $upload::cleanup($id), 'early cleanup accepted');
|
||||
$db::name('followup_audio_upload')->where('id', $id)->update(['expires_at' => time() - 1]);
|
||||
$reject(fn () => $upload::path($upload::session($id)), 'expired audio still playable');
|
||||
$upload::cleanup($id);
|
||||
$ok(!file_exists($path) && $upload::session($id)['status'] === 'deleted', 'expired original not deleted');
|
||||
$upload::cleanup($id);
|
||||
$ok($upload::session($id)['status'] === 'deleted', 'cleanup repeat is idempotent');
|
||||
$db::name('followup_audio_upload')->where('id', $id)->update(['status' => 'complete']);
|
||||
$upload::cleanup($id);
|
||||
$ok($upload::session($id)['status'] === 'deleted', 'missing directory after DB rollback can be reconciled');
|
||||
echo "Followup audio private upload: {$checks} checks passed\n";
|
||||
} finally {
|
||||
foreach ($ids as $id) { $db::name('followup_audio_upload')->where('id', $id)->delete(); }
|
||||
$files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST);
|
||||
foreach ($files as $f) { $f->isDir() && !$f->isLink() ? rmdir($f->getPathname()) : unlink($f->getPathname()); }
|
||||
rmdir($dir);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user