This commit is contained in:
Your Name
2026-09-09 15:47:48 +08:00
parent bd5d5c5f08
commit cb10e75ead
98 changed files with 7031 additions and 804 deletions
+548
View File
@@ -0,0 +1,548 @@
<?php
declare(strict_types=1);
namespace ImArchiveTest {
/** Strict in-memory adapters: unexpected persistence operations fail closed. */
final class Store
{
public static array $tables = [];
public static array $cache = [];
public static array $events = [];
public static int $clock = 1000;
public static int $executeCount = 0;
public static int $failOnExecute = 0;
public static bool $failUpdate = false;
public static function reset(): void
{
self::$tables = [
'diagnosis' => [
['id' => 101, 'patient_id' => 501, 'patient_name' => '患者甲', 'assistant_id' => 7, 'delete_time' => null],
['id' => 102, 'patient_id' => 501, 'patient_name' => '患者甲', 'assistant_id' => 7, 'delete_time' => null],
['id' => 201, 'patient_id' => 502, 'patient_name' => '患者乙', 'assistant_id' => 8, 'delete_time' => null],
],
'messages' => [],
'admin' => [['id' => 7, 'name' => '医生甲'], ['id' => 8, 'name' => '医生乙']],
'appointment' => [],
'admin_role' => [['admin_id' => 7, 'role_id' => 1]],
];
self::$cache = self::$events = [];
self::$clock = 1000;
self::$executeCount = self::$failOnExecute = 0;
self::$failUpdate = false;
\app\common\service\TencentImService::$responses = [];
\app\common\service\TencentImService::$requests = [];
\app\common\service\TencentImService::$checkRequests = [];
\app\common\service\TencentImService::$missingAccounts = [];
\app\common\service\TencentImService::$checkFailure = null;
}
}
final class Rows
{
public function __construct(private array $rows) {}
public function toArray(): array { return $this->rows; }
}
final class Query
{
private array $filters = [];
private array $orders = [];
public function __construct(private string $table) {}
public function where(string $field, $operator, $value = null): self
{
if (func_num_args() === 2) {
$value = $operator;
$operator = '=';
}
if ($operator !== '=') throw new \RuntimeException('Unexpected query operator: ' . $operator);
$this->filters[] = static fn (array $row): bool => ($row[$field] ?? null) === $value;
return $this;
}
public function whereIn(string $field, array $values): self
{
$this->filters[] = static fn (array $row): bool => in_array($row[$field] ?? null, $values, true);
return $this;
}
public function order(string $field, string $direction): self
{
$this->orders[] = [$field, $direction];
return $this;
}
private function rows(): array
{
if (!array_key_exists($this->table, Store::$tables)) throw new \RuntimeException('Unexpected table: ' . $this->table);
$rows = array_values(array_filter(Store::$tables[$this->table], function (array $row): bool {
foreach ($this->filters as $filter) if (!$filter($row)) return false;
return true;
}));
if ($this->orders) {
usort($rows, function (array $left, array $right): int {
foreach ($this->orders as [$field, $direction]) {
$comparison = ($left[$field] ?? null) <=> ($right[$field] ?? null);
if ($comparison !== 0) return $direction === 'desc' ? -$comparison : $comparison;
}
return 0;
});
}
return $rows;
}
public function select(): Rows { return new Rows($this->rows()); }
public function find(): ?array { return $this->rows()[0] ?? null; }
public function column(string $field, string $key = ''): array
{
return $key === '' ? array_column($this->rows(), $field) : array_column($this->rows(), $field, $key);
}
public function update(array $data): int
{
if (Store::$failUpdate) throw new \RuntimeException('archive repair failed');
if ($this->table !== 'messages' || array_keys($data) !== ['msg_type', 'text', 'raw_elem_type', 'image_url', 'file_url', 'file_name']) {
throw new \RuntimeException('Unexpected archive update');
}
$ids = array_column($this->rows(), 'id');
foreach (Store::$tables[$this->table] as &$row) {
if (in_array($row['id'], $ids, true)) $row = array_replace($row, $data);
}
unset($row);
Store::$events[] = ['repair', $ids];
return count($ids);
}
}
abstract class Model
{
protected const TABLE = '';
public static function where(...$args): Query { return (new Query(static::TABLE))->where(...$args); }
public static function whereIn(...$args): Query { return (new Query(static::TABLE))->whereIn(...$args); }
public function getTable(): string { return 'archive_test_messages'; }
}
final class Database
{
public function name(string $table): Query { return new Query($table); }
public function execute(string $sql, array $bindings): int
{
Store::$executeCount++;
if (Store::$executeCount === Store::$failOnExecute) throw new \RuntimeException('archive write failed');
if (!preg_match('/^INSERT INTO `archive_test_messages` \(([^)]+)\) VALUES /', $sql, $match)
|| !str_ends_with($sql, ' ON DUPLICATE KEY UPDATE `msg_id` = `msg_id`')) {
throw new \RuntimeException('Unexpected archive SQL; only explicit duplicate-key no-op is supported');
}
$columns = array_map(static fn (string $column): string => trim($column, '`'), explode(',', $match[1]));
if (count($bindings) % count($columns) !== 0) throw new \RuntimeException('Invalid archive SQL bindings');
$inserted = 0;
foreach (array_chunk($bindings, count($columns)) as $values) {
$row = array_combine($columns, $values);
$exists = false;
foreach (Store::$tables['messages'] as $existing) {
if ($existing['msg_id'] === $row['msg_id']) { $exists = true; break; }
}
if ($exists) continue;
$row['id'] = count(Store::$tables['messages']) + 1;
Store::$tables['messages'][] = $row;
$inserted++;
}
Store::$events[] = ['archive', $inserted];
return $inserted;
}
}
final class Cache
{
public function get(string $key, $default = null) { return Store::$cache[$key] ?? $default; }
public function set(string $key, $value, int $ttl = 0): bool
{
Store::$events[] = ['cache', $key];
Store::$cache[$key] = $value;
return true;
}
}
}
namespace app\common\model\tcm {
class Diagnosis extends \ImArchiveTest\Model { protected const TABLE = 'diagnosis'; }
class ImChatMessage extends \ImArchiveTest\Model { protected const TABLE = 'messages'; }
}
namespace app\common\model\auth {
class Admin extends \ImArchiveTest\Model { protected const TABLE = 'admin'; }
}
namespace app\common\model\doctor {
class Appointment extends \ImArchiveTest\Model { protected const TABLE = 'appointment'; }
}
namespace app\common\service {
/** Pager remains real; this adapter cannot make HTTP requests. */
class TencentImService
{
public static array $responses = [];
public static array $requests = [];
public static array $checkRequests = [];
public static array $missingAccounts = [];
public static ?\Throwable $checkFailure = null;
public function checkAccounts(array $accounts): array
{
self::$checkRequests[] = $accounts;
if (count($accounts) > 100) throw new \RuntimeException('account batch exceeds 100');
if (self::$checkFailure) throw self::$checkFailure;
return ['existing' => array_values(array_diff($accounts, self::$missingAccounts)),
'missing' => array_values(array_intersect($accounts, self::$missingAccounts))];
}
public function adminGetRoamMsg(string $operatorAccount, string $peerAccount, int $maxCnt = 100,
int $minTime = 0, int $maxTime = 4294967295, ?string $lastMsgKey = null, ?int $lastMsgTime = null): array
{
self::$requests[] = compact('operatorAccount', 'peerAccount', 'maxCnt', 'minTime', 'maxTime', 'lastMsgKey', 'lastMsgTime');
if (self::$responses === []) throw new \RuntimeException('Unexpected additional IM request');
$response = array_shift(self::$responses);
if ($response instanceof \Throwable) throw $response;
return $response;
}
}
}
namespace app\adminapi\logic\tcm {
// Make checkpoint timing deterministic without replacing any DiagnosisLogic method.
function time(): int { return \ImArchiveTest\Store::$clock; }
}
namespace {
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\common\service\ImChatSyncSession;
use app\common\service\ImRoamMessagePager;
use app\common\service\TencentImService;
use ImArchiveTest\Store;
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
// No initialize(): no environment configuration, network, or business DB connection.
$testApp = new think\App();
$testApp->instance('think\DbManager', new ImArchiveTest\Database());
$testApp->instance('cache', new ImArchiveTest\Cache());
$testApp->instance('log', new Psr\Log\NullLogger());
think\facade\Config::set(['trtc' => ['sdkAppId' => 123, 'secretKey' => 'archive-test-key']], 'project');
function archiveExpect(bool $ok, string $message): void
{
if (!$ok) throw new RuntimeException($message);
}
function archiveFails(callable $action, string $expected): void
{
try { $action(); }
catch (Throwable $exception) {
archiveExpect(str_contains($exception->getMessage(), $expected), 'Expected ' . $expected . '; got ' . $exception->getMessage());
return;
}
throw new RuntimeException('Expected failure: ' . $expected);
}
function archiveInvoke(string $method, ...$args)
{
return (new ReflectionMethod(DiagnosisLogic::class, $method))->invoke(null, ...$args);
}
function archiveRaw(string $key, int $time, int $patientId = 501, bool $reverse = false): array
{
return [
'From_Account' => $reverse ? 'patient_' . $patientId : 'doctor_7',
'To_Account' => $reverse ? 'doctor_7' : 'patient_' . $patientId,
'MsgTimeStamp' => $time, 'MsgSeq' => 11, 'MsgRandom' => 22, 'MsgKey' => $key,
'MsgBody' => [['MsgType' => 'TIMTextElem', 'MsgContent' => ['Text' => 'message ' . $key]]],
];
}
function archivePage(bool $completed, ?int $time, ?string $key, array $messages): array
{
return ['success' => true, 'complete' => $completed ? 1 : 0, 'msgList' => $messages,
'lastMsgTime' => $time, 'lastMsgKey' => $key, 'rawErrorCode' => 0, 'error' => ''];
}
function archiveStored(string $id, int $patientId, int $diagnosisId, string $from, string $to, int $time): array
{
return ['id' => count(Store::$tables['messages']) + 1, 'msg_id' => $id, 'patient_id' => $patientId,
'diagnosis_id' => $diagnosisId, 'from_account' => $from, 'to_account' => $to,
'msg_time' => $time, 'doctor_peer_account' => 'doctor_7', 'text' => $id];
}
// Existing archive/pagination cases advance past account-only setup requests.
function archiveStep(int $diagnosisId, int $adminId, string $token = '', bool $currentPeer = false): array
{
do {
$result = DiagnosisLogic::syncImChatArchiveStep($diagnosisId, $adminId, $token, $currentPeer);
$token = $result['sync_token'];
$state = Store::$cache['im_chat_sync:' . $token];
} while (!$result['completed'] && (!($state['accounts_verified'] ?? false) || !isset($state['active_index'])));
return $result;
}
function archiveFinishPatientSide(string $token): array
{
TencentImService::$responses[] = archivePage(true, null, null, []);
return archiveStep(101, 7, $token, true);
}
archiveExpect(str_ends_with((new ReflectionClass(DiagnosisLogic::class))->getFileName(), 'DiagnosisLogic.php'), 'Use the actual diagnosis logic');
archiveExpect(str_ends_with((new ReflectionClass(ImRoamMessagePager::class))->getFileName(), 'ImRoamMessagePager.php'), 'Use the actual pager');
// Reading a patient's archive spans diagnoses, but never trusts patient_id without checking the accounts.
Store::reset();
Store::$tables['messages'][] = archiveStored('old', 501, 101, 'doctor_7', 'patient_501', 100);
Store::$tables['messages'][] = archiveStored('new', 501, 102, 'patient_501', 'doctor_8', 200);
Store::$tables['messages'][] = archiveStored('corrupt-patient-column', 501, 101, 'doctor_7', 'patient_502', 150);
Store::$tables['messages'][] = archiveStored('other', 502, 201, 'doctor_7', 'patient_502', 100);
$old = DiagnosisLogic::getImChatMessagesForDiagnosis(101, true);
$new = DiagnosisLogic::getImChatMessagesForDiagnosis(102, true);
$other = DiagnosisLogic::getImChatMessagesForDiagnosis(201, true);
archiveExpect(array_column($old['lists'], 'msg_id') === ['old', 'new'] && array_column($new['lists'], 'msg_id') === ['old', 'new'], 'Old and new diagnoses share the same patient archive');
archiveExpect(array_unique(array_column($old['lists'], 'diagnosis_id')) === [101]
&& array_unique(array_column($new['lists'], 'diagnosis_id')) === [102], 'Response rows carry the currently authorized diagnosis');
archiveExpect(array_column($other['lists'], 'msg_id') === ['other'], 'Another patient cannot receive shared or mislabeled rows');
archiveExpect($new['lists'][0]['from_staff_name'] === '医生甲' && TencentImService::$requests === [], 'Archive-only reads enrich staff names without network');
// Callback MsgTime + MsgKey and roam MsgTimeStamp/MsgRandom identify the same message.
Store::reset();
$roam = archiveRaw('11_22_100', 100);
$callback = $roam;
$callback['SendMsgResult'] = 0;
$callback['MsgTime'] = $callback['MsgTimeStamp'];
unset($callback['MsgTimeStamp'], $callback['MsgRandom'], $callback['MsgSeq']);
$normalized = archiveInvoke('normalizeTimMessage', $roam);
archiveExpect($normalized['msg_id'] === archiveInvoke('normalizeTimMessage', $callback)['msg_id'], 'Callback and roam use canonical message identity');
archiveExpect(DiagnosisLogic::archiveImCallbackMessage($callback) === 1, 'Callback archives to the latest patient diagnosis');
$failedCallback = array_replace($callback, ['MsgKey' => 'failed-send', 'SendMsgResult' => 90001]);
archiveExpect(DiagnosisLogic::archiveImCallbackMessage($failedCallback) === 0 && count(Store::$tables['messages']) === 1, 'Failed sending callback is not archived');
foreach ([null, '0', true] as $invalidSendResult) {
archiveFails(static fn () => DiagnosisLogic::archiveImCallbackMessage(array_replace($callback, ['SendMsgResult' => $invalidSendResult])), '发送结果');
}
$missingSendResult = $callback;
unset($missingSendResult['SendMsgResult']);
archiveFails(static fn () => DiagnosisLogic::archiveImCallbackMessage($missingSendResult), '发送结果');
archiveExpect(archiveInvoke('persistImChatArchiveRows', 101, 501, [$normalized]) === 0
&& DiagnosisLogic::archiveImCallbackMessage($callback) === 0, 'Roam and repeated callback are idempotent');
archiveExpect(count(Store::$tables['messages']) === 1 && Store::$tables['messages'][0]['diagnosis_id'] === 102, 'Duplicate writes do not mutate existing archive provenance');
archiveExpect($normalized['msg_id'] !== archiveInvoke('normalizeTimMessage', archiveRaw('11_22_100', 100, 502))['msg_id'], 'Canonical identity includes both accounts to isolate patients');
archiveFails(static fn () => archiveInvoke('persistImChatArchiveRows', 101, 501, [archiveInvoke('normalizeTimMessage', archiveRaw('wrong', 100, 502))]), '不属于当前患者');
// Legacy seq_random_from keys are reused only when patient, endpoints and time all agree.
Store::reset();
Store::$tables['messages'][] = archiveStored('11_22_doctor_7', 501, 101, 'doctor_7', 'patient_501', 100);
archiveExpect(DiagnosisLogic::archiveImCallbackMessage($callback) === 0 && count(Store::$tables['messages']) === 1, 'Legacy key remains deduplicated even when callback omits MsgRandom');
foreach ([[502, 'patient_502', 100], [501, 'patient_502', 100], [501, 'patient_501', 99]] as [$legacyPatient, $legacyTo, $legacyTime]) {
Store::reset();
$legacy = archiveStored('11_22_doctor_7', $legacyPatient, 201, 'doctor_7', $legacyTo, $legacyTime);
Store::$tables['messages'][] = $legacy;
archiveExpect(DiagnosisLogic::archiveImCallbackMessage($callback) === 1, 'Colliding legacy key does not suppress a different patient/time/account message');
archiveExpect(Store::$tables['messages'][0] === $legacy && Store::$tables['messages'][1]['msg_id'] === $normalized['msg_id'], 'Legacy collision preserves the existing row and inserts the canonical key');
}
// Composite messages retain every element and repair truncated legacy rows without changing ownership.
Store::reset();
$composite = $callback;
$composite['MsgBody'][] = ['MsgType' => 'TIMImageElem', 'MsgContent' => ['ImageInfoArray' => [['URL' => 'https://example.invalid/image.jpg']]]];
$composite['MsgBody'][] = ['MsgType' => 'TIMFileElem', 'MsgContent' => ['Url' => 'https://example.invalid/report.pdf', 'FileName' => '报告.pdf']];
$compositeRow = archiveInvoke('normalizeTimMessage', $composite);
archiveExpect($compositeRow['msg_type'] === 'composite' && $compositeRow['raw_elem_type'] === 'TIMMultiElem', 'Multiple message elements use the composite archive representation');
$parts = json_decode($compositeRow['text'], true, 512, JSON_THROW_ON_ERROR);
archiveExpect(array_column($parts, 'msg_type') === ['text', 'image', 'file'] && $parts[2]['file_name'] === '报告.pdf', 'Text, image and file elements retain their order and content');
archiveExpect(DiagnosisLogic::archiveImCallbackMessage($composite) === 1, 'Composite callback archives as one canonical message');
$compositeRead = DiagnosisLogic::getImChatMessagesForDiagnosis(101, true);
archiveExpect($compositeRead['lists'][0]['parts'] === $parts, 'Archive reads restore every composite element for rendering');
Store::reset();
$truncated = archiveStored('11_22_doctor_7', 501, 101, 'doctor_7', 'patient_501', 100);
$truncated['msg_type'] = 'text';
$truncated['text'] = 'first element only';
Store::$tables['messages'][] = $truncated;
$unrelated = archiveStored('other-patient-legacy', 502, 201, 'doctor_7', 'patient_502', 100);
Store::$tables['messages'][] = $unrelated;
archiveExpect(DiagnosisLogic::archiveImCallbackMessage($composite) === 0 && count(Store::$tables['messages']) === 2, 'Repair reuses a matched legacy key without creating a duplicate');
$repaired = Store::$tables['messages'][0];
archiveExpect($repaired['msg_type'] === 'composite' && json_decode($repaired['text'], true) === $parts
&& $repaired['diagnosis_id'] === 101 && $repaired['msg_id'] === $truncated['msg_id']
&& Store::$tables['messages'][1] === $unrelated, 'Legacy content repair preserves archive provenance and cannot modify another patient');
$repairCount = count(array_filter(Store::$events, static fn (array $event): bool => $event[0] === 'repair'));
DiagnosisLogic::archiveImCallbackMessage($composite);
archiveExpect(count(array_filter(Store::$events, static fn (array $event): bool => $event[0] === 'repair')) === $repairCount, 'Already repaired composite content is idempotent');
Store::$tables['messages'][0] = $truncated;
Store::$failUpdate = true;
archiveFails(static fn () => DiagnosisLogic::archiveImCallbackMessage($composite), 'archive repair failed');
archiveExpect(Store::$tables['messages'][0] === $truncated, 'A failed legacy repair is not swallowed as successful archive');
// Current-peer scope is server-selected; token reuse is bound to admin, diagnosis and patient.
Store::reset();
TencentImService::$responses = [archivePage(false, 200, 'first', [archiveRaw('first', 200)])];
$first = archiveStep(101, 7, '', true);
$token = $first['sync_token'];
$sessionKey = 'im_chat_sync:' . $token;
$checkpointKey = 'im_chat_complete_v1:501:doctor_7';
$saved = Store::$cache[$sessionKey];
archiveExpect(!$first['completed'] && $first['inserted'] === 1 && $saved['cursor']['max_time'] === 200, 'A persisted non-final page advances its full cursor');
archiveExpect($saved['accounts'] === ['doctor_7'] && $saved['admin_id'] === 7 && $saved['diagnosis_id'] === 101 && $saved['patient_id'] === 501, 'Current scope records its exact authorized identity');
archiveExpect(!isset(Store::$cache[$checkpointKey]) && TencentImService::$requests[0]['minTime'] === 0, 'Partial scan has no complete checkpoint and starts from the beginning');
$requestCount = count(TencentImService::$requests);
archiveFails(static fn () => archiveStep(101, 8, $token, true), '失效');
archiveFails(static fn () => archiveStep(102, 7, $token, true), '失效');
Store::$tables['diagnosis'][0]['patient_id'] = 502;
archiveFails(static fn () => archiveStep(101, 7, $token, true), '失效');
Store::$tables['diagnosis'][0]['patient_id'] = 501;
archiveFails(static fn () => archiveStep(101, 7, 'bad-token', true), '无效');
archiveFails(static fn () => archiveStep(101, 7, str_repeat('a', 48), true), '失效');
archiveExpect(count(TencentImService::$requests) === $requestCount && Store::$cache[$sessionKey] === $saved, 'Invalid token reuse cannot call IM or change saved progress');
Store::$clock = 2000;
TencentImService::$responses = [archivePage(true, 199, 'last', [archiveRaw('last', 199, 501, true)])];
$doctorSide = archiveStep(101, 7, $token, false);
archiveExpect(!$doctorSide['completed'] && Store::$cache[$sessionKey]['side'] === 1
&& !isset(Store::$cache[$checkpointKey]), 'Finishing doctor-side pages starts the patient-side scan without a complete checkpoint');
TencentImService::$responses = [archivePage(true, 198, 'patient-only', [archiveRaw('first', 200), archiveRaw('patient-only', 198, 501, true)])];
$last = archiveStep(101, 7, $token, true);
archiveExpect($last['completed'] && $last['errors'] === [] && $last['inserted'] === 3, 'Only both persisted perspectives complete a conversation, including patient-only history');
archiveExpect(Store::$cache[$checkpointKey] === 1000, 'Complete checkpoint uses the scan start time, not archive MAX(msg_time) or finish time');
archiveExpect(TencentImService::$requests[1]['maxTime'] === 200 && TencentImService::$requests[1]['lastMsgKey'] === 'first'
&& TencentImService::$requests[1]['operatorAccount'] === 'doctor_7', 'Token continuation preserves cursor and cannot expand current-peer scope');
archiveExpect(TencentImService::$requests[2]['operatorAccount'] === 'patient_501'
&& TencentImService::$requests[2]['peerAccount'] === 'doctor_7' && TencentImService::$requests[2]['minTime'] === 0
&& TencentImService::$requests[2]['maxTime'] === 4294967295, 'Patient-side scan swaps perspective and restarts the same time range');
archiveExpect(count(Store::$tables['messages']) === 3 && Store::$tables['messages'][2]['doctor_peer_account'] === 'doctor_7', 'Both perspectives deduplicate shared messages and preserve doctor attribution');
$eventKinds = array_column(Store::$events, 0);
archiveExpect($eventKinds === ['cache', 'archive', 'cache', 'archive', 'cache', 'archive', 'cache', 'cache'], 'Both-side page persistence precedes checkpoint and session progress writes');
TencentImService::$responses = [archivePage(true, null, null, [])];
$incremental = archiveStep(101, 7, '', true);
archiveFinishPatientSide($incremental['sync_token']);
archiveExpect(TencentImService::$requests[3]['minTime'] === 880 && TencentImService::$requests[4]['minTime'] === 880, 'Only a completed checkpoint can enable the same overlap range for both perspectives');
// A later-page IM error reports failure and leaves the complete checkpoint untouched.
Store::reset();
Store::$cache[$checkpointKey] = 250;
TencentImService::$responses = [archivePage(false, 300, 'a', [archiveRaw('a', 300)]),
['success' => false, 'rawErrorCode' => 91000, 'error' => 'page two unavailable']];
$first = archiveStep(101, 7, '', true);
$failedDoctorSide = archiveStep(101, 7, $first['sync_token'], true);
archiveExpect(!$failedDoctorSide['completed'], 'Doctor-side failure still permits the patient-side attempt');
$failed = archiveFinishPatientSide($first['sync_token']);
archiveExpect($failed['inserted'] === 1 && count($failed['errors']) === 1 && str_contains($failed['errors'][0], 'page two unavailable'), 'Failed page is visible while prior successfully archived pages remain');
archiveExpect(Store::$cache[$checkpointKey] === 250 && count(Store::$tables['messages']) === 1, 'An incomplete conversation preserves the previous complete checkpoint');
TencentImService::$responses = [archivePage(true, 299, 'b', [archiveRaw('a', 300), archiveRaw('b', 299)])];
$recoveredDoctorSide = archiveStep(101, 7, '', true);
$recovered = archiveFinishPatientSide($recoveredDoctorSide['sync_token']);
archiveExpect($recovered['completed'] && $recovered['errors'] === [] && $recovered['inserted'] === 1
&& count(Store::$tables['messages']) === 2 && TencentImService::$requests[3]['minTime'] === 130, 'Next round backfills from the unchanged checkpoint range and deduplicates the already archived page');
Store::reset();
TencentImService::$responses = [archivePage(true, 300, 'foreign', [archiveRaw('foreign', 300, 502)])];
$foreignPage = archiveStep(101, 7, '', true);
archiveExpect(count($foreignPage['errors']) === 1 && Store::$tables['messages'] === []
&& !isset(Store::$cache[$checkpointKey]), 'A cross-patient cloud page cannot be archived or create a complete checkpoint');
Store::reset();
Store::$cache[$checkpointKey] = 250;
TencentImService::$responses = [archivePage(true, 300, 'doctor-only', [archiveRaw('doctor-only', 300)]),
['success' => false, 'rawErrorCode' => 91000, 'error' => 'patient perspective unavailable']];
$doctorOnly = archiveStep(101, 7, '', true);
$patientSideFailure = archiveStep(101, 7, $doctorOnly['sync_token'], true);
archiveExpect($patientSideFailure['completed'] && count($patientSideFailure['errors']) === 1
&& str_contains($patientSideFailure['errors'][0], '患者侧') && Store::$cache[$checkpointKey] === 250,
'A patient-side failure also prevents advancing the complete checkpoint');
// A DB failure must throw before caching the new page cursor or complete checkpoint.
Store::reset();
TencentImService::$responses = [archivePage(false, 400, 'db-first', [archiveRaw('db-first', 400)])];
$first = archiveStep(101, 7, '', true);
$sessionKey = 'im_chat_sync:' . $first['sync_token'];
$beforeWriteFailure = Store::$cache[$sessionKey];
Store::$failOnExecute = Store::$executeCount + 1;
TencentImService::$responses = [archivePage(true, 399, 'db-last', [archiveRaw('db-last', 399)])];
archiveFails(static fn () => archiveStep(101, 7, $first['sync_token'], true), 'archive write failed');
archiveExpect(Store::$cache[$sessionKey] === $beforeWriteFailure && !isset(Store::$cache[$checkpointKey])
&& count(Store::$tables['messages']) === 1, 'Failed persistence leaves resumable session cursor and checkpoint unchanged');
Store::$failOnExecute = 0;
TencentImService::$responses = [archivePage(true, 399, 'db-last', [archiveRaw('db-last', 399)])];
$retried = archiveStep(101, 7, $first['sync_token'], true);
archiveExpect(!$retried['completed'] && $retried['inserted'] === 2 && count(Store::$tables['messages']) === 2
&& !isset(Store::$cache[$checkpointKey]), 'Retry archives the failed doctor-side page before starting the patient side');
archiveExpect(TencentImService::$requests[1] === TencentImService::$requests[2], 'DB-failed page is retried using the exact previous cursor');
archiveExpect(archiveFinishPatientSide($first['sync_token'])['completed'], 'Completion follows successful persistence and both perspective scans');
// A single cloud page can span several SQL batches; retry keeps the successful first batch idempotent.
Store::reset();
$bulkMessages = [];
for ($index = 0; $index < 81; $index++) $bulkMessages[] = archiveRaw('bulk-' . $index, 600);
Store::$failOnExecute = 2;
TencentImService::$responses = [archivePage(true, 600, 'bulk-80', $bulkMessages)];
archiveFails(static fn () => archiveStep(101, 7, '', true), 'archive write failed');
archiveExpect(count(Store::$tables['messages']) === 80 && !isset(Store::$cache[$checkpointKey]) && count(Store::$cache) === 1 && array_values(Store::$cache)[0]['cursor'] === [], 'A failed second SQL batch does not publish a completed page or session cursor');
Store::$failOnExecute = 0;
TencentImService::$responses = [archivePage(true, 600, 'bulk-80', $bulkMessages)];
$bulkDoctorSide = archiveStep(101, 7, '', true);
$bulkRetry = archiveFinishPatientSide($bulkDoctorSide['sync_token']);
archiveExpect($bulkRetry['completed'] && $bulkRetry['errors'] === [] && $bulkRetry['inserted'] === 1
&& count(Store::$tables['messages']) === 81, 'Retry finishes a partially persisted page without duplicating its first batch');
Store::reset();
Store::$failOnExecute = 1;
archiveFails(static fn () => DiagnosisLogic::archiveImCallbackMessage($callback), 'archive write failed');
TencentImService::$responses = [archivePage(true, 100, 'cli', [archiveRaw('cli', 100)])];
Store::$failOnExecute = Store::$executeCount + 1;
$cliFailure = DiagnosisLogic::syncImChatArchiveForDiagnosis(101);
archiveExpect(str_contains($cliFailure['error'] ?? '', 'archive write failed') && !$cliFailure['skipped_live_empty']
&& !isset(Store::$cache[$checkpointKey]), 'CLI sync also reports write failure instead of empty successful synchronization');
// Session policy continues other peers after a fetch failure, never after a swallowed archive failure.
$state = ImChatSyncSession::start(['doctor_7', 'doctor_8', 'doctor_7']);
$archiveCalls = 0;
$next = ImChatSyncSession::step($state, static function () { throw new RuntimeException('peer unavailable'); },
static function () use (&$archiveCalls): int { $archiveCalls++; return 1; });
archiveExpect($next['index'] === 0 && $next['side'] === 1 && $next['cursor'] === [] && count($next['errors']) === 1 && $archiveCalls === 0, 'Fetch failure starts the other side without archiving or marking that peer as successful');
$nextPeer = ImChatSyncSession::step($next, static fn (): array => ['msgList' => [], 'completed' => true, 'cursor' => []], static fn (): int => 0);
archiveExpect($nextPeer['index'] === 1 && $nextPeer['side'] === 0, 'Only finishing both perspectives moves to the next peer');
$lastSide = ImChatSyncSession::step($nextPeer, static fn (): array => ['msgList' => [], 'completed' => true, 'cursor' => []], static fn (): int => 0);
$finished = ImChatSyncSession::step($lastSide, static fn (): array => ['msgList' => [], 'completed' => true, 'cursor' => []], static fn (): int => 0);
archiveExpect(ImChatSyncSession::progress($finished)['completed'] && count($finished['errors']) === 1, 'Other peers can finish while the prior failure remains visible');
archiveFails(static fn () => ImChatSyncSession::step($state,
static fn (): array => ['msgList' => [], 'completed' => true, 'cursor' => []],
static function (): int { throw new RuntimeException('archive failed'); }), 'archive failed');
archiveExpect($state['index'] === 0 && $state['cursor'] === [], 'Archive failure does not mutate the caller state');
// Check candidates in bounded batches before querying any cloud history.
Store::reset();
Store::$tables['admin_role'] = array_map(static fn (int $id): array => ['admin_id' => $id, 'role_id' => 1], range(1, 184));
TencentImService::$missingAccounts = array_values(array_filter(array_map(static fn (int $id): string => 'doctor_' . $id, range(1, 184)), static fn (string $account): bool => $account !== 'doctor_7'));
$checking = DiagnosisLogic::syncImChatArchiveStep(101, 7);
archiveExpect($checking['phase'] === 'checking_accounts' && !$checking['completed'] && $checking['checked_accounts'] === 100
&& $checking['candidate_accounts'] === 185 && TencentImService::$requests === [], 'First request only validates one batch of 100 accounts');
$beforeCheckFailure = Store::$cache['im_chat_sync:' . $checking['sync_token']];
TencentImService::$checkFailure = new RuntimeException('account service permission denied', 70001);
archiveFails(static fn () => DiagnosisLogic::syncImChatArchiveStep(101, 7, $checking['sync_token']), 'permission denied');
archiveExpect(Store::$cache['im_chat_sync:' . $checking['sync_token']] === $beforeCheckFailure && TencentImService::$requests === [], 'Account check failure does not discard unknown accounts or advance progress');
TencentImService::$checkFailure = null;
$checked = DiagnosisLogic::syncImChatArchiveStep(101, 7, $checking['sync_token']);
archiveExpect($checked['phase'] === 'syncing' && $checked['total_peers'] === 1 && $checked['skipped_accounts'] === 183
&& $checked['errors'] === [] && TencentImService::$requests === [], 'Only imported doctor accounts become history peers; missing accounts are an informational count');
archiveExpect(count(TencentImService::$checkRequests[0]) === 100 && count(TencentImService::$checkRequests[2]) === 85, 'Continuation reuses the uncompleted second batch');
TencentImService::$responses = [archivePage(true, 900, 'valid', [archiveRaw('valid', 900)]), archivePage(true, null, null, [])];
DiagnosisLogic::syncImChatArchiveStep(101, 7, $checking['sync_token']);
$validFinished = DiagnosisLogic::syncImChatArchiveStep(101, 7, $checking['sync_token']);
archiveExpect($validFinished['completed'] && $validFinished['errors'] === [] && $validFinished['inserted'] === 1, 'Valid messages continue syncing despite 183 unregistered staff accounts');
archiveExpect(array_column(TencentImService::$requests, 'operatorAccount') === ['doctor_7', 'patient_501']
&& array_column(TencentImService::$requests, 'peerAccount') === ['patient_501', 'doctor_7'], 'Missing accounts are never sent to admin_getroammsg');
Store::reset();
Store::$tables['messages'][] = archiveStored('keep-archive', 501, 101, 'doctor_7', 'patient_501', 100);
TencentImService::$missingAccounts = ['doctor_7'];
$emptyPeers = DiagnosisLogic::syncImChatArchiveStep(101, 7);
archiveExpect($emptyPeers['completed'] && $emptyPeers['total_peers'] === 0 && $emptyPeers['skipped_accounts'] === 1 && $emptyPeers['errors'] === [], 'All staff missing completes without flooding errors or fabricating a failed conversation');
archiveExpect(count(DiagnosisLogic::getImChatMessagesForDiagnosis(101, true)['lists']) === 1 && TencentImService::$requests === [], 'Missing/deleted cloud accounts do not remove existing archives');
TencentImService::$missingAccounts = ['patient_501'];
archiveFails(static fn () => DiagnosisLogic::syncImChatArchiveStep(101, 7), '未找到患者聊天账号');
archiveExpect(TencentImService::$requests === [] && count(Store::$tables['messages']) === 1, 'Missing patient yields one actionable configuration error and keeps archived records');
// In-flight tokens from the previous release also go through validation, instead of repeating stale invalid-account errors.
Store::reset();
$oldToken = str_repeat('b', 48);
Store::$cache['im_chat_sync:' . $oldToken] = array_merge(ImChatSyncSession::start(['doctor_7', 'doctor_8']), [
'diagnosis_id' => 101, 'patient_id' => 501, 'admin_id' => 7, 'index' => 1,
'inserted' => 3, 'errors' => ['old invalid Operator_Account or Peer_Account'], 'active_index' => 1,
]);
TencentImService::$missingAccounts = ['doctor_8'];
$migrated = DiagnosisLogic::syncImChatArchiveStep(101, 7, $oldToken);
archiveExpect($migrated['total_peers'] === 1 && $migrated['inserted'] === 3 && $migrated['errors'] === []
&& $migrated['skipped_accounts'] === 1 && TencentImService::$requests === [], 'Old tokens retain archived counts and restart verified peer selection without stale errors');
echo "IM_CHAT_ARCHIVE_TEST_OK\n";
}