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); } }