using Admin.NET.Core.Update; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.Migration; /// /// 已登记「迁移记录被误记」重整策略的契约。 /// /// 被守护的核心不变量有两条,方向相反、同等重要: /// /// ① 不能误伤正常环境。闸门 1–4 任一不成立必须返回 None—— /// 全新环境(无日志行)、已升级完成的环境(hash 已是目标值)、失败重试中的环境(非 Success), /// 这三种都是正常状态,一旦在这里 Reject 就是把好好的库打死。 /// 其中「已升级完成」最危险:它与事故环境只差 file_hash 一个字段。 /// /// ② 命中事故签名之后不能沉默放行。闸门 5–7 是安全前提, /// 任一不成立必须 Reject 停机,绝不允许降级成「跳过重整继续启动」—— /// 该通道会修改迁移历史,沉默放行等于在无人知晓的情况下改写执行史。 /// /// 策略是纯函数,故这些用例不需要数据库;副作用(UPDATE / 抛异常)由 /// AutoVersionUpdate 执行,其真实运行结果由隔离库的 First Run / Second Run 覆盖。 /// public class MigrationCollisionPolicyTests { private const string V = "1.0.536"; private const string CollidedHash = "970E5A8F0133944CB04AC95E67112A2EC6E2E45F06AE61C3F2049163F7DC9A61"; private const string TargetHash = "5A18A15AB379167A151C317A18617E2841EC4E808C8FD727216360D81EA76776"; private const string DisplacedV = "1.0.539"; private const string DisplacedHash = "9B627E41C15EBB5195C035592870E56A9EDE5704FA423DDEC5A4A8FF94A6CE42"; /// 事故现场的完整参数;各用例只改动自己要验证的那一个维度。 private static MigrationCollisionOutcome Decide( string version = V, int matchedRowCount = 1, string? loggedStatus = "Success", string? loggedFileHash = CollidedHash, bool targetDiskHashMatches = true, string? displacedLoggedStatus = "Success", string? displacedLoggedFileHash = DisplacedHash, bool displacedDiskHashMatches = true) => MigrationCollisionPolicy.Decide( version, matchedRowCount, loggedStatus, loggedFileHash, targetDiskHashMatches, displacedLoggedStatus, displacedLoggedFileHash, displacedDiskHashMatches); // ── 正向:只有事故现场本身才允许重整 ──────────────────────────────────────────── [Fact] public void ExactAccidentSignature_ResetsRecord() { var outcome = Decide(); Assert.Equal(MigrationCollisionDecision.ResetCollidedRecord, outcome.Decision); Assert.Equal("COLLISION_STATUS_RESET", outcome.Plan!.Strategy); Assert.Equal(DisplacedV, outcome.Plan.DisplacedVersion); Assert.Null(outcome.RejectReason); } /// 不同驱动回读 hash / status 的形态不一致,大小写与首尾空白不应改变裁决。 [Theory] [InlineData("Success", "970e5a8f0133944cb04ac95e67112a2ec6e2e45f06ae61c3f2049163f7dc9a61")] [InlineData("SUCCESS", " " + CollidedHash + " ")] [InlineData("success", CollidedHash)] public void CaseInsensitiveAndTrimmed_StillResets(string status, string hash) => Assert.Equal( MigrationCollisionDecision.ResetCollidedRecord, Decide(loggedStatus: status, loggedFileHash: hash).Decision); // ── 闸门 1–4:正常环境一律 None,绝不 Reject ────────────────────────────────── [Fact] public void UnregisteredVersion_IsNone() { var outcome = Decide(version: "1.0.551"); Assert.Equal(MigrationCollisionDecision.None, outcome.Decision); Assert.Null(outcome.Plan); } /// 全新环境:该版本从未跑过 ⇒ 正常首次执行,不是事故场景。 [Fact] public void FreshEnvironmentWithNoRow_IsNone() => Assert.Equal(MigrationCollisionDecision.None, Decide(matchedRowCount: 0, loggedStatus: null, loggedFileHash: null).Decision); /// 唯一键理论上保证不会有多行;真出现了也只能停手,不能猜该动哪一行。 [Fact] public void MultipleRowsForVersion_IsNone() => Assert.Equal(MigrationCollisionDecision.None, Decide(matchedRowCount: 2).Decision); [Theory] [InlineData("Failed")] [InlineData("Running")] [InlineData("Skipped")] [InlineData("Superseded")] public void NonSuccessStatus_IsNone(string status) => Assert.Equal(MigrationCollisionDecision.None, Decide(loggedStatus: status).Decision); /// /// 最危险的一条:已经升级完成的环境,记录里是目标 hash + Success。 /// 它与事故环境只差 file_hash 一个字段,若这里 Reject 就会把所有正常环境打死, /// 必须放行给 ShouldSkipScript 按常规「hash 未变 ⇒ 跳过」处理。 /// [Fact] public void AlreadyUpgradedEnvironment_IsNone_NotReject() { var outcome = Decide(loggedFileHash: TargetHash); Assert.Equal(MigrationCollisionDecision.None, outcome.Decision); Assert.Null(outcome.RejectReason); } /// 某个我们没见过的 1.0.536 ⇒ 不认领,交给既有 hash 守卫去拒绝启动。 [Fact] public void UnknownHashForRegisteredVersion_IsNone() => Assert.Equal( MigrationCollisionDecision.None, Decide(loggedFileHash: "0000000000000000000000000000000000000000000000000000000000000000").Decision); // ── 闸门 5–7:命中签名后,安全前提不成立一律 Reject ──────────────────────────── /// 命中事故签名但磁盘不是目标 revision ⇒ 重整后会跑出未预期的脚本,必须停机。 [Fact] public void SignatureMatchedButWrongRevision_Rejects() { var outcome = Decide(targetDiskHashMatches: false); Assert.Equal(MigrationCollisionDecision.Reject, outcome.Decision); Assert.Contains(TargetHash, outcome.RejectReason); } /// /// 最关键的一道:被顶替的内容必须已被合法记录在案。 /// 拿不出这个证据就重整,等于可能抹掉一次真实发生过的执行史。 /// [Theory] [InlineData(null, DisplacedHash)] [InlineData("Failed", DisplacedHash)] [InlineData("Success", null)] [InlineData("Success", "0000000000000000000000000000000000000000000000000000000000000000")] public void DisplacedContentNotProvenRecorded_Rejects(string? status, string? hash) { var outcome = Decide(displacedLoggedStatus: status, displacedLoggedFileHash: hash); Assert.Equal(MigrationCollisionDecision.Reject, outcome.Decision); Assert.Contains(DisplacedV, outcome.RejectReason); } /// 磁盘上的被顶替脚本也必须仍是登记的那一份,否则「已记录的」与「本 revision 携带的」不是同一个东西。 [Fact] public void DisplacedScriptOnDiskDiffers_Rejects() => Assert.Equal(MigrationCollisionDecision.Reject, Decide(displacedDiskHashMatches: false).Decision); // ── 幂等:三种中断/完成状态下的收敛行为 ────────────────────────────────────── /// First Run 成功后:hash 已是目标值 ⇒ 永不再触发。 [Fact] public void AfterSuccessfulFirstRun_NeverTriggersAgain() => Assert.Equal(MigrationCollisionDecision.None, Decide(loggedFileHash: TargetHash).Decision); /// 重整后崩溃(Superseded + 旧 hash):闸门 3 不成立 ⇒ None; /// 而 ShouldSkipScript 对非 Success 行返回 false,脚本照常执行,仍然收敛。 [Fact] public void AfterResetButBeforeExecution_IsNone_SoNormalFlowProceeds() => Assert.Equal( MigrationCollisionDecision.None, Decide(loggedStatus: MigrationCollisionPolicy.SupersededStatus).Decision); /// 正式脚本执行失败(目标 hash + Failed):走常规重试,不再经过本策略。 [Fact] public void AfterFailedExecution_IsNone() => Assert.Equal( MigrationCollisionDecision.None, Decide(loggedStatus: "Failed", loggedFileHash: TargetHash).Decision); // ── 登记表本身的约束 ──────────────────────────────────────────────────────── /// /// 登记表只应包含这一条已评审的事故。新增条目 = 授权一次对迁移历史的修改, /// 必须先更新本用例,以强制评审发生。 /// [Fact] public void RegistryContainsOnlyTheReviewedCollision() { var plan = Assert.Single(MigrationCollisionPolicy.KnownCollisions); Assert.Equal(V, plan.Version); Assert.Equal(CollidedHash, plan.CollidedFileHash); Assert.Equal(TargetHash, plan.ExpectedDiskHash); Assert.Equal(DisplacedV, plan.DisplacedVersion); Assert.Equal(DisplacedHash, plan.DisplacedFileHash); } /// /// 重整写入的中间状态必须避开全部既有状态值,否则会被正常流程误解 /// (例如被 successVersions 统计、或被失败恢复策略认领)。 /// [Fact] public void SupersededStatusDoesNotCollideWithExistingStatuses() { foreach (var existing in new[] { "Running", "Success", "Failed", "Skipped" }) Assert.NotEqual(existing, MigrationCollisionPolicy.SupersededStatus, StringComparer.OrdinalIgnoreCase); } }