549 lines
37 KiB
PHP
549 lines
37 KiB
PHP
<?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";
|
|
}
|