using System.Reflection; using Microsoft.AspNetCore.Mvc; using Admin.NET.Plugin.AiDOP.Controllers.S8; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Admin.NET.Plugin.AiDOP.Service.S8; using Admin.NET.Plugin.AiDOP.Service.S8.Rules; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-RULE-GOVERNANCE-BATCH2:租户运行策略供给契约。 /// /// 本文件要证明的三条不变量: /// /// 身份 = (TenantId, RuleCode)。Factory 不参与 —— 0 / 1 / N 个工厂都只产出一条策略; /// 租户隔离。同一条规则定义在不同租户下是彼此独立的策略,改 A 不影响 B; /// 不覆盖租户已调整的参数与运行态。供给只纠正定义投影。 /// /// /// 测试打在纯决策函数 S8RuleProvisioningService.Plan 上,不接数据库 —— /// 这三条不变量必须能被逐字段断言,而不是"跑一遍看起来对"。 /// public class S8RuleProvisioningIdempotencyTests { private const long TenantA = 838257186181189L; private const long TenantB = 838257212780613L; private const string Rule01 = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode; private static readonly DateTime Now = new(2026, 9, 6, 14, 0, 0); private static List Definitions() => new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() }) .Definitions.ToList(); private static S8RuleProvisioningPlan Plan( IReadOnlyCollection tenants, IReadOnlyCollection existing, IReadOnlyCollection? definitions = null) => S8RuleProvisioningService.Plan(tenants, definitions ?? Definitions(), existing, Now); /// 一条租户已经调整过、并且跑过一段时间的既有策略行。 private static AdoS8WatchRule TunedRow(long tenantId, long id = 1000, string ruleCode = Rule01) => new() { Id = id, TenantId = tenantId, FactoryId = 838257186320453L, // 历史值,供给不得改写 RuleCode = ruleCode, // 定义投影:与代码定义一致 DatasetCode = S8BusinessDatasetDefinitions.PurchaseDeliveryCode, RuleType = "TIMEOUT", SourceObjectType = S8PurchaseDeliveryRuleDefinitions.PurchaseOrderLineObjectType, WatchObjectType = S8PurchaseDeliveryRuleDefinitions.PurchaseOrderLineObjectType, SceneCode = "S4", StageCode = "S4", OrderFlowCode = "MATERIAL_PURCHASE", RuleMechanism = "DATE", // 租户调过的参数 Enabled = true, Severity = S8SeverityCode.Follow, PollIntervalSeconds = 900, TriggerCountRequired = 3, RecoverCountRequired = 5, ParamsJson = """{"graceMinutes":60,"defaultOccurrenceDeptId":245}""", // 运行态 NextRunAt = new DateTime(2026, 9, 6, 13, 50, 0), LastRunAt = new DateTime(2026, 9, 6, 13, 40, 0), LastStatus = "SUCCESS", LastError = "prev-error", LastDurationMs = 255, LastRunId = "0da4435a127c47dd", LockToken = "tok", LockedBy = "host-1", LockUntil = new DateTime(2026, 9, 6, 13, 45, 0), RunningStartedAt = new DateTime(2026, 9, 6, 13, 40, 0), ConsecutiveFailureCount = 2, PausedUntil = new DateTime(2026, 9, 6, 12, 0, 0), PauseReason = "MANUAL_PAUSED", CreatedAt = new DateTime(2026, 9, 5, 11, 24, 43) }; // ───────────────────────── T1 / T2:建行与幂等 ───────────────────────── [Fact] public void T1_MissingTenantRule_CreatesExactlyOne() { var plan = Plan(new[] { TenantA }, Array.Empty()); var row = Assert.Single(plan.Inserts); Assert.Equal(TenantA, row.TenantId); Assert.Equal(Rule01, row.RuleCode); Assert.Empty(plan.Updates); Assert.Equal(0, plan.UnchangedCount); } [Fact] public void T2_SecondAndThirdRun_CreateNothing() { var created = Plan(new[] { TenantA }, Array.Empty()).Inserts; created[0].Id = 1; // 模拟落库后回填主键 var second = Plan(new[] { TenantA }, created); Assert.Empty(second.Inserts); Assert.Empty(second.Updates); Assert.Equal(1, second.UnchangedCount); var third = Plan(new[] { TenantA }, created); Assert.Empty(third.Inserts); Assert.Empty(third.Updates); } // ───────────────────────── T3 / T4 / T5:绝不覆盖租户侧 ───────────────────────── [Fact] public void T3_ExistingRuntimeParameters_AreUntouched() { var row = TunedRow(TenantA); Plan(new[] { TenantA }, new[] { row }); Assert.Equal(900, row.PollIntervalSeconds); Assert.Equal(3, row.TriggerCountRequired); Assert.Equal(5, row.RecoverCountRequired); Assert.Equal(S8SeverityCode.Follow, row.Severity); Assert.Equal("""{"graceMinutes":60,"defaultOccurrenceDeptId":245}""", row.ParamsJson); } [Fact] public void T4_ExistingEnabled_IsUntouched() { var enabled = TunedRow(TenantA); Plan(new[] { TenantA }, new[] { enabled }); Assert.True(enabled.Enabled); var disabled = TunedRow(TenantA); disabled.Enabled = false; Plan(new[] { TenantA }, new[] { disabled }); Assert.False(disabled.Enabled); } [Fact] public void T5_ExistingRuntimeState_IsUntouched() { var row = TunedRow(TenantA); Plan(new[] { TenantA }, new[] { row }); Assert.Equal(new DateTime(2026, 9, 6, 13, 50, 0), row.NextRunAt); Assert.Equal(new DateTime(2026, 9, 6, 13, 40, 0), row.LastRunAt); Assert.Equal("SUCCESS", row.LastStatus); Assert.Equal("prev-error", row.LastError); Assert.Equal(255, row.LastDurationMs); Assert.Equal("0da4435a127c47dd", row.LastRunId); Assert.Equal("tok", row.LockToken); Assert.Equal("host-1", row.LockedBy); Assert.Equal(new DateTime(2026, 9, 6, 13, 45, 0), row.LockUntil); Assert.Equal(new DateTime(2026, 9, 6, 13, 40, 0), row.RunningStartedAt); Assert.Equal(2, row.ConsecutiveFailureCount); Assert.Equal(new DateTime(2026, 9, 6, 12, 0, 0), row.PausedUntil); Assert.Equal("MANUAL_PAUSED", row.PauseReason); Assert.Equal(838257186320453L, row.FactoryId); // FactoryId 同样不得改写 } // ───────────────────────── T6:投影漂移被纠正 ───────────────────────── [Fact] public void T6_WrongDefinitionProjection_IsRefreshed_WithoutTouchingTenantPolicy() { var row = TunedRow(TenantA); row.DatasetCode = "WRONG_DATASET"; row.RuleType = "OUT_OF_RANGE"; row.SceneCode = "S1"; row.SourceObjectType = "EVIL_OBJECT"; row.WatchObjectType = "EVIL_OBJECT"; row.StageCode = null; row.OrderFlowCode = null; row.RuleMechanism = null; var plan = Plan(new[] { TenantA }, new[] { row }); Assert.Same(row, Assert.Single(plan.Updates)); Assert.Equal(S8BusinessDatasetDefinitions.PurchaseDeliveryCode, row.DatasetCode); Assert.Equal("TIMEOUT", row.RuleType); Assert.Equal("S4", row.SceneCode); Assert.Equal("S4", row.StageCode); Assert.Equal("MATERIAL_PURCHASE", row.OrderFlowCode); Assert.Equal("DATE", row.RuleMechanism); Assert.Equal(S8PurchaseDeliveryRuleDefinitions.PurchaseOrderLineObjectType, row.SourceObjectType); Assert.Equal(S8PurchaseDeliveryRuleDefinitions.PurchaseOrderLineObjectType, row.WatchObjectType); // 代码定义是权威,但它只管定义 —— 租户策略与运行态原封不动。 Assert.True(row.Enabled); Assert.Equal(900, row.PollIntervalSeconds); Assert.Equal(S8SeverityCode.Follow, row.Severity); Assert.Equal("""{"graceMinutes":60,"defaultOccurrenceDeptId":245}""", row.ParamsJson); Assert.Equal("SUCCESS", row.LastStatus); Assert.Equal(838257186320453L, row.FactoryId); } // ───────────────────────── T7 / T8:ParamsJson 不重写 ───────────────────────── [Fact] public void T7_LegacyMixedParamsJson_IsNotRewritten() { const string legacy = """ {"dueAtField":"due_at","statusField":"status","completedStates":["COMPLETED"], "objectIdField":"source_object_id","exceptionTypeCode":"PURCHASE_DELIVERY_ABNORMAL", "graceMinutes":30,"defaultOccurrenceDeptId":245} """; var row = TunedRow(TenantA); row.ParamsJson = legacy; row.DatasetCode = "WRONG"; // 同时触发一次投影刷新,证明刷新不会顺手动 params Plan(new[] { TenantA }, new[] { row }); Assert.Equal(legacy, row.ParamsJson); } /// /// G1 造成的历史状态:params_json = NULL。 /// 供给**不得**自动补写默认 JSON —— Batch 1 已证明 NULL + 代码定义可以正常运行, /// 悄悄补写反而是在未经请求的情况下改变租户策略。 /// [Fact] public void T8_NullParamsJson_IsNotBackfilled() { var row = TunedRow(TenantA); row.ParamsJson = null; row.RuleType = "WRONG"; // 触发投影刷新 var plan = Plan(new[] { TenantA }, new[] { row }); Assert.Single(plan.Updates); Assert.Null(row.ParamsJson); } // ───────────────────────── T9 / T10:租户隔离(本批核心) ───────────────────────── [Fact] public void T9_TwoTenants_GetTwoIndependentRows() { var plan = Plan(new[] { TenantA, TenantB }, Array.Empty()); Assert.Equal(2, plan.Inserts.Count); Assert.Equal(new[] { TenantA, TenantB }, plan.Inserts.Select(r => r.TenantId).ToArray()); Assert.All(plan.Inserts, r => Assert.Equal(Rule01, r.RuleCode)); // 两行必须是不同实例:共享引用会让一个租户改参数波及另一个。 Assert.NotSame(plan.Inserts[0], plan.Inserts[1]); } [Fact] public void T10_TenantParametersDoNotLeakAcrossTenants() { var a = TunedRow(TenantA, id: 1); a.ParamsJson = """{"graceMinutes":60}"""; var b = TunedRow(TenantB, id: 2); b.ParamsJson = """{"graceMinutes":0}"""; b.Enabled = false; b.PollIntervalSeconds = 300; Plan(new[] { TenantA, TenantB }, new[] { a, b }); Assert.Equal("""{"graceMinutes":60}""", a.ParamsJson); Assert.Equal("""{"graceMinutes":0}""", b.ParamsJson); Assert.True(a.Enabled); Assert.False(b.Enabled); Assert.Equal(900, a.PollIntervalSeconds); Assert.Equal(300, b.PollIntervalSeconds); } [Fact] public void T10_SyncingOneTenant_DoesNotTouchAnother() { var a = TunedRow(TenantA, id: 1); var b = TunedRow(TenantB, id: 2); b.DatasetCode = "WRONG_BUT_OUT_OF_SCOPE"; // 只对 A 做对账;B 的行即使投影是错的也不该进入方案。 var plan = Plan(new[] { TenantA }, new[] { a, b }); Assert.Empty(plan.Inserts); Assert.Empty(plan.Updates); Assert.Equal("WRONG_BUT_OUT_OF_SCOPE", b.DatasetCode); } // ───────────────────────── T11 / T12:Factory 与身份无关 ───────────────────────── /// /// 防回归:多工厂租户绝不能产出多条策略。 /// 这是最容易被后来者"顺手改回去"的地方 —— 一旦有人把 factory 加进身份,本用例立刻失败。 /// [Theory] [InlineData(0)] // 零工厂租户 [InlineData(1)] // 单工厂租户 [InlineData(3)] // 多工厂租户 public void T11_T12_TenantAlwaysGetsExactlyOnePolicyPerRule_RegardlessOfFactoryCount(int factoryCount) { // 工厂数量对 Plan 而言根本不是输入 —— 这正是要断言的事实: // 供给的输入只有租户与定义,工厂无从参与。 _ = factoryCount; var plan = Plan(new[] { TenantA }, Array.Empty()); var row = Assert.Single(plan.Inserts); Assert.Equal(S8RuleProvisioningService.CompatibilityFactoryId, row.FactoryId); } /// /// 历史上按工厂各建了一条 Rule 01(同 tenant + 同 rule_code,不同 factory_id)。 /// 供给必须把它们视为**同一个身份**:只刷新 Id 最小的一行、不再新建、多余行上报但不删。 /// [Fact] public void T11_PerFactoryLegacyRows_AreTreatedAsOneIdentity() { var f1 = TunedRow(TenantA, id: 10); f1.FactoryId = 111; f1.DatasetCode = "WRONG"; var f2 = TunedRow(TenantA, id: 20); f2.FactoryId = 222; f2.DatasetCode = "WRONG"; var f3 = TunedRow(TenantA, id: 30); f3.FactoryId = 333; f3.DatasetCode = "WRONG"; var plan = Plan(new[] { TenantA }, new[] { f2, f3, f1 }); Assert.Empty(plan.Inserts); // 不因"工厂缺行"而新建 Assert.Same(f1, Assert.Single(plan.Updates)); // 只刷新 Id 最小的一行 Assert.Equal(2, plan.DuplicateCount); // 其余上报 Assert.Equal("WRONG", f2.DatasetCode); // 不动 Assert.Equal("WRONG", f3.DatasetCode); Assert.Equal(111, f1.FactoryId); // 既有 FactoryId 保持 } [Fact] public void T12_NewRowUsesCompatibilityFactoryId_NotAResolvedFactory() { var row = Assert.Single(Plan(new[] { TenantA }, Array.Empty()).Inserts); // 0 = 无工厂作用域的兼容 metadata。绝不是"解析出来的唯一工厂"—— // 那样零工厂 / 多工厂租户会直接供给失败。 Assert.Equal(0L, row.FactoryId); } // ───────────────────────── T13 / T14:停用租户与孤儿 ───────────────────────── /// /// 停用租户不进入租户列表(SysTenant.Status = 1 过滤发生在 SyncAsync), /// 因此不会产生新行。本用例从 Plan 层确认:不在列表里 = 不产出。 /// [Fact] public void T13_TenantNotInActiveList_GetsNoNewRow() { var plan = Plan(Array.Empty(), Array.Empty()); Assert.Empty(plan.Inserts); Assert.Empty(plan.Updates); } [Fact] public void T13_ExistingRowOfInactiveTenant_IsNotDeletedNorDisabled() { var inactive = TunedRow(TenantB, id: 77); // 只对 A 对账(B 停用故不在列表)。B 的行必须原封不动。 Plan(new[] { TenantA }, new[] { inactive }); Assert.True(inactive.Enabled); Assert.Equal(S8SeverityCode.Follow, inactive.Severity); } [Fact] public void T14_OrphanRule_IsReportedButNeverDeletedOrChanged() { var orphan = TunedRow(TenantA, id: 55, ruleCode: "RULE_THAT_NO_LONGER_EXISTS"); orphan.DatasetCode = "SOME_OLD_DATASET"; var plan = Plan(new[] { TenantA }, new[] { orphan }); Assert.Equal(1, plan.OrphanedCount); Assert.DoesNotContain(orphan, plan.Updates); Assert.Equal("SOME_OLD_DATASET", orphan.DatasetCode); Assert.True(orphan.Enabled); // 孤儿不阻碍正常供给:Rule 01 仍会被建出来。 Assert.Equal(Rule01, Assert.Single(plan.Inserts).RuleCode); } // ───────────────────────── T15:新增定义自动铺开 ───────────────────────── [Fact] public void T15_NewDefinition_IsProvisionedToEveryExistingTenant() { var definitions = Definitions(); definitions.Add(FakeRule02()); var existingA = TunedRow(TenantA, id: 1); var existingB = TunedRow(TenantB, id: 2); var plan = Plan(new[] { TenantA, TenantB }, new[] { existingA, existingB }, definitions); Assert.Equal(2, plan.Inserts.Count); Assert.All(plan.Inserts, r => Assert.Equal("RULE_S4_FAKE_FOR_TEST", r.RuleCode)); Assert.Equal(new[] { TenantA, TenantB }, plan.Inserts.Select(r => r.TenantId).ToArray()); // 既有 Rule 01 不受影响。 Assert.Equal(2, plan.UnchangedCount); } // ───────────────────────── 新行契约 ───────────────────────── [Fact] public void NewRow_UsesDefinitionDefaults_AndNoFakedRunHistory() { var row = Assert.Single(Plan(new[] { TenantA }, Array.Empty()).Inserts); // 定义投影 Assert.Equal(S8BusinessDatasetDefinitions.PurchaseDeliveryCode, row.DatasetCode); Assert.Equal("TIMEOUT", row.RuleType); Assert.Equal("DATE", row.RuleMechanism); Assert.Equal("S4", row.SceneCode); Assert.Equal("S4", row.StageCode); Assert.Equal("MATERIAL_PURCHASE", row.OrderFlowCode); // 运行参数默认值来自定义策略 Assert.False(row.Enabled); Assert.Equal(S8SeverityCode.Serious, row.Severity); Assert.Equal(300, row.PollIntervalSeconds); Assert.Equal(1, row.TriggerCountRequired); Assert.Equal(2, row.RecoverCountRequired); Assert.Equal("""{"graceMinutes":0}""", row.ParamsJson); // 运行态:一条尚未运行的规则该有的样子,不伪造历史 Assert.Null(row.NextRunAt); Assert.Null(row.LastRunAt); Assert.Null(row.LastStatus); Assert.Null(row.LastError); Assert.Null(row.LastDurationMs); Assert.Null(row.LastRunId); Assert.Null(row.LockToken); Assert.Null(row.LockedBy); Assert.Null(row.LockUntil); Assert.Null(row.RunningStartedAt); Assert.Equal(0, row.ConsecutiveFailureCount); Assert.Null(row.PausedUntil); Assert.Null(row.PauseReason); Assert.Null(row.UpdatedAt); Assert.Equal(0, row.Id); } /// 新行的 params_json 只能是 B 类运行参数,绝不能带任何判定语义。 [Fact] public void NewRow_ParamsJson_CarriesNoJudgementSemantics() { var row = Assert.Single(Plan(new[] { TenantA }, Array.Empty()).Inserts); foreach (var forbidden in new[] { "dueAtField", "statusField", "completedStates", "objectIdField", "objectCodeField", "exceptionTypeCode" }) Assert.DoesNotContain(forbidden, row.ParamsJson!, StringComparison.Ordinal); } // ───────────────────────── T16 / T17 / T18:端点 · 注册 · 依赖方向 ───────────────────────── [Fact] public void T16_ProvisionEndpoint_IsPost_Scoped_AndReturnsObservableCounters() { var action = Assert.Single(typeof(AdoS8ConfigWatchRulesController) .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .Where(m => m.Name == "ProvisionAsync")); Assert.Equal("provision", Assert.Single(action.GetCustomAttributes()).Template); Assert.NotEmpty(action.GetCustomAttributes() .Where(a => a.GetType().Name.Contains("S8Permission", StringComparison.Ordinal))); // 结果必须可观测:只回 200 会让运维不知道新规则有没有铺开。 foreach (var counter in new[] { "TenantCount", "DefinitionCount", "CreatedCount", "RefreshedCount", "UnchangedCount", "OrphanedCount" }) Assert.NotNull(typeof(S8RuleProvisioningResult).GetProperty(counter)); } [Fact] public void T17_Service_ExposesBothFullAndPerTenantSync() { var t = typeof(S8RuleProvisioningService); Assert.NotNull(t.GetMethod("SyncAsync")); Assert.NotNull(t.GetMethod("SyncTenantAsync")); } /// /// T18 + 依赖方向守卫:供给**不得**架在即将退役的业务建规则路径之上。 /// Batch 3 会删掉 CreateAsync;如果这里依赖它,那一批会连带炸掉供给。 /// [Fact] public void T18_ProvisioningDoesNotDependOnLegacyCreatePath() { var deps = Assert.Single(typeof(S8RuleProvisioningService).GetConstructors()) .GetParameters().Select(p => p.ParameterType.Name).ToArray(); Assert.DoesNotContain(deps, d => d.Contains("S8WatchRuleService", StringComparison.Ordinal)); Assert.DoesNotContain(deps, d => d.Contains("S8ConfigDraftService", StringComparison.Ordinal)); // 正向:依赖方向必须是 Catalog → Provisioning → DB。 Assert.Contains(deps, d => d.Contains("IS8RuleCatalog", StringComparison.Ordinal)); } /// /// Factory 守卫:身份判定不得含 FactoryId。用源码扫描而非行为断言 —— /// 谓词写没写在方法体里,反射看不到。 /// [Fact] public void FactoryGuard_IdentityPredicateNeverIncludesFactory() { var code = SourceOf("Service/S8/S8RuleProvisioningService.cs"); // 存在性查询只按 tenant_id 过滤 Assert.Contains("Where(x => tenantIds.Contains(x.TenantId))", code); // 身份分组键只有 (TenantId, RuleCode) Assert.Contains("GroupBy(r => (r.TenantId, Code: r.RuleCode ?? string.Empty)", code); // 可执行代码里不得出现按工厂比较的身份谓词 foreach (var forbidden in new[] { "x.FactoryId == ", "r.FactoryId == ", "FactoryId == scope.FactoryId" }) Assert.DoesNotContain(forbidden, ExecutableLines(code)); } private static S8RuleDefinition FakeRule02() => new() { RuleCode = "RULE_S4_FAKE_FOR_TEST", DisplayName = "测试用第二条规则", Description = "验证新增定义会自动铺开到每个既有租户", DatasetCode = S8BusinessDatasetDefinitions.PurchaseDeliveryCode, RuleType = "TIMEOUT", RuleMechanism = "DATE", SourceObjectType = "PURCHASE_ORDER_LINE", SceneCode = "S4", StageCode = "S4", ExceptionTypeCode = "PURCHASE_DELIVERY_ABNORMAL", Timeout = new S8TimeoutSemantics() }; private static string SourceOf(string relative) { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP"))) dir = dir.Parent; Assert.NotNull(dir); var full = Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP", relative.Replace('/', Path.DirectorySeparatorChar)); Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}"); return File.ReadAllText(full); } /// 只取可执行代码行:注释里为留档会复述旧写法,不应算违规。 private static string ExecutableLines(string code) => string.Join('\n', code.Split('\n') .Select(l => l.TrimStart()) .Where(l => !l.StartsWith("///", StringComparison.Ordinal) && !l.StartsWith("//", StringComparison.Ordinal))); }