using System.Reflection; using Microsoft.AspNetCore.Mvc; using Admin.NET.Plugin.AiDOP.Controllers.S8; using Admin.NET.Plugin.AiDOP.Service.S8; using Admin.NET.Plugin.AiDOP.Service.S8.Rules; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-RULE01-INTEGRATION-PREVIEW-1:预演端点的**零副作用**契约。 /// /// 预演的全部价值建立在一条保证上:它什么都不写。 /// 一旦有人给它加一个"顺便把命中落库"的分支,它就从"启用前的安全窗口" /// 变成"另一条没人审过的建单入口" —— 而且因为名字叫 preview,没人会去审它。 /// 因此本文件用结构性断言把这条钉死,而不是依赖注释和自觉。 /// /// 不接 DB、不接 DI:全部断言基于类型结构与依赖形态。 /// public class S8WatchRulePreviewContractTests { private static readonly Type Service = typeof(S8WatchRulePreviewService); /// /// 预演服务只允许持有「取数 + 判定 + 读既有异常」所需的依赖。 /// 出现任何建单 / 通知 / 流转类服务都意味着它可能产生副作用。 /// [Fact] public void PreviewService_HoldsNoWriteCapableDependency() { var deps = Assert.Single(Service.GetConstructors()) .GetParameters().Select(p => p.ParameterType.Name).ToArray(); foreach (var forbidden in new[] { "S8ManualReportService", // 建单 "S8TaskFlowService", // 状态流转 "S8NotificationLayerResolver",// 通知 "S8WatchSchedulerService", // 会写 detection / 推进租约 "S8ExceptionService" // 异常写入口 }) Assert.DoesNotContain(forbidden, deps); // 正向:取数网关与两个只读仓储必须在,否则它没法走生产同款链路。 Assert.Contains("S8MonitoringDataGateway", deps); } /// /// 预演服务不得出现任何写方法。命名约定在本仓是稳定的 /// (Create/Update/Delete/Save/Insert/Write/Execute…),逐个排除比读注释可靠。 /// [Fact] public void PreviewService_ExposesOnlyPreview() { var publicMethods = Service .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .Select(m => m.Name) .ToArray(); Assert.Equal(new[] { "PreviewAsync" }, publicMethods); foreach (var verb in new[] { "Create", "Update", "Delete", "Save", "Insert", "Write", "Run", "Execute" }) Assert.DoesNotContain(publicMethods, n => n.StartsWith(verb, StringComparison.Ordinal)); } /// /// 端点必须是 GET。用 POST 会让调用方以为"点一下会发生点什么", /// 也会让它在审计与网关限流里被归入写操作那一类。 /// [Fact] public void PreviewEndpoint_IsHttpGet_AndScoped() { var action = Assert.Single( typeof(AdoS8ConfigWatchRulesController) .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .Where(m => m.Name == "PreviewAsync")); var get = Assert.Single(action.GetCustomAttributes()); Assert.Equal("{id:long}/preview", get.Template); Assert.Empty(action.GetCustomAttributes()); Assert.Empty(action.GetCustomAttributes()); Assert.Empty(action.GetCustomAttributes()); // 必须带权限;未登记路由会退回平台的「默认放行」。 Assert.NotEmpty(action.GetCustomAttributes() .Where(a => a.GetType().Name.Contains("S8Permission", StringComparison.Ordinal))); } /// /// 预演必须能对 enabled=false 的规则工作 —— 这正是它存在的理由: /// 先看清会命中什么,再决定启不启用。若签名或结果里带上"仅启用规则"的约束, /// 它就退化成了事后观察工具。 /// [Fact] public void PreviewResult_ReportsEnabledState_WithoutRequiringIt() { var enabled = typeof(S8WatchRulePreviewResult).GetProperty("Enabled"); Assert.NotNull(enabled); Assert.Equal(typeof(bool), enabled!.PropertyType); // 结果必须能回答"会新建多少 / 会命中既有多少"——只给总命中数无法评估污染规模。 foreach (var required in new[] { "CandidateRows", "HitCount", "NoHitRows", "WouldCreateCount", "WouldMatchExistingCount" }) Assert.NotNull(typeof(S8WatchRulePreviewResult).GetProperty(required)); } /// /// 单条命中必须带 DedupKey 与既有异常 id: /// 没有这两项就无法回答"真跑一次会不会重复建单",而那正是启用前最该确认的事。 /// [Fact] public void PreviewHit_CarriesIdentityAndDedupContext() { foreach (var required in new[] { "SourceObjectType", "SourceObjectId", "RelatedObjectCode", "ExceptionTypeCode", "Severity", "DedupKey", "ExistingActiveExceptionId" }) Assert.NotNull(typeof(S8WatchRulePreviewHit).GetProperty(required)); Assert.Equal(typeof(long?), typeof(S8WatchRulePreviewHit).GetProperty("ExistingActiveExceptionId")!.PropertyType); } // ============================================================ // S8-RULE-LIFECYCLE-CREATE-GATE-1:预演与首次立案资格的口径对齐 // // 修的是一个已在本地 Runtime 实测到的分叉: // 预演 命中 3 · 将新建 3 // 真跑 hits=3 · created=2 (1 条 create_not_eligible) // 差的那一条是已过客户交期、风险已经兑现的历史行 —— 它是真实命中 // (因而保护既有异常不被误判恢复),但不该再开一个新案子。 // 管理员正是拿「将新建」这个数字决定要不要启用,报大了比不报更糟。 // ============================================================ private static S8RuleHit Hit(string dedupKey, bool createEligible) => new() { SourceRuleCode = "RULE_UNDER_TEST", SourceObjectType = "SALES_ORDER_LINE", SourceObjectId = dedupKey, RelatedObjectCode = dedupKey, DedupKey = dedupKey, CreateEligible = createEligible }; private static IReadOnlyDictionary Active(params string[] dedupKeys) => dedupKeys.ToDictionary(k => k, k => (long)k.GetHashCode(), StringComparer.Ordinal); private static readonly IReadOnlyDictionary NoneActive = new Dictionary(StringComparer.Ordinal); /// P1:CreateEligible=false 的命中仍然是命中 [Fact] public void P1_IneligibleHit_StillCountsAsHit() { var hits = new[] { Hit("A", true), Hit("B", true), Hit("C", false) }; // HitCount 直接取 hits.Count(见 RunAsync)——这里锁的是"不得在命中侧做任何过滤"。 // 一旦有人为了让数字好看而把 ineligible 从 hits 里剔掉,恢复判定会立刻失去保护, // 假恢复原地复活。 Assert.Equal(3, hits.Length); Assert.Contains(hits, h => !h.CreateEligible); } /// P2:CreateEligible=false 不计入 WouldCreate。 [Fact] public void P2_IneligibleHit_IsNotCountedAsWouldCreate() { var hits = new[] { Hit("A", true), Hit("B", true), Hit("C", false) }; Assert.Equal(2, S8WatchRulePreviewService.CountWouldCreate(hits, NoneActive)); } /// P3:CreateEligible=true 仍按原有去重口径计入。 [Fact] public void P3_EligibleHits_KeepExistingDedupSemantics() { var hits = new[] { Hit("A", true), Hit("B", true) }; Assert.Equal(2, S8WatchRulePreviewService.CountWouldCreate(hits, NoneActive)); // A 已有活动异常 → 它会被刷新而不是新建(既有语义,未改动)。 Assert.Equal(1, S8WatchRulePreviewService.CountWouldCreate(hits, Active("A"))); Assert.Equal(1, S8WatchRulePreviewService.CountWouldMatchExisting(hits, Active("A"))); } /// /// P4:未声明闸门的规则(Rule 01 即是)行为逐字不变。 /// CreateEligible 默认 true,因此新过滤对它恒真、不改变任何计数。 /// [Fact] public void P4_RulesWithoutTheGate_AreUnaffected() { Assert.True(new S8RuleHit().CreateEligible); var rule01Style = new[] { new S8RuleHit { DedupKey = "P1" }, new S8RuleHit { DedupKey = "P2" } }; Assert.Equal(2, S8WatchRulePreviewService.CountWouldCreate(rule01Style, NoneActive)); Assert.Equal(1, S8WatchRulePreviewService.CountWouldCreate(rule01Style, Active("P1"))); Assert.Equal(1, S8WatchRulePreviewService.CountWouldMatchExisting(rule01Style, Active("P1"))); } /// /// P5:预演仍然零写入。本批只改了两个计数表达式,不得因此引入任何写能力。 /// (与本文件开头的依赖/方法名断言互补:那两条从依赖面证明,这条从源码面证明。) /// [Fact] public void P5_PreviewRemainsWriteFree() { var code = PreviewSource(); foreach (var write in new[] { "Insertable", "Updateable", "Deleteable", "ExecuteCommand", "SaveChanges" }) Assert.DoesNotContain(write, code, StringComparison.Ordinal); } /// P6:不得出现 RuleCode 特判 —— 闸门是通用能力,不是 Rule 02 的特例。 [Fact] public void P6_NoRuleCodeSpecialCasing() { var code = PreviewSource(); Assert.DoesNotContain("RULE_S1_ORDER_DELIVERY_DELAY_WARNING", code, StringComparison.Ordinal); Assert.DoesNotContain("RULE_S4_PURCHASE_DELIVERY", code, StringComparison.Ordinal); Assert.DoesNotContain("CreateOnlyBeforeDueAt", code, StringComparison.Ordinal); } /// /// P7:Rule 02 本地实测形态 —— 2 条未到期 + 1 条已过交期,均无既有异常。 /// 期望 Hits=3 / WouldCreate=2,与真跑的 hits=3 created=2 逐字一致。 /// [Fact] public void P7_Rule02LocalShape_HitsThree_WouldCreateTwo() { var hits = new[] { Hit("T838257186181189:R…:SALES_ORDER_LINE:SO202609030003#1", true), // 未到期 Hit("T838257186181189:R…:SALES_ORDER_LINE:SO202609030004#1", true), // 未到期 Hit("T838257186181189:R…:SALES_ORDER_LINE:SO202608240001#1", false) // 已过交期 }; Assert.Equal(3, hits.Length); Assert.Equal(2, S8WatchRulePreviewService.CountWouldCreate(hits, NoneActive)); Assert.Equal(0, S8WatchRulePreviewService.CountWouldMatchExisting(hits, NoneActive)); } /// /// P8:跨过交期的既有预警必须仍报「会刷新」。 /// 刷新分支受闸门影响 —— 闸门在调度器里位于刷新之后。 /// 若这里也过滤,预演会把「保持 active」误报成「什么都不会发生」。 /// [Fact] public void P8_IneligibleHitWithExistingException_StillReportsRefresh() { var hits = new[] { Hit("C", false) }; Assert.Equal(0, S8WatchRulePreviewService.CountWouldCreate(hits, Active("C"))); Assert.Equal(1, S8WatchRulePreviewService.CountWouldMatchExisting(hits, Active("C"))); } private static string PreviewSource() { var path = Path.GetFullPath(Path.Combine( AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRulePreviewService.cs")); Assert.True(File.Exists(path), $"预演服务源码路径需同步更新:{path}"); return string.Join('\n', File.ReadAllLines(path) .Where(l => { var t = l.TrimStart(); return !t.StartsWith("///", StringComparison.Ordinal) && !t.StartsWith("//", StringComparison.Ordinal); })); } }