191 lines
14 KiB
PHP
191 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/** Real ffprobe/ffmpeg + loopback multipart, synthetic SQLite only. No application/.env initialization. */
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
|
|
|
use app\common\service\followupaudio\FollowupAudioDify as Dify;
|
|
use app\common\service\followupaudio\FollowupAudioException as AudioError;
|
|
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
|
|
use think\Container;
|
|
|
|
$directory = sys_get_temp_dir() . '/followup-audio-media-' . bin2hex(random_bytes(6));
|
|
mkdir($directory . '/private', 0700, true);
|
|
new think\App(); // Never initialize real app services or config.
|
|
$pdo = new PDO('sqlite:' . $directory . '/test.sqlite');
|
|
$pdo->exec('CREATE TABLE zyt_dict_data (id INTEGER PRIMARY KEY, type_value TEXT, status INTEGER, sort INTEGER, name TEXT, value TEXT)');
|
|
$pdo->exec('CREATE TABLE zyt_followup_audio_upload (id TEXT PRIMARY KEY, diagnosis_id INT, actor_id INT, file_name TEXT,
|
|
extension TEXT, total_bytes INT, received_bytes INT, sha256 TEXT, duration_seconds REAL, status TEXT, created_at INT, expires_at INT)');
|
|
$manager = new think\DbManager();
|
|
$manager->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => ['type' => 'sqlite',
|
|
'database' => $directory . '/test.sqlite', 'prefix' => 'zyt_']]]);
|
|
Container::getInstance()->instance('think\DbManager', $manager);
|
|
$config = new think\Config();
|
|
$settings = ['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen'],
|
|
'private_dir' => $directory . '/private', 'ffprobe' => 'ffprobe', 'ffmpeg' => 'ffmpeg', 'normalize_timeout' => 120,
|
|
'max_bytes' => 524288000, 'max_seconds' => 3600, 'upstream_max_bytes' => 20971520, 'request_timeout' => 5];
|
|
$config->set($settings, 'followup_audio'); Container::getInstance()->instance('config', $config);
|
|
$socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error);
|
|
$port = (int) substr(strrchr(stream_socket_get_name($socket, false), ':'), 1); fclose($socket);
|
|
$environment = getenv(); $environment['FOLLOWUP_AUDIO_MOCK_DIR'] = $directory;
|
|
$process = proc_open([PHP_BINARY, '-n', '-d', 'upload_max_filesize=24M', '-d', 'post_max_size=25M', '-S', '127.0.0.1:' . $port,
|
|
__DIR__ . '/fixtures/followup_audio/dify_router.php'], [0 => ['pipe', 'r'],
|
|
1 => ['file', $directory . '/server.stdout', 'a'], 2 => ['file', $directory . '/server.stderr', 'a']], $pipes, __DIR__, $environment);
|
|
if (!is_resource($process)) { throw new RuntimeException('MOCK_SERVER_START_FAILED'); } fclose($pipes[0]);
|
|
$checks = 0;
|
|
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
|
|
$expectError = static function (callable $call, string $code, bool $uncertain = false) use ($expect): void {
|
|
try { $call(); } catch (AudioError $e) { $expect($e->errorCode === $code && $e->uncertain === $uncertain,
|
|
'expected ' . $code . ', got ' . $e->errorCode); return; }
|
|
throw new RuntimeException('Expected ' . $code);
|
|
};
|
|
$command = static function (array $args): void {
|
|
$p = proc_open($args, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
|
if (!is_resource($p)) { throw new RuntimeException('TEST_PROCESS_FAILED'); }
|
|
fclose($pipes[0]); $out = stream_get_contents($pipes[1]); $err = stream_get_contents($pipes[2]);
|
|
fclose($pipes[1]); fclose($pipes[2]); $exit = proc_close($p);
|
|
if ($exit !== 0) { throw new RuntimeException('TEST_PROCESS_FAILED exit=' . $exit . ' stderr=' . $err . ' stdout=' . $out); }
|
|
};
|
|
$makeWav = static function (string $path, int $seconds): void {
|
|
$size = 16000 * 2 * $seconds;
|
|
$f = fopen($path, 'xb');
|
|
fwrite($f, 'RIFF' . pack('V', 36 + $size) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16)
|
|
. 'data' . pack('V', $size));
|
|
// Sparse silence: a valid complete waveform, not a forged duration tag, without allocating 115 MB in memory.
|
|
fseek($f, 44 + $size - 1); fwrite($f, "\0"); fclose($f); chmod($path, 0600);
|
|
};
|
|
$makeTask = static function (string $path, string $extension) use ($directory): array {
|
|
$id = bin2hex(random_bytes(24)); $dir = $directory . '/private/' . $id; mkdir($dir . '/parts', 0700, true);
|
|
$target = $dir . '/audio.' . $extension; rename($path, $target); chmod($target, 0600);
|
|
$duration = Upload::inspect($target, $extension)['duration_seconds']; $hash = hash_file('sha256', $target);
|
|
think\facade\Db::name('followup_audio_upload')->insert(['id' => $id, 'diagnosis_id' => 91, 'actor_id' => 7,
|
|
'file_name' => 'synthetic.' . $extension, 'extension' => $extension, 'total_bytes' => filesize($target),
|
|
'received_bytes' => filesize($target), 'sha256' => $hash, 'duration_seconds' => $duration,
|
|
'status' => 'complete', 'created_at' => time(), 'expires_at' => time() + 86400]);
|
|
return ['upstream_ids_json' => '{}', 'id' => 1, 'upload_id' => $id, 'diagnosis_id' => 91, 'actor_id' => 7, 'model_key' => 'qwen',
|
|
'recorded_at' => '2026-09-29 10:00:00', 'sha256' => $hash, 'duration_seconds' => $duration, 'path' => $target];
|
|
};
|
|
$adapter = static function (string $scenario, array $overrides = []) use ($port, $settings): Dify {
|
|
$provider = ['base_url' => 'https://synthetic.invalid/' . $scenario . '/v1',
|
|
'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]];
|
|
$effective = $overrides + $settings;
|
|
// Keep a stable task identity while varying transport scenarios; the test seam owns scenario routing.
|
|
$provider['base_url'] = 'https://synthetic.invalid/v1';
|
|
$effective['providers']['qwen']['verified_fingerprint'] = \app\common\service\followupaudio\FollowupAudioProviderConfig::resolve('qwen', $effective, $provider)['fingerprint'];
|
|
$adapter = null;
|
|
$transport = static function (array $spec, callable $heartbeat) use (&$adapter, $port, $scenario): array {
|
|
$spec['url'] = str_replace('https://synthetic.invalid', 'http://127.0.0.1:' . $port . '/' . $scenario, $spec['url']);
|
|
// Reflection is a test seam only; production cURL still verifies HTTPS certificates.
|
|
$curl = new ReflectionMethod(Dify::class, 'curl');
|
|
return $curl->invoke($adapter, $spec, $heartbeat);
|
|
};
|
|
$adapter = new Dify($transport, $effective, $provider);
|
|
return $adapter;
|
|
};
|
|
$taskFingerprint = \app\common\service\followupaudio\FollowupAudioProviderConfig::resolve('qwen', $settings,
|
|
['base_url' => 'https://synthetic.invalid/v1', 'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]])['fingerprint'];
|
|
$bindTask = static function (array $task) use ($taskFingerprint): array {
|
|
$task['upstream_ids_json'] = json_encode(['provider_fingerprint' => $taskFingerprint]);
|
|
return $task;
|
|
};
|
|
$requests = static fn (): array => is_file($directory . '/requests.jsonl') ? array_map(static fn (string $line): array =>
|
|
json_decode($line, true), file($directory . '/requests.jsonl', FILE_IGNORE_NEW_LINES)) : [];
|
|
try {
|
|
$ready = false;
|
|
for ($i = 0; $i < 100; $i++) {
|
|
$c = @stream_socket_client('tcp://127.0.0.1:' . $port, $errno, $error, 0.1);
|
|
if ($c) { fclose($c); $ready = true; break; } usleep(50000);
|
|
}
|
|
$expect($ready, 'loopback server ready');
|
|
$makeWav($directory . '/source.wav', 3);
|
|
$command(['ffmpeg', '-nostdin', '-hide_banner', '-v', 'error', '-i', $directory . '/source.wav', '-c:a', 'aac', $directory . '/source.m4a']);
|
|
$command(['ffmpeg', '-nostdin', '-hide_banner', '-v', 'error', '-i', $directory . '/source.wav', '-c:a', 'libmp3lame', $directory . '/source.mp3']);
|
|
// 150 complete synthetic AMR-NB mode-7 frames: exactly three seconds. No patient recording.
|
|
file_put_contents($directory . '/source.amr', "#!AMR\n" . str_repeat("\x3c" . str_repeat("\0", 31), 150));
|
|
$tasks = [];
|
|
foreach (['wav', 'm4a', 'mp3', 'amr'] as $extension) {
|
|
$task = $bindTask($makeTask($directory . '/source.' . $extension, $extension)); $tasks[$extension] = $task;
|
|
$events = []; $copies = [];
|
|
$heartbeat = static function (array $fields = []) use (&$events, &$copies, $task): bool {
|
|
$events[] = $fields;
|
|
foreach (glob(dirname($task['path']) . '/processing.*.mp3') ?: [] as $copy) {
|
|
$copies[] = ['path' => $copy, 'mode' => fileperms($copy) & 0777, 'bytes' => filesize($copy)];
|
|
}
|
|
return true;
|
|
};
|
|
$result = $adapter('media-' . $extension)->analyze($task, $heartbeat);
|
|
$uploads = array_values(array_filter($requests(), static fn (array $r): bool => $r['scenario'] === 'media-' . $extension && $r['upload']));
|
|
$expect(count($uploads) === 1 && count($result['items']) === 1, $extension . ' actual multipart accepted and factual review returned');
|
|
$expect(hash_file('sha256', $task['path']) === $task['sha256'], $extension . ' pristine original retained');
|
|
$expect(glob(dirname($task['path']) . '/processing.*') === [], $extension . ' processing copy removed after success');
|
|
if ($extension === 'amr') {
|
|
$expect(abs($task['duration_seconds'] - 3) < 0.001, 'AMR duration uses complete packet count, not bitrate estimate');
|
|
$expect($uploads[0]['mime'] === 'audio/mpeg' && $uploads[0]['filename'] === 'followup-audio.mp3', 'AMR uses compatible MP3 processing copy');
|
|
$expect($copies !== [] && array_unique(array_column($copies, 'mode')) === [0600], 'processing copy private 0600');
|
|
} else {
|
|
$expect($uploads[0]['sha256'] === $task['sha256'], $extension . ' small compatible original unchanged in actual request');
|
|
}
|
|
}
|
|
$wave = $tasks['wav'];
|
|
foreach (['missing-ffmpeg', 'limit', 'timeout', 'truncated', 'lease'] as $failure) {
|
|
$before = count($requests()); $events = []; $extra = ['upstream_max_bytes' => 20000];
|
|
$callback = static function (array $fields = []) use (&$events): bool { $events[] = $fields; return true; };
|
|
$code = 'AUDIO_NORMALIZATION_FAILED';
|
|
if ($failure === 'missing-ffmpeg') { $extra['ffmpeg'] = $directory . '/missing-tool'; }
|
|
if ($failure === 'limit') { $extra['upstream_max_bytes'] = 1; $code = 'UPSTREAM_AUDIO_LIMIT'; }
|
|
if ($failure === 'timeout') {
|
|
$extra['ffmpeg'] = $directory . '/slow-ffmpeg'; $extra['normalize_timeout'] = 1;
|
|
file_put_contents($extra['ffmpeg'], "#!/bin/sh\nexec sleep 5\n"); chmod($extra['ffmpeg'], 0700); $code = 'AUDIO_NORMALIZATION_TIMEOUT';
|
|
}
|
|
if ($failure === 'truncated') {
|
|
$makeWav($directory . '/fragment.wav', 1);
|
|
$command(['ffmpeg', '-nostdin', '-hide_banner', '-v', 'error', '-i', $directory . '/fragment.wav',
|
|
'-c:a', 'libmp3lame', '-ar', '16000', '-b:a', '32k', $directory . '/fragment.mp3']);
|
|
$extra['ffmpeg'] = $directory . '/truncated-ffmpeg';
|
|
file_put_contents($extra['ffmpeg'], "#!/bin/sh\nfor last; do :; done\ncp " . escapeshellarg($directory . '/fragment.mp3') . ' "$last"' . "\n");
|
|
chmod($extra['ffmpeg'], 0700);
|
|
}
|
|
if ($failure === 'lease') { $callback = static fn (): bool => false; $code = 'LEASE_LOST'; }
|
|
$expectError(static fn () => $adapter('media-failure', $extra)->analyze($wave, $callback), $code);
|
|
$expect(count($requests()) === $before && !array_filter($events, static fn (array $row): bool => isset($row['upstream_started_at'])),
|
|
$failure . ' local failure never uploads or creates upstream intent');
|
|
$expect(glob(dirname($wave['path']) . '/processing.*') === [] && hash_file('sha256', $wave['path']) === $wave['sha256'],
|
|
$failure . ' cleanup preserves exact original');
|
|
}
|
|
$before = count($requests());
|
|
$expectError(static fn () => $adapter('unknown', ['upstream_max_bytes' => 20000])->analyze($wave, static fn (): bool => true), 'UPSTREAM_UNCERTAIN', true);
|
|
$expect(count($requests()) === $before + 2 && glob(dirname($wave['path']) . '/processing.*') === [],
|
|
'unknown HTTP result is not resent and processing copy is erased');
|
|
$expect(hash_file('sha256', $wave['path']) === $wave['sha256'], 'unknown result leaves original intact');
|
|
$makeWav($directory . '/long.wav', 3600); $long = $bindTask($makeTask($directory . '/long.wav', 'wav'));
|
|
$copyMetadata = [];
|
|
$adapter('media-long')->analyze($long, static function (array $fields = []) use ($long, &$copyMetadata): bool {
|
|
if (isset($fields['upstream_started_at'])) {
|
|
foreach (glob(dirname($long['path']) . '/processing.*.mp3') ?: [] as $copy) {
|
|
$copyMetadata = ['bytes' => filesize($copy), 'mode' => fileperms($copy) & 0777];
|
|
$p = proc_open(['ffprobe', '-v', 'error', '-show_entries', 'format=duration:stream=codec_name,sample_rate,channels',
|
|
'-of', 'json', $copy], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
|
fclose($pipes[0]); $copyMetadata += json_decode(stream_get_contents($pipes[1]), true);
|
|
stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); proc_close($p);
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
$uploads = array_values(array_filter($requests(), static fn (array $r): bool => $r['scenario'] === 'media-long' && $r['upload']));
|
|
$expect(filesize($long['path']) > 52428800 && hash_file('sha256', $long['path']) === $long['sha256'], 'one-hour >50MiB original preserved byte-for-byte');
|
|
$expect(count($uploads) === 1 && $uploads[0]['size'] <= 20971520 && $uploads[0]['mime'] === 'audio/mpeg', 'entire hour uploaded once inside conservative 20MiB budget');
|
|
$expect(abs((float) $copyMetadata['format']['duration'] - 3600) < 0.25 && $copyMetadata['mode'] === 0600
|
|
&& $copyMetadata['streams'][0]['codec_name'] === 'mp3' && (int) $copyMetadata['streams'][0]['channels'] === 1
|
|
&& (int) $copyMetadata['streams'][0]['sample_rate'] === 16000, 'full-duration mono16k processing copy independently inspected before transport');
|
|
$expect(glob(dirname($long['path']) . '/processing.*') === [], 'hour processing copy removed');
|
|
echo 'FOLLOWUP_AUDIO_MEDIA assertions=' . $checks . ' PASS formats=MP3,M4A,WAV,AMR original_retained=1 hour_complete=1 bounded_private_copy=1 failure_no_send=1' . PHP_EOL;
|
|
} finally {
|
|
proc_terminate($process); proc_close($process);
|
|
$manager->connect()->close(); $pdo = null;
|
|
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
|
|
foreach ($files as $file) { $file->isDir() && !$file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname()); }
|
|
rmdir($directory);
|
|
}
|