144 lines
8.8 KiB
PHP
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";
|
|
}
|