Files
zyt/server/tests/FollowupAudioTrackingNoteBehaviorTest.php
T

144 lines
8.8 KiB
PHP

<?php
declare(strict_types=1);
namespace think\facade {
// In-memory transaction/row-lock fixture: no application bootstrap or database.
class Db
{
public static int $depth = 0;
public static int $calls = 0;
public static function transaction(callable $callback)
{
self::$calls++;
$snapshot = \app\common\model\tcm\TrackingNote::$rows;
self::$depth++;
try { return $callback(); }
catch (\Throwable $error) { \app\common\model\tcm\TrackingNote::$rows = $snapshot; throw $error; }
finally { self::$depth--; }
}
}
}
namespace app\common\model\tcm {
class FixtureQuery
{
public static array $events = [];
public static bool $enforceLocks = false;
private string $table;
private array $where = [];
private bool $locked = false;
public function __construct(string $table) { $this->table = $table; }
public function where(string $key, $value): self { $this->where[$key] = $value; return $this; }
public function whereNull(string $key): self { return $this->where($key, null); }
public function lock(bool $value): self { $this->locked = $value; return $this; }
public function find()
{
self::$events[] = ['table' => $this->table, 'where' => $this->where, 'locked' => $this->locked];
if (self::$enforceLocks && (!$this->locked || \think\facade\Db::$depth < 1)) {
throw new \RuntimeException('write read must lock inside caller transaction');
}
if ($this->table === 'diagnosis') return ($this->where['id'] ?? 0) === 7 ? (object) ['id' => 7] : null;
foreach (TrackingNote::$rows as $row) {
$matches = true;
foreach ($this->where as $key => $value) { if (($row[$key] ?? null) !== $value) $matches = false; }
if ($matches) return new FixtureRow($row);
}
return null;
}
}
class FixtureRow
{
public function __construct(private array $data) {}
public function __get(string $key) { return $this->data[$key] ?? null; }
public function __isset(string $key): bool { return isset($this->data[$key]); }
public function __set(string $key, $value): void { $this->data[$key] = $value; }
public function save(): void { TrackingNote::$rows[$this->data['id']] = $this->data; }
}
class Diagnosis
{
public static function where(string $key, $value): FixtureQuery { return (new FixtureQuery('diagnosis'))->where($key, $value); }
}
class TrackingNote
{
public static array $rows = [];
public static function where(string $key, $value): FixtureQuery { return (new FixtureQuery('tracking_note'))->where($key, $value); }
public static function create(array $data): FixtureRow
{
$data['id'] = count(self::$rows) + 1;
$data['delete_time'] = null;
self::$rows[$data['id']] = $data;
return new FixtureRow($data);
}
}
}
namespace {
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\common\model\tcm\TrackingNote;
use app\common\model\tcm\FixtureQuery;
use think\facade\Db;
require dirname(__DIR__) . '/app/common/logic/BaseLogic.php';
$sourceFlag = array_search('--source', $argv, true);
require $sourceFlag === false ? dirname(__DIR__) . '/app/adminapi/logic/tcm/TrackingNoteLogic.php' : $argv[$sourceFlag + 1];
date_default_timezone_set('Asia/Shanghai');
function check(bool $condition, string $message): void { if (!$condition) throw new RuntimeException($message); }
function rejects(callable $fn, string $message): void
{
try { $fn(); } catch (DomainException $expected) { return; }
throw new RuntimeException($message);
}
$today = (new DateTimeImmutable('now', new DateTimeZone('Asia/Shanghai')))->format('Y-m-d');
check(TrackingNoteLogic::addOrAppend(['diagnosis_id' => 7, 'admin_id' => 9, 'content' => '今日合成备注', 'note_date' => '2000-01-01']), 'legacy call remains accepted');
$legacy = reset(TrackingNote::$rows);
check($legacy['note_date'] === $today, 'legacy entry point still ignores submitted historical date');
if (in_array('--observe', $argv, true)) {
echo json_encode(['legacyTodayOnly' => $legacy['note_date'] === $today, 'explicitHistoricalDateSupported' => method_exists(TrackingNoteLogic::class, 'appendForDate')], JSON_UNESCAPED_UNICODE) . PHP_EOL;
exit(0);
}
check(method_exists(TrackingNoteLogic::class, 'appendForDate'), 'explicit historical API exists');
TrackingNote::$rows = [];
FixtureQuery::$events = [];
FixtureQuery::$enforceLocks = true;
$ownTransactions = Db::$calls;
$id = Db::transaction(static fn() => TrackingNoteLogic::appendForDate(7, ' 2024-02-29 ', ' 第一条合成历史备注 ', 9));
check(Db::$calls === $ownTransactions + 1, 'new method does not open or commit a nested transaction');
check($id === 1, 'actual created id returned');
check(TrackingNote::$rows[$id]['note_date'] === '2024-02-29', 'valid leap-day date normalized explicitly');
check(preg_match('/^\[\d{2}:\d{2}\] 第一条合成历史备注$/u', TrackingNote::$rows[$id]['content']) === 1, 'trimmed content has insertion-time prefix');
$again = Db::transaction(static fn() => TrackingNoteLogic::appendForDate(7, '2024-02-29', '第二条合成历史备注', 10));
check($again === $id && count(TrackingNote::$rows) === 1, 'same day appends instead of replacing or creating second note');
check(substr_count(TrackingNote::$rows[$id]['content'], "\n") === 1, 'two original lines preserved');
check(str_contains(TrackingNote::$rows[$id]['content'], '第一条合成历史备注'), 'first content retained');
check(TrackingNote::$rows[$id]['admin_id'] === 9, 'original creator retained; caller audit owns latest actor');
check(array_column(FixtureQuery::$events, 'table') === ['diagnosis', 'tracking_note', 'diagnosis', 'tracking_note'], 'diagnosis lock precedes exact-day row lock for both insert/update');
check(FixtureQuery::$events[1]['where'] === ['diagnosis_id' => 7, 'note_date' => '2024-02-29'], 'date and diagnosis scoped lookup includes deleted rows explicitly');
$before = TrackingNote::$rows;
try {
Db::transaction(static function (): void {
TrackingNoteLogic::appendForDate(7, '2024-02-29', '必须随调用者回滚', 9);
throw new RuntimeException('synthetic audit insert failure');
});
} catch (RuntimeException $error) { check($error->getMessage() === 'synthetic audit insert failure', 'expected synthetic rollback'); }
check(TrackingNote::$rows === $before, 'caller rollback restores note alongside task/audit transaction');
$tomorrow = (new DateTimeImmutable('tomorrow', new DateTimeZone('Asia/Shanghai')))->format('Y-m-d');
foreach (['', '2025-02-29', '2024-02-30', '2024-2-01', '2024/02/01', '0000-01-01', '2024-01-01 08:00:00', $tomorrow] as $invalid) {
rejects(static fn() => TrackingNoteLogic::appendForDate(7, $invalid, '合成备注', 9), 'invalid date rejected: ' . $invalid);
}
rejects(static fn() => TrackingNoteLogic::appendForDate(0, '2024-01-01', '内容', 9), 'missing diagnosis rejected');
rejects(static fn() => TrackingNoteLogic::appendForDate(7, '2024-01-01', '内容', 0), 'missing actor rejected');
rejects(static fn() => TrackingNoteLogic::appendForDate(7, '2024-01-01', ' ', 9), 'blank content rejected');
rejects(static fn() => Db::transaction(static fn() => TrackingNoteLogic::appendForDate(999, '2024-01-01', '内容', 9)), 'missing diagnosis row rejected');
TrackingNote::$rows[$id]['delete_time'] = 123;
rejects(static fn() => Db::transaction(static fn() => TrackingNoteLogic::appendForDate(7, '2024-02-29', '不能复活已删除备注', 9)), 'deleted note cannot be resurrected');
check(TrackingNote::$rows[$id]['delete_time'] === 123, 'deleted state retained');
TrackingNote::$rows = [];
FixtureQuery::$events = [];
check(TrackingNoteLogic::addOrAppend(['diagnosis_id' => 7, 'content' => '旧接口仍可追加']), 'legacy zero-actor semantics preserved');
check(reset(TrackingNote::$rows)['note_date'] === $today, 'legacy remains today only');
check(FixtureQuery::$events[0]['table'] === 'diagnosis' && FixtureQuery::$events[0]['locked'], 'legacy shares serialization lock with new service');
check(!TrackingNoteLogic::addOrAppend(['diagnosis_id' => 0, 'content' => '无效']), 'legacy invalid ID returns false');
check(!TrackingNoteLogic::addOrAppend(['diagnosis_id' => 7, 'content' => '']), 'legacy blank content returns false');
echo "FollowupAudioTrackingNoteBehaviorTest: historical date/leap-day validation, caller transaction rollback, lock order, append-only identity, deleted guard, legacy today semantics: PASS\n";
}