547 lines
51 KiB
PHP
547 lines
51 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/** Local disposable MySQL only; NEVER initialize the application or load its .env/config/database.php. */
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
|
|
|
use app\common\service\followupaudio\FollowupAudioApply as Apply;
|
|
use app\common\service\followupaudio\FollowupAudioFields as Fields;
|
|
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
|
|
use app\common\service\followupaudio\FollowupAudioProviderConfig as ProviderConfig;
|
|
use app\common\service\followupaudio\FollowupAudioStore as Store;
|
|
use think\Container;
|
|
use think\facade\Db;
|
|
|
|
$port = (int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT');
|
|
if ($port <= 0 || getenv('FOLLOWUP_AUDIO_TEST_ALLOW_DISPOSABLE') !== '1') {
|
|
throw new RuntimeException('Explicit local disposable MySQL port and FOLLOWUP_AUDIO_TEST_ALLOW_DISPOSABLE=1 required');
|
|
}
|
|
$mode = $argv[1] ?? '';
|
|
$child = in_array($mode, ['--claim', '--apply', '--create'], true);
|
|
$database = $child ? (string) getenv('FOLLOWUP_AUDIO_TEST_DATABASE') : 'fa_core_' . bin2hex(random_bytes(6));
|
|
if (!preg_match('/^fa_core_[a-f0-9]{12}$/D', $database)) { throw new RuntimeException('Disposable test database name required'); }
|
|
$pdo = new PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', (string) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PASSWORD'),
|
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
|
if (!$child) { $pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4"); }
|
|
$pdo->exec("USE `{$database}`");
|
|
$app = new think\App(); // no initialize(): no environment/config discovery.
|
|
$manager = new think\DbManager();
|
|
$manager->setConfig(['default' => 'mysql', 'auto_timestamp' => true, 'datetime_format' => false,
|
|
'connections' => ['mysql' => ['type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port,
|
|
'database' => $database, 'username' => 'root', 'password' => (string) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PASSWORD'),
|
|
'charset' => 'utf8mb4', 'prefix' => 'zyt_', 'fields_strict' => true, 'trigger_sql' => false]]]);
|
|
Container::getInstance()->instance('think\DbManager', $manager);
|
|
$config = new think\Config();
|
|
$private = $child ? (string) getenv('FOLLOWUP_AUDIO_TEST_PRIVATE') : '/private/tmp/' . $database;
|
|
$config->set(['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen', 'openai'], 'encryption_key' => str_repeat('synthetic-test-key-', 4),
|
|
'lease_seconds' => 60, 'concurrency' => 1, 'retention_days' => 90, 'private_dir' => $private], 'followup_audio');
|
|
Container::getInstance()->instance('config', $config);
|
|
// Explicit synthetic provider identity: no live credentials, application .env or external requests.
|
|
$testProviders = [];
|
|
foreach (['qwen', 'openai'] as $profile) {
|
|
$testProviders[$profile] = ['driver' => 'dify', 'base_url' => 'https://synthetic.invalid/v1',
|
|
'api_key' => 'synthetic-test-key', 'model' => 'synthetic-audio', 'label' => 'Synthetic ' . $profile];
|
|
}
|
|
$config->set(['providers' => $testProviders], 'followup_audio');
|
|
foreach (array_keys($testProviders) as $profile) {
|
|
$testProviders[$profile]['verified_fingerprint'] = ProviderConfig::resolve($profile)['fingerprint'];
|
|
}
|
|
$config->set(['providers' => $testProviders], 'followup_audio');
|
|
$root = ['root' => 1, 'admin_id' => 1, 'id' => 1, 'name' => 'Synthetic'];
|
|
if ($mode === '--claim') { echo json_encode(['id' => Store::claim()['id'] ?? null]) . "\n"; exit(0); }
|
|
if ($mode === '--create') {
|
|
$result = Store::create(['id' => $argv[2]], $argv[3], 'qwen', 1, $root);
|
|
echo json_encode(['id' => $result['task_id'], 'reused' => $result['reused']]) . "\n";
|
|
exit(0);
|
|
}
|
|
if ($mode === '--apply') {
|
|
$result = Apply::apply((int) $argv[2], (int) $argv[3], json_decode(file_get_contents($argv[4]), true, 512, JSON_THROW_ON_ERROR), 1, $root);
|
|
echo json_encode(['status' => $result['status'], 'ids' => array_column($result['applied_items'], 'record_id')]) . "\n";
|
|
exit(0);
|
|
}
|
|
$checks = 0;
|
|
$expect = static function (bool $ok, string $message) use (&$checks): void {
|
|
if (!$ok) { throw new RuntimeException($message); }
|
|
$checks++;
|
|
};
|
|
$reject = static function (callable $call, string $code) use ($expect): void {
|
|
try { $call(); } catch (Throwable $e) { $expect(str_contains($e->getMessage(), $code), 'Expected ' . $code . ', observed ' . $e->getMessage()); return; }
|
|
throw new RuntimeException('Expected rejection: ' . $code);
|
|
};
|
|
$runChildren = static function (array $arguments) use ($database, $private): array {
|
|
$jobs = [];
|
|
$env = array_merge(getenv(), ['FOLLOWUP_AUDIO_TEST_DATABASE' => $database, 'FOLLOWUP_AUDIO_TEST_PRIVATE' => $private]);
|
|
foreach ($arguments as $args) {
|
|
$pipes = [];
|
|
$process = proc_open(array_merge([PHP_BINARY, __FILE__], $args), [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, $env);
|
|
if (!is_resource($process)) { throw new RuntimeException('Child process unavailable'); }
|
|
fclose($pipes[0]);
|
|
$jobs[] = [$process, $pipes];
|
|
}
|
|
$results = [];
|
|
foreach ($jobs as [$process, $pipes]) {
|
|
$stdout = stream_get_contents($pipes[1]); $stderr = stream_get_contents($pipes[2]);
|
|
fclose($pipes[1]); fclose($pipes[2]); $exit = proc_close($process);
|
|
if ($exit !== 0 || $stderr !== '') { throw new RuntimeException('Child exit=' . $exit . ' stderr=' . $stderr . ' stdout=' . $stdout); }
|
|
$results[] = json_decode(trim($stdout), true, 512, JSON_THROW_ON_ERROR);
|
|
}
|
|
return $results;
|
|
};
|
|
try {
|
|
mkdir($private, 0700, true);
|
|
file_put_contents($private . '/unrelated-sentinel', 'KEEP');
|
|
$pdo->exec('CREATE TABLE zyt_admin(id INT PRIMARY KEY,name VARCHAR(50),root INT,disable INT,delete_time INT NULL)');
|
|
$pdo->exec("INSERT INTO zyt_admin VALUES(1,'Synthetic Root',1,0,NULL),(2,'Synthetic Assistant',0,0,NULL),(3,'Synthetic Limited',0,0,NULL)");
|
|
$pdo->exec('CREATE TABLE zyt_admin_role(admin_id INT,role_id INT)');
|
|
$pdo->exec('INSERT INTO zyt_admin_role VALUES(2,2),(3,5)');
|
|
$pdo->exec('CREATE TABLE zyt_admin_dept(admin_id INT,dept_id INT)');
|
|
$pdo->exec('CREATE TABLE zyt_admin_jobs(admin_id INT,jobs_id INT)');
|
|
$pdo->exec('CREATE TABLE zyt_system_menu(id INT PRIMARY KEY,perms VARCHAR(100),is_disable INT)');
|
|
$pdo->exec("INSERT INTO zyt_system_menu VALUES(1,'tcm.diagnosis/edit',0),(2,'tcm.diagnosis/dailyRecord',0),(3,'firstvisit.wecomPromotion/overview',0)");
|
|
$pdo->exec('CREATE TABLE zyt_system_role_menu(role_id INT,menu_id INT)');
|
|
$pdo->exec('INSERT INTO zyt_system_role_menu VALUES(2,1),(2,2),(2,3),(5,1),(5,3)');
|
|
$pdo->exec('CREATE TABLE zyt_doctor_appointment(id INT PRIMARY KEY,patient_id INT,doctor_id INT,status INT)');
|
|
$pdo->exec('CREATE TABLE zyt_dict_data(id INT PRIMARY KEY AUTO_INCREMENT,name VARCHAR(50),value VARCHAR(50),type_value VARCHAR(50),status INT,sort INT)');
|
|
$pdo->exec("INSERT INTO zyt_dict_data(name,value,type_value,status,sort) VALUES('Synthetic Enabled','dry','appetite',1,1),('Synthetic Disabled','bad','appetite',0,2)");
|
|
$pdo->exec('CREATE TABLE zyt_tcm_diagnosis(id INT PRIMARY KEY,patient_id INT,assistant_id INT DEFAULT 2,status INT DEFAULT 1,delete_time INT NULL,update_time INT DEFAULT 0) ENGINE=InnoDB');
|
|
foreach (Fields::diagnosisKeys() as $key) { $pdo->exec("ALTER TABLE zyt_tcm_diagnosis ADD `{$key}` TEXT NULL"); }
|
|
$pdo->exec("INSERT INTO zyt_tcm_diagnosis(id,patient_id,patient_name,assistant_id,symptoms) VALUES(1,101,'Synthetic A',2,''),(2,202,'Synthetic B',3,''),(3,303,'Synthetic C',2,'')");
|
|
$pdo->exec('CREATE TABLE zyt_tcm_prescription_order(id INT PRIMARY KEY,diagnosis_id INT,creator_id INT,create_time INT,fulfillment_status INT,delete_time INT NULL,KEY idx_diagnosis(diagnosis_id)) ENGINE=InnoDB');
|
|
$pdo->exec('CREATE TABLE zyt_tcm_blood_record(id INT PRIMARY KEY AUTO_INCREMENT,diagnosis_id INT,patient_id INT,record_date INT,record_time VARCHAR(10),source INT DEFAULT 0,create_time INT DEFAULT 0,update_time INT DEFAULT 0,delete_time INT NULL,KEY idx_day(diagnosis_id,record_date)) ENGINE=InnoDB');
|
|
$pdo->exec('CREATE TABLE zyt_patient_diet_record(id INT PRIMARY KEY AUTO_INCREMENT,diagnosis_id INT,patient_id INT,record_date INT,create_time INT DEFAULT 0,update_time INT DEFAULT 0,delete_time INT NULL,KEY idx_day(diagnosis_id,record_date)) ENGINE=InnoDB');
|
|
$pdo->exec('CREATE TABLE zyt_patient_exercise_record(id INT PRIMARY KEY AUTO_INCREMENT,diagnosis_id INT,patient_id INT,record_date INT,create_time INT DEFAULT 0,update_time INT DEFAULT 0,delete_time INT NULL,KEY idx_day(diagnosis_id,record_date)) ENGINE=InnoDB');
|
|
foreach (['blood' => 'tcm_blood_record', 'diet' => 'patient_diet_record', 'exercise' => 'patient_exercise_record'] as $kind => $table) {
|
|
$keys = $kind === 'diet' ? ['breakfast_foods', 'lunch_foods', 'dinner_foods', 'note',
|
|
'breakfast_images', 'lunch_images', 'dinner_images'] : Fields::keys($kind);
|
|
foreach ($keys as $key) { $pdo->exec("ALTER TABLE zyt_{$table} ADD `{$key}` TEXT NULL"); }
|
|
}
|
|
$pdo->exec('CREATE TABLE zyt_tracking_note(id INT PRIMARY KEY AUTO_INCREMENT,diagnosis_id INT,admin_id INT,note_date DATE,content TEXT,create_time INT DEFAULT 0,update_time INT DEFAULT 0,delete_time INT NULL,UNIQUE KEY uk_day(diagnosis_id,note_date)) ENGINE=InnoDB');
|
|
$migration = preg_replace('/^--.*$/m', '', file_get_contents(dirname(__DIR__) . '/sql/2026_09_29_followup_audio.sql'));
|
|
for ($i = 0; $i < 2; $i++) {
|
|
foreach (explode(';', $migration) as $statement) { if (trim($statement) !== '') { $stmt = $pdo->query($statement); $stmt->closeCursor(); } }
|
|
}
|
|
$expect((int) Db::name('followup_audio_mutex')->count() === 2, 'Migration is rerunnable');
|
|
$expect(count(array_filter(Fields::catalog()['diagnosis'], static fn ($f) => $f['key'] === 'status')) === 0, 'Ownership/status fields are not writable');
|
|
$catalog = array_column(Fields::catalog()['diagnosis'], null, 'key');
|
|
$expect(count($catalog['appetite']['options']) === 1, 'Only active dictionary options');
|
|
$reject(fn () => Fields::validateValues('diagnosis', ['height' => 175.5]), 'NUMBER_INVALID');
|
|
$reject(fn () => Fields::validateValues('diagnosis', ['weight' => 1000]), 'NUMBER_INVALID');
|
|
$expect(Fields::validateValues('diagnosis', ['gender' => 0]) === ['gender' => 0], 'Explicit zero is preserved');
|
|
$reject(fn () => Fields::validateValues('diagnosis', ['patient_id' => 7]), 'FIELD_INVALID');
|
|
$reject(fn () => Fields::validateValues('diagnosis', ['appetite' => ['bad']]), 'OPTION_INVALID');
|
|
$reject(fn () => Fields::validateValues('blood', ['fasting_blood_sugar' => NAN]), 'NUMBER_INVALID');
|
|
$reject(fn () => Fields::validateValues('blood', ['fasting_blood_sugar' => 7.123]), 'NUMBER_INVALID');
|
|
$reject(fn () => Fields::validateValues('diagnosis', ['diagnosis_date' => '2026-02-30']), 'DATE_INVALID');
|
|
$reject(fn () => Policy::strictTime('25:00'), 'TIME_INVALID');
|
|
$expect(Policy::strictTime('13:41:59') === '13:41', 'Valid seconds input canonicalized to minute precision');
|
|
$recordedAt = '2026-09-20 20:00:00';
|
|
$event = static fn (string $kind, array $values, string $quote, array $extra = []): array => array_replace([
|
|
'kind' => $kind, 'values' => $values, 'date_text' => '昨天', 'time_text' => '', 'record_time' => '08:00',
|
|
'evidence' => [['text' => $quote]],
|
|
], $extra);
|
|
$extract = static fn (array $items): array => ['summary' => 'Synthetic follow-up summary',
|
|
'transcript' => implode(' ', array_map(static fn ($x) => $x['evidence'][0]['text'] ?? '', $items)), 'uncertainties' => [], 'items' => $items];
|
|
$norm = Policy::normalizeExtraction($extract([
|
|
$event('blood', ['fasting_blood_sugar' => 6.1], 'synthetic first 6.1'),
|
|
$event('blood', ['fasting_blood_sugar' => 6.1], 'synthetic second 6.1'),
|
|
$event('blood', ['fasting_blood_sugar' => 6.1], 'synthetic first 6.1'),
|
|
$event('blood', ['systolic_pressure' => 120], 'synthetic yesterday noon', ['record_time' => null, 'time_period' => 'noon']),
|
|
$event('blood', ['diastolic_pressure' => 80], 'synthetic no date', ['date_text' => '', 'record_time' => null]),
|
|
$event('diagnosis', ['allergy_history' => 0], 'synthetic explicitly no allergy'),
|
|
]), $recordedAt);
|
|
$expect(count($norm['items']) === 5, 'Exact same event evidence deduped; equal values with distinct evidence retained');
|
|
$expect($norm['items'][0]['record_date'] === '2026-09-19', 'Relative dates use recordedAt, not execution day');
|
|
$expect($norm['items'][2]['record_time'] === '12:00' && $norm['items'][2]['time_period'] === '中午' && $norm['items'][2]['time_estimated'], 'Approved noon default is retained as an estimated clock');
|
|
$expect($norm['items'][3]['record_date'] === null && $norm['items'][3]['needs_review'], 'Missing date stays unresolved');
|
|
$expect($norm['items'][4]['values'] === ['allergy_history' => 0] && !isset($norm['items'][4]['values']['family_history']), 'Missing history is not normal');
|
|
foreach (['morning' => '08:00', 'noon' => '12:00', 'afternoon' => '15:00', 'evening' => '20:00', 'bedtime' => '22:00'] as $period => $clock) {
|
|
$periodResult = Policy::normalizeExtraction($extract([
|
|
$event('blood', ['systolic_pressure' => 120], 'synthetic first ' . $period, ['record_time' => null, 'time_period' => $period]),
|
|
$event('blood', ['systolic_pressure' => 120], 'synthetic second ' . $period, ['record_time' => null, 'time_period' => $period]),
|
|
]), $recordedAt);
|
|
$expect(count($periodResult['items']) === 2 && $periodResult['items'][0]['record_time'] === $clock
|
|
&& $periodResult['items'][0]['time_estimated'] && $periodResult['items'][0]['needs_review'],
|
|
'Approved estimated ' . $period . ' clock never collapses distinct equal measurements');
|
|
}
|
|
$ambiguous = Policy::normalizeExtraction($extract([$event('blood', ['systolic_pressure' => 120], 'synthetic range',
|
|
['date_text' => '昨天或前天', 'record_date' => '2026-09-19'])]), $recordedAt);
|
|
$expect($ambiguous['items'][0]['record_date'] === null && $ambiguous['items'][0]['needs_review'],
|
|
'Explicit model date cannot resolve ambiguous spoken range');
|
|
$mismatch = Policy::normalizeExtraction($extract([$event('blood', ['systolic_pressure' => 120], 'synthetic conflicting date', ['record_date' => '2026-09-18'])]), $recordedAt);
|
|
$expect($mismatch['items'][0]['record_date'] === null, 'Conflicting date evidence left unresolved');
|
|
$upload = static function (int $diagnosisId = 1, int $actor = 1, ?array $copy = null) use ($private): array {
|
|
$id = bin2hex(random_bytes(24)); $dir = $private . '/' . $id;
|
|
mkdir($dir . '/parts', 0700, true);
|
|
$pcm = str_repeat("\0", 1536) . random_bytes(64); $wav = 'RIFF' . pack('V', 36 + strlen($pcm)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 8000, 16000, 2, 16) . 'data' . pack('V', strlen($pcm)) . $pcm;
|
|
if ($copy !== null) { $wav = file_get_contents($private . '/' . $copy['id'] . '/audio.wav'); }
|
|
file_put_contents($dir . '/audio.wav', $wav); chmod($dir . '/audio.wav', 0600);
|
|
$row = ['id' => $id, 'diagnosis_id' => $diagnosisId, 'actor_id' => $actor, 'file_name' => 'synthetic.wav', 'total_bytes' => strlen($wav),
|
|
'received_bytes' => strlen($wav), 'sha256' => hash('sha256', $wav), 'duration_seconds' => 0.1, 'extension' => 'wav',
|
|
'status' => 'complete', 'created_at' => time(), 'expires_at' => time() + 86400];
|
|
Db::name('followup_audio_upload')->insert($row); return $row;
|
|
};
|
|
$firstUpload = $upload();
|
|
$config->set(['enabled' => false], 'followup_audio');
|
|
$reject(fn () => Store::create($firstUpload, $recordedAt, 'qwen', 1, $root), 'DISABLED_OR_UNVERIFIED');
|
|
$expect(Store::claim() === null, 'Disabled gate blocks queue');
|
|
$config->set(['enabled' => true, 'audio_verified' => false], 'followup_audio');
|
|
$reject(fn () => Store::create($firstUpload, $recordedAt, 'qwen', 1, $root), 'DISABLED_OR_UNVERIFIED');
|
|
$config->set(['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen', 'openai'], 'encryption_key' => str_repeat('synthetic-test-key-', 4),
|
|
'lease_seconds' => 60, 'concurrency' => 1, 'retention_days' => 90, 'private_dir' => $private], 'followup_audio');
|
|
$config->set(['verified_profiles' => []], 'followup_audio');
|
|
$expect(!Store::verified() && !Store::verified('qwen'), 'No profile is implicitly verified');
|
|
$config->set(['verified_profiles' => ['qwen']], 'followup_audio');
|
|
$expect(Store::verified('qwen') && !Store::verified('openai'), 'Verification is profile-specific');
|
|
$unboundProviders = $testProviders; $unboundProviders['qwen']['verified_fingerprint'] = '';
|
|
$config->set(['providers' => $unboundProviders], 'followup_audio');
|
|
$expect(!Store::verified('qwen'), 'Flags alone cannot verify an unbound provider');
|
|
$config->set(['providers' => $testProviders], 'followup_audio');
|
|
$reject(fn () => Store::create($firstUpload, $recordedAt, 'openai', 1, $root), 'DISABLED_OR_UNVERIFIED');
|
|
$config->set(['verified_profiles' => ['qwen', 'openai']], 'followup_audio');
|
|
$a = Store::create($firstUpload, $recordedAt, 'qwen', 1, $root);
|
|
$binding = json_decode(Store::task($a['task_id'])['upstream_ids_json'], true, 16, JSON_THROW_ON_ERROR);
|
|
$expect($binding === ['provider_fingerprint' => ProviderConfig::resolve('qwen')['fingerprint']],
|
|
'New task persists only immutable provider fingerprint, not credentials or endpoint');
|
|
$expect(Store::create($firstUpload, $recordedAt, 'qwen', 1, $root)['task_id'] === $a['task_id'], 'Repeated create does not enqueue duplicate upstream work');
|
|
$reject(fn () => Store::create($firstUpload, $recordedAt, 'openai', 1, $root), 'UPLOAD_ALREADY_USED');
|
|
$differentUpload = $upload(1, 1, $firstUpload);
|
|
$sameContent = Store::create($differentUpload, '2026-09-19 20:00:00', 'qwen', 1, $root);
|
|
$expect($sameContent['task_id'] === $a['task_id'] && $sameContent['reused'] && str_contains($sameContent['reuse_message'], '更正记录日期'),
|
|
'New upload/name/recordedAt cannot bypass content idempotency');
|
|
$expect(Store::task($a['task_id'])['upload_id'] === $firstUpload['id'] && Db::name('followup_audio_task')->where('upload_id', $differentUpload['id'])->count() === 0,
|
|
'Reuse keeps original audio and leaves duplicate staging upload unreferenced');
|
|
$b = Store::create($upload(), $recordedAt, 'qwen', 1, $root);
|
|
$claims = $runChildren([['--claim'], ['--claim']]);
|
|
$expect(count(array_filter(array_column($claims, 'id'))) === 1, 'Two real PHP workers obey global concurrency mutex: ' . json_encode($claims));
|
|
$running = Db::name('followup_audio_task')->where('status', 'running')->find();
|
|
$expect(!Store::heartbeat((int) $running['id'], 'forged-token'), 'Lease rejects stale/foreign token');
|
|
$expect(!Store::complete((int) $running['id'], 'forged-token', []), 'Completion token fenced');
|
|
$expect(Store::heartbeat((int) $running['id'], $running['lease_token']), 'Current heartbeat accepted');
|
|
$expect(Store::checkpoint((int) $running['id'], $running['lease_token'], ['stage' => 'uploading', 'upstream_started_at' => time(),
|
|
'upstream_ids_json' => ['request_id' => 'opaque-test-request']]), 'Upstream started checkpoint persisted');
|
|
$reject(fn () => Store::checkpoint((int) $running['id'], $running['lease_token'], ['api_key' => 'forbidden']), 'CHECKPOINT_INVALID');
|
|
$savedIds = json_decode(Store::task((int) $running['id'])['upstream_ids_json'], true, 16, JSON_THROW_ON_ERROR);
|
|
$expect($savedIds['provider_fingerprint'] === $binding['provider_fingerprint'] && $savedIds['request_id'] === 'opaque-test-request',
|
|
'Normal upstream checkpoints preserve immutable fingerprint');
|
|
foreach ([str_repeat('0', 64), '', null] as $replacement) {
|
|
$reject(fn () => Store::checkpoint((int) $running['id'], $running['lease_token'],
|
|
['upstream_ids_json' => ['provider_fingerprint' => $replacement]]), 'CHECKPOINT_INVALID');
|
|
}
|
|
$expect(Store::checkpoint((int) $running['id'], $running['lease_token'], ['upstream_ids_json' => '{}'])
|
|
&& json_decode(Store::task((int) $running['id'])['upstream_ids_json'], true)['provider_fingerprint'] === $binding['provider_fingerprint'],
|
|
'An empty checkpoint cannot delete task binding');
|
|
$expect(Store::checkpoint((int) $running['id'], $running['lease_token'], ['upstream_ids_json' => $binding]),
|
|
'Transport may echo exactly the original immutable fingerprint');
|
|
Db::name('followup_audio_task')->where('id', $running['id'])->update(['lease_until' => time() - 1]);
|
|
$other = Store::claim();
|
|
$expect(Store::task((int) $running['id'])['status'] === 'needs_reconciliation', 'Expired attempted request cannot be resubmitted');
|
|
$reject(fn () => Store::retry((int) $running['id']), 'RETRY_NOT_SAFE');
|
|
$uncertainUpload = Db::name('followup_audio_upload')->where('id', $running['upload_id'])->find();
|
|
$uncertainCopy = Store::create($upload(1, 1, $uncertainUpload), $recordedAt, 'qwen', 1, $root);
|
|
$expect($uncertainCopy['task_id'] === (int) $running['id'] && $uncertainCopy['reused'] && $uncertainCopy['status'] === 'needs_reconciliation',
|
|
'Unknown upstream result cannot be bypassed by re-upload');
|
|
$expect(!Store::heartbeat((int) $running['id'], $running['lease_token']), 'Expired worker cannot renew after fence');
|
|
$expect(Store::fail((int) $other['id'], $other['lease_token'], 'LOCAL_PROBE_FAILED', 'sensitive text MUST NOT persist'), 'Pre-network failure recorded');
|
|
$expect(!str_contains(json_encode(Store::task((int) $other['id'])), 'MUST NOT'), 'Error bodies never persisted');
|
|
$expect(Store::retry((int) $other['id'])['status'] === 'queued', 'Verified no-attempt local failure may retry');
|
|
$retried = Store::claim(); Store::fail((int) $retried['id'], $retried['lease_token'], 'LOCAL_PROBE_FAILED', '');
|
|
$parallelUploadA = $upload(); $parallelUploadB = $upload(1, 1, $parallelUploadA);
|
|
$parallelCreated = $runChildren([['--create', $parallelUploadA['id'], $recordedAt], ['--create', $parallelUploadB['id'], $recordedAt]]);
|
|
$expect($parallelCreated[0]['id'] === $parallelCreated[1]['id'] && count(array_filter(array_column($parallelCreated, 'reused'))) === 1,
|
|
'Two real concurrent creates for separate uploads of identical audio produce one task');
|
|
$parallelClaim = Store::claim();
|
|
$expect((int) $parallelClaim['id'] === $parallelCreated[0]['id'], 'Concurrent dedupe leaves exactly one queued upstream operation');
|
|
Store::fail((int) $parallelClaim['id'], $parallelClaim['lease_token'], 'LOCAL_PROBE_FAILED', '');
|
|
// Queue bindings survive model switches; changed configuration never silently reroutes old audio.
|
|
$oldTask = Store::create($upload(), $recordedAt, 'qwen', 1, $root);
|
|
Db::name('followup_audio_task')->where('id', $oldTask['id'])->update(['upstream_ids_json' => '{}']);
|
|
$expect(Store::claim() === null, 'Legacy task without fingerprint is never claimed');
|
|
$legacy = Store::task($oldTask['id']);
|
|
$expect($legacy['status'] === 'failed' && $legacy['error_code'] === 'PROVIDER_CONFIGURATION_CHANGED'
|
|
&& (int) $legacy['attempts'] === 0 && !Store::summary($legacy)['can_retry'], 'Legacy binding failure is visible and cannot retry');
|
|
$changedQueued = Store::create($upload(), $recordedAt, 'qwen', 1, $root);
|
|
$changedProviders = $testProviders; $changedProviders['qwen']['model'] = 'synthetic-changed-model';
|
|
$config->set(['providers' => $changedProviders], 'followup_audio');
|
|
$expect(!Store::verified('qwen') && Store::claim() === null, 'Changed unverified model cannot consume queued audio');
|
|
$changedRow = Store::task($changedQueued['id']);
|
|
$expect($changedRow['status'] === 'failed' && $changedRow['error_code'] === 'PROVIDER_CONFIGURATION_CHANGED'
|
|
&& (int) $changedRow['upstream_started_at'] === 0, 'Configuration drift fails before upstream intent');
|
|
$changedProviders['qwen']['verified_fingerprint'] = ProviderConfig::resolve('qwen')['fingerprint'];
|
|
$config->set(['providers' => $changedProviders], 'followup_audio');
|
|
$expect(Store::verified('qwen') && !Store::summary($changedRow)['can_retry'], 'Reverified new provider does not unlock old task retry');
|
|
$providerMismatch = false;
|
|
try { Store::retry($changedQueued['id']); }
|
|
catch (\app\common\service\followupaudio\FollowupAudioException $error) { $providerMismatch = $error->errorCode === 'PROVIDER_CONFIGURATION_CHANGED'; }
|
|
$expect($providerMismatch, 'Retry reports stable provider-binding error rather than rerouting');
|
|
$config->set(['providers' => $testProviders], 'followup_audio');
|
|
$expect(Store::retry($changedQueued['id'])['status'] === 'queued', 'Explicit restoration of exact original provider allows safe pre-request retry');
|
|
$restored = Store::claim();
|
|
Store::fail((int) $restored['id'], $restored['lease_token'], 'LOCAL_PROBE_FAILED', '');
|
|
$switchUpload = $upload(); $switchTask = Store::create($switchUpload, $recordedAt, 'qwen', 1, $root);
|
|
$switchClaim = Store::claim();
|
|
Store::checkpoint($switchTask['id'], $switchClaim['lease_token'], ['upstream_started_at' => time()]);
|
|
$config->set(['providers' => $changedProviders], 'followup_audio');
|
|
$expect(!Store::heartbeat($switchTask['id'], $switchClaim['lease_token'])
|
|
&& !Store::checkpoint($switchTask['id'], $switchClaim['lease_token'], ['stage' => 'analyzing'])
|
|
&& !Store::complete($switchTask['id'], $switchClaim['lease_token'], []),
|
|
'Heartbeat checkpoint and complete independently fence provider drift');
|
|
$config->set(['enabled' => false, 'audio_verified' => false], 'followup_audio');
|
|
$expect(Store::fail($switchTask['id'], $switchClaim['lease_token'], 'PROVIDER_CONFIGURATION_CHANGED', 'must not persist', false),
|
|
'Configuration and feature withdrawal cannot prevent fenced failure persistence');
|
|
$failedSwitch = Store::task($switchTask['id']);
|
|
$expect($failedSwitch['status'] === 'needs_reconciliation' && $failedSwitch['lease_token'] === '',
|
|
'Attempted old-provider work is held for reconciliation, not left running');
|
|
$config->set(['enabled' => true, 'audio_verified' => true], 'followup_audio');
|
|
$switchReuse = Store::create($upload(1, 1, $switchUpload), $recordedAt, 'qwen', 1, $root);
|
|
$expect($switchReuse['id'] === $switchTask['id'] && $switchReuse['reused']
|
|
&& $switchReuse['status'] === 'needs_reconciliation' && str_contains($switchReuse['reuse_message'], '配置'),
|
|
'Reverified provider switch plus reupload cannot bypass unknown-request dedupe');
|
|
$config->set(['providers' => $testProviders], 'followup_audio');
|
|
$makeReview = static function (array $extraction, int $diagnosisId = 1, int $actor = 1) use ($upload, $recordedAt, $root): array {
|
|
$created = Store::create($upload($diagnosisId, $actor), $recordedAt, 'qwen', $actor, $root);
|
|
$claim = Store::claim();
|
|
if ((int) ($claim['id'] ?? 0) !== $created['task_id']) { throw new RuntimeException('Unexpected pending test queue'); }
|
|
if (!Store::complete($created['task_id'], $claim['lease_token'], $extraction)) { throw new RuntimeException('Synthetic extraction completion rejected'); }
|
|
return Store::detail($created['task_id']);
|
|
};
|
|
$resolve = static function (array $detail): array {
|
|
return array_map(static function (array $item): array { $item['selected'] = true; $item['needs_review'] = false; return $item; }, $detail['items']);
|
|
};
|
|
$d = $makeReview($extract([$event('diagnosis', ['symptoms' => 'synthetic symptom'], 'synthetic symptom', ['date_text' => '今天'])]));
|
|
$expect(!$d['items'][0]['selected'] && $d['items'][0]['needs_review'] && !$d['items'][0]['conflict'],
|
|
'Even empty clinical diagnosis fields require explicit human review');
|
|
$unreviewed = $d['items']; $unreviewed[0]['selected'] = true;
|
|
$reject(fn () => Apply::apply($d['id'], $d['version'], $unreviewed, 1, $root), 'REVIEW_REQUIRED');
|
|
$rawTask = Store::task($d['id']);
|
|
$expect(!str_contains($rawTask['extraction_cipher'], 'synthetic symptom') && str_starts_with($rawTask['extraction_cipher'], 'v1:'), 'Extraction encrypted at rest');
|
|
$reject(fn () => Store::open($d['id'] + 1, 'extraction', $rawTask['extraction_cipher']), 'CIPHER_INVALID');
|
|
$forged = $d['items']; $forged[0]['evidence'][0]['text'] = 'forged evidence';
|
|
$reject(fn () => Store::saveDraft($d['id'], $d['version'], $forged, 1), 'IMMUTABLE_FIELD');
|
|
$forged = $d['items']; $forged[0]['expected_hash'] = str_repeat('0', 64);
|
|
$reject(fn () => Apply::apply($d['id'], $d['version'], $forged, 1, $root), 'IMMUTABLE_FIELD');
|
|
$forged = $d['items']; $forged[0]['values']['phone'] = '13800000000';
|
|
$reject(fn () => Store::saveDraft($d['id'], $d['version'], $forged, 1), 'UNPROPOSED_FIELD');
|
|
Db::name('tcm_diagnosis')->where('id', 1)->update(['symptoms' => 'newer manual value']);
|
|
$reject(fn () => Apply::apply($d['id'], $d['version'], $resolve($d), 1, $root), 'STALE_REVIEW');
|
|
$expect(Db::name('tcm_diagnosis')->where('id', 1)->value('symptoms') === 'newer manual value', 'Stale review cannot overwrite current data');
|
|
$fresh = Store::detail($d['id']);
|
|
$expect($fresh['version'] === $d['version'] + 1 && !$fresh['items'][0]['selected'] && $fresh['items'][0]['needs_review'], 'Stale conflict commits fresh review/version for human review');
|
|
$expect($fresh['items'][0]['current_values']['symptoms'] === 'newer manual value', 'Fresh review shows actual conflicting value');
|
|
Db::startTrans();
|
|
$reject(fn () => Apply::apply($fresh['id'], $fresh['version'], $resolve($fresh), 1, $root), 'NESTED_APPLY_FORBIDDEN');
|
|
Db::rollback();
|
|
$isolationBefore = Db::query('SELECT @@session.transaction_isolation AS isolation_level')[0]['isolation_level'];
|
|
$applied = Apply::apply($fresh['id'], $fresh['version'], $resolve($fresh), 1, $root);
|
|
$expect(Db::query('SELECT @@session.transaction_isolation AS isolation_level')[0]['isolation_level'] === $isolationBefore, 'Apply does not change session transaction isolation');
|
|
$expect($applied['status'] === 'applied' && Db::name('tcm_diagnosis')->where('id', 1)->value('symptoms') === 'synthetic symptom', 'Explicitly resolved current conflict may apply');
|
|
$expect(Policy::canonical(Apply::apply($fresh['id'], $fresh['version'], $resolve($fresh), 1, $root)) === Policy::canonical($applied), 'Repeated apply is idempotent');
|
|
$day = Policy::dayTimestamp('2026-09-19');
|
|
$oldBlood = Db::name('tcm_blood_record')->insertGetId(['diagnosis_id' => 1, 'patient_id' => 101, 'record_date' => $day,
|
|
'record_time' => '07:00', 'fasting_blood_sugar' => 5.5, 'source' => 1]);
|
|
$foreignBlood = Db::name('tcm_blood_record')->insertGetId(['diagnosis_id' => 2, 'patient_id' => 202, 'record_date' => $day,
|
|
'record_time' => '07:00', 'fasting_blood_sugar' => 4.4]);
|
|
$daily = $makeReview($extract([
|
|
$event('blood', ['fasting_blood_sugar' => 6.1], 'synthetic 08:00 6.1'),
|
|
$event('blood', ['fasting_blood_sugar' => 6.1], 'synthetic 10:00 6.1', ['record_time' => '10:00']),
|
|
$event('diet', ['breakfast' => 'synthetic breakfast'], 'synthetic breakfast'),
|
|
$event('exercise', ['exercise_type' => 'synthetic walk', 'duration' => 25, 'intensity' => 1], 'synthetic walk 25 minutes'),
|
|
$event('tracking_note', ['content' => 'synthetic historical follow-up'], 'synthetic historical follow-up'),
|
|
]));
|
|
$expect($daily['items'][0]['target_id'] === null && $daily['items'][0]['possible_duplicate'] && !$daily['items'][0]['selected'], 'Same-day existing measurements are not silently merged');
|
|
$foreign = $resolve($daily); $foreign[0]['target_id'] = (int) $foreignBlood;
|
|
$reject(fn () => Apply::apply($daily['id'], $daily['version'], $foreign, 1, $root), 'TARGET_INVALID');
|
|
$result = Apply::apply($daily['id'], $daily['version'], $resolve($daily), 1, $root);
|
|
$expect(count($result['applied_items']) === 5, 'All requested kinds atomically adopted');
|
|
$bloodIds = array_column(array_filter($result['applied_items'], static fn ($r) => $r['kind'] === 'blood'), 'record_id');
|
|
$expect(count(array_unique($bloodIds)) === 2 && !in_array((int) $oldBlood, $bloodIds, true), 'Different times and evidence produce distinct actual rows');
|
|
$expect((float) Db::name('tcm_blood_record')->where('id', $oldBlood)->value('fasting_blood_sugar') === 5.5, 'Original manual/self row remains unchanged');
|
|
$expect((int) Db::name('tcm_blood_record')->where('id', $bloodIds[0])->value('followup_audio_task_id') === $daily['id'], 'Blood metadata carries source task');
|
|
$dietId = array_values(array_filter($result['applied_items'], static fn ($r) => $r['kind'] === 'diet'))[0]['record_id'];
|
|
$exerciseId = array_values(array_filter($result['applied_items'], static fn ($r) => $r['kind'] === 'exercise'))[0]['record_id'];
|
|
$dietColumns = array_column(Db::query('SHOW COLUMNS FROM zyt_patient_diet_record'), 'Field');
|
|
$expect(array_intersect(['breakfast', 'lunch', 'dinner'], $dietColumns) === []
|
|
&& count(array_intersect(['breakfast_foods', 'lunch_foods', 'dinner_foods'], $dietColumns)) === 3,
|
|
'Real disposable MySQL contains canonical meal columns only, never fake extraction aliases');
|
|
$dietRow = Db::name('patient_diet_record')->where('id', $dietId)->find();
|
|
$expect($dietRow['breakfast_foods'] === 'synthetic breakfast', 'Adoption maps extraction alias to actual canonical column');
|
|
$expect(Fields::fromDatabase('diet', $dietRow)['breakfast'] === 'synthetic breakfast', 'Review reader maps canonical column back to extraction alias');
|
|
$dietAudit = Db::name('followup_audio_audit')->where('task_id', $daily['id'])->where('kind', 'diet')->find();
|
|
$dietAuditAfter = Store::open($daily['id'], 'audit-after:' . $dietAudit['item_id'], $dietAudit['after_cipher']);
|
|
$expect($dietAuditAfter['values']['breakfast'] === 'synthetic breakfast', 'Canonical column mapping does not erase audit after-values');
|
|
Db::name('patient_diet_record')->where('id', $dietId)->update(['lunch_foods' => 'keep lunch', 'dinner_foods' => 'keep dinner',
|
|
'breakfast_images' => '["keep-breakfast.png"]', 'lunch_images' => '["keep-lunch.png"]', 'dinner_images' => '["keep-dinner.png"]']);
|
|
$dietCorrection = $makeReview($extract([$event('diet', ['breakfast' => 'corrected breakfast'], 'synthetic breakfast correction')]));
|
|
$dietChanges = $resolve($dietCorrection); $dietChanges[0]['target_id'] = (int) $dietId;
|
|
$dietDraft = Store::saveDraft($dietCorrection['id'], $dietCorrection['version'], $dietChanges, 1);
|
|
$expect($dietDraft['items'][0]['current_values']['breakfast'] === 'synthetic breakfast', 'Retargeted review reads actual prior meal through aliases');
|
|
Apply::apply($dietDraft['id'], $dietDraft['version'], $resolve($dietDraft), 1, $root);
|
|
$dietRow = Db::name('patient_diet_record')->where('id', $dietId)->find();
|
|
$dietModel = \app\common\model\tcm\DietRecord::findOrEmpty($dietId)->toArray();
|
|
$expect($dietModel['breakfast_foods'] === 'corrected breakfast' && $dietModel['lunch_foods'] === 'keep lunch'
|
|
&& $dietModel['dinner_foods'] === 'keep dinner', 'Actual model getters preserve changed and untouched meals');
|
|
$expect($dietModel['breakfast_images'] === ['keep-breakfast.png'] && $dietModel['lunch_images'] === ['keep-lunch.png']
|
|
&& $dietModel['dinner_images'] === ['keep-dinner.png'], 'Meal adoption never clears preexisting images');
|
|
$exerciseRow = Db::name('patient_exercise_record')->where('id', $exerciseId)->find();
|
|
$exerciseModel = \app\common\model\tcm\ExerciseRecord::findOrEmpty($exerciseId)->append(['intensity_text'])->toArray();
|
|
$expect($exerciseModel['exercise_type'] === 'synthetic walk' && (int) $exerciseModel['duration'] === 25
|
|
&& $exerciseModel['intensity_text'] === '低强度', 'Actual exercise model getters read adopted values');
|
|
$exportPath = (string) getenv('FOLLOWUP_AUDIO_TEST_CANONICAL_EXPORT');
|
|
if ($exportPath !== '') {
|
|
if (!str_starts_with($exportPath, '/private/tmp/')) { throw new RuntimeException('Disposable export path required'); }
|
|
file_put_contents($exportPath, json_encode(['synthetic' => true, 'source' => 'real-disposable-mysql-apply',
|
|
'database' => $database, 'diet_columns' => $dietColumns, 'diet_row' => $dietRow, 'exercise_row' => $exerciseRow,
|
|
'diet_model' => $dietModel, 'exercise_model' => $exerciseModel], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
|
}
|
|
$noteId = array_values(array_filter($result['applied_items'], static fn ($r) => $r['kind'] === 'tracking_note'))[0]['record_id'];
|
|
$expect(Db::name('tracking_note')->where('id', $noteId)->value('note_date') === '2026-09-19', 'Historical note uses spoken date, not execution day');
|
|
$expect((int) Db::name('followup_audio_audit')->where('task_id', $daily['id'])->count() === 5, 'One provenance record per adopted item');
|
|
$audit = Db::name('followup_audio_audit')->where('task_id', $daily['id'])->find();
|
|
$sourceAudit = Store::open($daily['id'], 'audit-source:' . $audit['item_id'], $audit['source_cipher']);
|
|
$expect(isset($sourceAudit['evidence']) && !isset($sourceAudit['transcript']), 'Audit retains necessary adopted evidence, not full transcript');
|
|
$estimate = $makeReview($extract([$event('blood', ['systolic_pressure' => 122], 'synthetic afternoon estimate',
|
|
['record_time' => null, 'time_period' => 'afternoon'])]));
|
|
$estimateItems = $resolve($estimate); $estimateItems[0]['time_period'] = '中午';
|
|
$estimateDraft = Store::saveDraft($estimate['id'], $estimate['version'], $estimateItems, 1);
|
|
$expect($estimateDraft['items'][0]['record_time'] === '12:00' && $estimateDraft['items'][0]['time_estimated'],
|
|
'Changing estimated period also updates its estimated clock');
|
|
$estimateResult = Apply::apply($estimateDraft['id'], $estimateDraft['version'], $resolve($estimateDraft), 1, $root);
|
|
$estimateRow = Db::name('tcm_blood_record')->where('id', $estimateResult['applied_items'][0]['record_id'])->find();
|
|
$expect($estimateRow['record_time'] === '12:00' && (int) $estimateRow['record_time_estimated'] === 1
|
|
&& $estimateRow['record_time_period'] === '中午', 'Estimated clock and period stored on the actual independent row');
|
|
$precise = $makeReview($extract([$event('blood', ['systolic_pressure' => 123], 'synthetic human precision',
|
|
['record_time' => null, 'time_period' => 'afternoon'])]));
|
|
$preciseItems = $resolve($precise); $preciseItems[0]['record_time'] = '13:41:30';
|
|
$preciseDraft = Store::saveDraft($precise['id'], $precise['version'], $preciseItems, 1);
|
|
$expect($preciseDraft['items'][0]['record_time'] === '13:41' && !$preciseDraft['items'][0]['time_estimated'],
|
|
'Human concrete time clears estimate without accepting client time_estimated');
|
|
$update = $makeReview($extract([$event('blood', ['fasting_blood_sugar' => 6.7], 'synthetic correction 6.7', ['record_time' => '07:00'])]));
|
|
$updateItems = $resolve($update); $updateItems[0]['target_id'] = (int) $oldBlood;
|
|
$newDraft = Store::saveDraft($update['id'], $update['version'], $updateItems, 1);
|
|
$expect(!empty($newDraft['review_refreshed']) && !$newDraft['items'][0]['selected'] && $newDraft['items'][0]['current_values']['fasting_blood_sugar'] === 5.5, 'Retarget snapshots selected real row and requires review again');
|
|
Apply::apply($newDraft['id'], $newDraft['version'], $resolve($newDraft), 1, $root);
|
|
$expect((float) Db::name('tcm_blood_record')->where('id', $oldBlood)->value('fasting_blood_sugar') === 6.7
|
|
&& (float) Db::name('tcm_blood_record')->where('id', $bloodIds[0])->value('fasting_blood_sugar') === 6.1, 'Update touches exact target ID, not synthetic daily aggregate');
|
|
$atomic = $makeReview($extract([
|
|
$event('diagnosis', ['remark' => 'synthetic atomic change'], 'synthetic atomic change', ['date_text' => '今天']),
|
|
$event('diet', ['lunch' => 'synthetic rollback lunch'], 'synthetic rollback lunch'),
|
|
]));
|
|
$dietCount = (int) Db::name('patient_diet_record')->count();
|
|
$pdo->exec("CREATE TRIGGER fa_audit_failure BEFORE INSERT ON zyt_followup_audio_audit FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='synthetic audit rejection'");
|
|
$reject(fn () => Apply::apply($atomic['id'], $atomic['version'], $resolve($atomic), 1, $root), 'synthetic audit rejection');
|
|
$expect(Db::name('tcm_diagnosis')->where('id', 1)->value('remark') === null && (int) Db::name('patient_diet_record')->count() === $dietCount
|
|
&& Store::task($atomic['id'])['status'] === 'review', 'DB failure rolls back adopted data, task status and audit together');
|
|
$pdo->exec('DROP TRIGGER fa_audit_failure');
|
|
$payloadPath = $private . '/concurrent-items.json'; file_put_contents($payloadPath, json_encode($resolve($atomic))); chmod($payloadPath, 0600);
|
|
$concurrent = $runChildren([['--apply', (string) $atomic['id'], (string) $atomic['version'], $payloadPath], ['--apply', (string) $atomic['id'], (string) $atomic['version'], $payloadPath]]);
|
|
unlink($payloadPath);
|
|
$expect($concurrent[0] === $concurrent[1] && (int) Db::name('followup_audio_audit')->where('task_id', $atomic['id'])->count() === 2,
|
|
'Two real concurrent apply requests return same IDs and produce one batch');
|
|
$identity = $makeReview($extract([$event('diagnosis', ['patient_name' => 'synthetic corrected name'], 'synthetic corrected name', ['date_text' => '今天'])]), 3, 2);
|
|
$pdo->exec('INSERT INTO zyt_tcm_prescription_order VALUES(1,3,2,100,1,NULL)');
|
|
$reject(fn () => Apply::apply($identity['id'], $identity['version'], $resolve($identity), 2, $root), 'PATIENT_BASIC_LOCKED');
|
|
$expect(Db::name('tcm_diagnosis')->where('id', 3)->value('patient_name') === 'Synthetic C', 'Order identity lock preserved even if caller supplies stale root info');
|
|
$pdo->exec('UPDATE zyt_tcm_prescription_order SET fulfillment_status=3 WHERE id=1');
|
|
Apply::apply($identity['id'], $identity['version'], $resolve($identity), 2, []);
|
|
$expect(Db::name('tcm_diagnosis')->where('id', 3)->value('patient_name') === 'synthetic corrected name', 'Completed latest order permits existing identity rules');
|
|
$phone = $makeReview($extract([$event('diagnosis', ['phone' => '13800000000'], 'synthetic phone', ['date_text' => '今天'])]), 3, 2);
|
|
$reject(fn () => Apply::apply($phone['id'], $phone['version'], $resolve($phone), 2, $root), 'IDENTITY_PLAIN_PERMISSION_REQUIRED');
|
|
$expect(Db::name('tcm_diagnosis')->where('id', 3)->value('phone') === null, 'Masked-identity permission cannot be bypassed by new service');
|
|
Db::name('tcm_diagnosis')->where('id', 3)->update(['phone' => '13900000000']);
|
|
$masked = $makeReview($extract([$event('diagnosis', ['phone' => '13800000000'], 'synthetic masked roundtrip', ['date_text' => '今天'])]), 3, 2);
|
|
$protected = \app\adminapi\logic\tcm\FollowupAudioLogic::detail($masked['id'], 2, []);
|
|
$expect($protected['items'][0]['current_values']['phone'] === '139****0000', 'Actual endpoint logic masks existing identity before browser review');
|
|
$maskedDraft = \app\adminapi\logic\tcm\FollowupAudioLogic::saveDraft($masked['id'], $masked['version'], $protected['items'], 2, []);
|
|
$savedReview = Store::open($masked['id'], 'review', Store::task($masked['id'])['review_cipher']);
|
|
$expect($maskedDraft['items'][0]['current_values']['phone'] === '139****0000'
|
|
&& $savedReview['items'][0]['current_values']['phone'] === '13900000000', 'Redacted display fields can roundtrip without replacing immutable stored snapshot');
|
|
$limited = $makeReview($extract([$event('diet', ['dinner' => 'synthetic denied dinner'], 'synthetic denied dinner')]), 2, 3);
|
|
$reject(fn () => Apply::apply($limited['id'], $limited['version'], $resolve($limited), 3, []), '无权');
|
|
$rebound = $makeReview($extract([$event('diagnosis', ['remark' => 'synthetic rebound'], 'synthetic rebound', ['date_text' => '今天'])]));
|
|
Db::name('tcm_diagnosis')->where('id', 1)->update(['patient_id' => 999]);
|
|
$reject(fn () => Apply::apply($rebound['id'], $rebound['version'], $resolve($rebound), 1, $root), '归属已变化');
|
|
Db::name('tcm_diagnosis')->where('id', 1)->update(['patient_id' => 101]);
|
|
$expiryTask = Store::task($rebound['id']);
|
|
Db::name('followup_audio_task')->where('id', $rebound['id'])->update(['expires_at' => time() - 1]);
|
|
Db::name('followup_audio_upload')->where('id', $expiryTask['upload_id'])->update(['expires_at' => time() - 1]);
|
|
$reject(fn () => Apply::apply($rebound['id'], $rebound['version'], $resolve($rebound), 1, $root), 'EXPIRED');
|
|
$expect(Store::detail($rebound['id'])['transcript'] === '' && Store::detail($rebound['id'])['status'] === 'expired', 'Expired full content hidden before cleanup scheduler');
|
|
$retainedAudit = (int) Db::name('followup_audio_audit')->count();
|
|
$appliedTask = Store::task($daily['id']);
|
|
Db::name('followup_audio_task')->where('id', $daily['id'])->update(['expires_at' => time() - 1]);
|
|
Db::name('followup_audio_upload')->where('id', $appliedTask['upload_id'])->update(['expires_at' => time() - 1]);
|
|
$active = Store::create($upload(), $recordedAt, 'qwen', 1, $root); $activeClaim = Store::claim();
|
|
Db::name('followup_audio_task')->where('id', $active['id'])->update(['expires_at' => time() - 1]);
|
|
Db::name('followup_audio_upload')->where('id', $activeClaim['upload_id'])->update(['expires_at' => time() - 1]);
|
|
$cleanup = Store::cleanupExpired();
|
|
$expect($cleanup['tasks_purged'] === 2 && $cleanup['active_skipped'] === 1 && $cleanup['errors'] === 0, 'Cleanup purges expiry but not an active lease');
|
|
$purged = Store::task($rebound['id']);
|
|
$expect($purged['extraction_cipher'] === '' && $purged['review_cipher'] === '' && $purged['status'] === 'expired', 'Full transcript and immutable extraction erased');
|
|
$expect(!is_file($private . '/' . $expiryTask['upload_id'] . '/audio.wav') && is_file($private . '/' . $activeClaim['upload_id'] . '/audio.wav'), 'Expired raw audio deleted via verified private path; active audio retained');
|
|
$expect(Store::task($daily['id'])['file_name'] === '已清理录音'
|
|
&& Db::name('followup_audio_upload')->where('id', $appliedTask['upload_id'])->value('file_name') === '已清理录音', 'Expired original filenames are not retained');
|
|
$expect((int) Db::name('followup_audio_audit')->count() === $retainedAudit && Store::task($daily['id'])['status'] === 'applied', 'Adopted data/provenance retained after 90-day cleanup');
|
|
$expect(file_get_contents($private . '/unrelated-sentinel') === 'KEEP', 'Cleanup never touches unrelated sibling files');
|
|
// More than a batch of already-purged history must not starve a newly expired review forever.
|
|
$history = [];
|
|
for ($i = 0; $i < 505; $i++) {
|
|
$row = $rawTask; unset($row['id']);
|
|
$row = array_replace($row, ['upload_id' => bin2hex(random_bytes(24)), 'sha256' => hash('sha256', 'expired-history-' . $i),
|
|
'status' => 'expired', 'stage' => 'expired', 'purged_at' => time(), 'expires_at' => time() - 1,
|
|
'lease_until' => 0, 'lease_token' => '', 'extraction_cipher' => '', 'review_cipher' => '', 'applied_cipher' => '']);
|
|
$history[] = $row;
|
|
}
|
|
Db::name('followup_audio_task')->insertAll($history);
|
|
Db::name('followup_audio_task')->where('id', $active['id'])->update(['lease_until' => 0]);
|
|
$newExpiry = $makeReview($extract([$event('diagnosis', ['remark' => 'synthetic newest expiry'], 'synthetic newest expiry', ['date_text' => '今天'])]));
|
|
$newExpiryTask = Store::task($newExpiry['id']);
|
|
Db::name('followup_audio_task')->where('id', $newExpiry['id'])->update(['expires_at' => time() - 1]);
|
|
Db::name('followup_audio_upload')->where('id', $newExpiryTask['upload_id'])->update(['expires_at' => time() - 1]);
|
|
Store::cleanupExpired();
|
|
$expect((int) Store::task($newExpiry['id'])['purged_at'] > 0, 'Cleanup never starves new expiry behind >500 already purged rows');
|
|
// Exercise the actual Worker -> Dify -> Store checkpoint contract, not a permissive Store test double.
|
|
$transportCalls = [];
|
|
$rawAnswer = ['schema_version' => 'followup-audio-v1', 'audio_processed' => true,
|
|
'summary' => 'Synthetic integrated result', 'transcript' => 'Synthetic integrated fact', 'uncertainties' => [],
|
|
'items' => [['kind' => 'diagnosis', 'values' => ['remark' => 'Synthetic integrated fact'],
|
|
'record_date' => null, 'record_time' => null, 'time_period' => null, 'date_text' => '今天', 'time_text' => '',
|
|
'evidence' => [['text' => 'Synthetic integrated fact']], 'needs_review' => false]]];
|
|
$adapter = new \app\common\service\followupaudio\FollowupAudioDify(
|
|
static function (array $spec) use (&$transportCalls, $rawAnswer): array {
|
|
$transportCalls[] = $spec;
|
|
$body = isset($spec['multipart']) ? ['id' => 'integrated-file', 'mime_type' => 'audio/wav']
|
|
: ['answer' => json_encode($rawAnswer), 'task_id' => 'integrated-task', 'message_id' => 'integrated-message'];
|
|
return ['http_code' => 200, 'errno' => 0, 'body' => json_encode($body)];
|
|
}, (array) config('followup_audio'), ['base_url' => 'http://127.0.0.1/v1',
|
|
'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]]);
|
|
$worker = new \app\common\service\followupaudio\FollowupAudioWorker($adapter);
|
|
$integrated = Store::create($upload(), $recordedAt, 'qwen', 1, $root);
|
|
$expect($worker->runOnce(), 'Integrated worker consumes queued task');
|
|
$integratedTask = Store::task($integrated['id']);
|
|
$expect($integratedTask['status'] === 'review', 'Actual Worker/Dify checkpoint success must become review, observed '
|
|
. $integratedTask['status'] . '/' . $integratedTask['error_code']);
|
|
$expect(count($transportCalls) === 2 && $integratedTask['upstream_run_id'] === 'integrated-task'
|
|
&& $integratedTask['upstream_file_id'] === 'integrated-file', 'Durable upload and generation IDs survive all actual Store checkpoints');
|
|
$expect(Store::detail($integrated['id'])['items'][0]['values']['remark'] === 'Synthetic integrated fact',
|
|
'Adapter normalization plus Store normalization preserves actionable evidence');
|
|
$transportCalls = [];
|
|
$reboundWorker = Store::create($upload(), $recordedAt, 'qwen', 1, $root);
|
|
Db::name('tcm_diagnosis')->where('id', 1)->update(['patient_id' => 999]);
|
|
$worker->runOnce();
|
|
$reboundWorkerTask = Store::task($reboundWorker['id']);
|
|
$expect($transportCalls === [] && (int) $reboundWorkerTask['upstream_started_at'] === 0,
|
|
'Rebound patient is denied BEFORE any original-patient audio leaves local storage');
|
|
Db::name('tcm_diagnosis')->where('id', 1)->update(['patient_id' => 101]);
|
|
echo "Follow-up audio core: {$checks} checks passed (real disposable MySQL; concurrent claim/apply; rollback; no upstream requests).\n";
|
|
} finally {
|
|
$manager->connect()->close();
|
|
$pdo->exec("DROP DATABASE IF EXISTS `{$database}`");
|
|
// All paths under an independently generated disposable test root, never application private storage.
|
|
if (preg_match('#^/private/tmp/fa_core_[a-f0-9]{12}$#D', $private) && is_dir($private)) {
|
|
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($private, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
|
|
foreach ($files as $file) { $file->isDir() && !$file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname()); }
|
|
rmdir($private);
|
|
}
|
|
}
|