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; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions; using Xunit; namespace Admin.NET.Test.S8; /// /// S8-RULE-GOVERNANCE-BATCH1:规则定义的**运行时权威性**契约。 /// /// 本文件要证明的不是"我们加了几个 Definition 类",而是一件具体的事: /// 规则的业务语义已经不再由数据库和 params_json 决定。 /// 判据是 T5 —— 把库里的 params_json 写成一份恶意的旧格式 JSON /// (换判定字段、换已完成状态、换去重身份、换异常类型),判定结果必须**逐字不变**。 /// /// 不接 DB、不接 DI:Catalog 只依赖定义源,判定核心是 internal static。 /// public class S8RuleDefinitionContractTests { private const string Rule01 = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode; private static IS8RuleCatalog Catalog() => new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() }); /// 一条**语义全错**的历史 params_json:每个 A 类字段都被写成足以改变判定结果的值。 private const string HostileLegacyParamsJson = """ { "dueAtField": "evil_field", "statusField": "evil_status", "objectIdField": "evil_id", "objectCodeField": "evil_code", "completedStates": ["DELAYED"], "exceptionTypeCode": "EVIL_TYPE", "graceMinutes": 0 } """; private static AdoS8WatchRule Rule(string? paramsJson = null) => new() { Id = 1329909460023L, TenantId = 838257186181189L, FactoryId = 838257186320453L, RuleCode = Rule01, // 投影列刻意写成**错误值**:运行语义若真的来自代码定义,这些值就不该产生任何影响。 RuleType = "OUT_OF_RANGE", DatasetCode = "SOMETHING_ELSE", SourceObjectType = "EVIL_OBJECT_TYPE", WatchObjectType = "EVIL_WATCH_TYPE", SceneCode = "S1", Severity = "FOLLOW", PollIntervalSeconds = 300, TriggerCountRequired = 1, RecoverCountRequired = 2, ParamsJson = paramsJson }; /// /// 一行已超期的采购订单行。 /// 刻意同时带上恶意 JSON 指向的四个列,且让它们指向**完全不同**的值: /// 若判定真的被 JSON 牵着走,结果会明显不同而不是碰巧一致。 /// private static S8MonitoringRowSet OverdueRow(string status = "DELAYED") => S8MonitoringRowSet.FromRows(new[] { S8MonitoringRow.FromValues(new Dictionary { [S8CanonicalColumns.SourceObjectId] = "UATA-PO-001#1", [S8CanonicalColumns.RelatedObjectCode] = "UATA-PO-001", [S8CanonicalColumns.DueAt] = new DateTime(2026, 8, 31, 4, 33, 7), [S8CanonicalColumns.Status] = status, ["evil_field"] = new DateTime(2030, 1, 1), // 未来时间:若被当作 due_at 就不会命中 ["evil_status"] = "WHATEVER", ["evil_id"] = "EVIL-ID", ["evil_code"] = "EVIL-CODE" }) }); // ───────────────────────── T1:Rule 01 定义 ───────────────────────── [Fact] public void T1_Catalog_Defines_Rule01_WithPinnedContract() { var d = Catalog().GetRequired(Rule01); Assert.Equal(S8BusinessDatasetDefinitions.PurchaseDeliveryCode, d.DatasetCode); Assert.Equal(S8TimeoutRuleEvaluator.RuleTypeCode, d.RuleType); Assert.Equal("DATE", d.RuleMechanism); Assert.Equal(S8PurchaseDeliveryRuleDefinitions.PurchaseOrderLineObjectType, d.SourceObjectType); Assert.Equal(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryExceptionTypeCode, d.ExceptionTypeCode); Assert.Equal("S4", d.SceneCode); Assert.Equal("S4", d.StageCode); Assert.Equal("MATERIAL_PURCHASE", d.OrderFlowCode); // 配置页的「规则名称 / 业务说明」在 DB 里没有对应列,只能来自定义。 Assert.False(string.IsNullOrWhiteSpace(d.DisplayName)); Assert.False(string.IsNullOrWhiteSpace(d.Description)); } [Fact] public void T1_Rule01_CompletedStates_ArePinned() { var timeout = Catalog().GetRequired(Rule01).Timeout; Assert.NotNull(timeout); // 三值取自 status 域取证(三个写入方合并)。少一个就会把已完成单判成延误。 Assert.Equal(new[] { "COMPLETED", "CLOSED", "CANCELLED" }, timeout!.CompletedStates); } [Fact] public void T1_Rule01_JudgementColumns_AreCanonical() { var t = Catalog().GetRequired(Rule01).Timeout!; Assert.Equal(S8CanonicalColumns.DueAt, t.DueAtColumn); Assert.Equal(S8CanonicalColumns.Status, t.StatusColumn); Assert.Equal(S8CanonicalColumns.SourceObjectId, t.SourceObjectIdColumn); Assert.Equal(S8CanonicalColumns.RelatedObjectCode, t.RelatedObjectCodeColumn); } // ───────────────────────── T2:重复定义 ───────────────────────── [Fact] public void T2_DuplicateRuleCode_FailsCatalogConstruction() { var ex = Assert.Throws(() => new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions(), new S8PurchaseDeliveryRuleDefinitions() })); Assert.Contains(Rule01, ex.Message); } [Theory] [InlineData("NOT_A_TYPE", "rule_type")] [InlineData("timeout", "rule_type")] // 大小写:调度器 switch 是 ordinal 敏感的 public void T2_InvalidRuleType_FailsCatalogConstruction(string ruleType, string _) { var ex = Assert.Throws(() => new S8RuleCatalog(new IS8RuleDefinitionSource[] { new BadSource { RuleType = ruleType } })); Assert.Contains("rule_type", ex.Message); } [Fact] public void T2_TimeoutWithoutSemantics_FailsCatalogConstruction() { var ex = Assert.Throws(() => new S8RuleCatalog(new IS8RuleDefinitionSource[] { new BadSource { DropTimeout = true } })); Assert.Contains("TIMEOUT 判定语义", ex.Message); } [Fact] public void T2_InvalidScene_FailsCatalogConstruction() { var ex = Assert.Throws(() => new S8RuleCatalog(new IS8RuleDefinitionSource[] { new BadSource { SceneCode = "S2S6_PRODUCTION" } })); Assert.Contains("scene_code", ex.Message); } // ───────────────────────── T3:无定义规则 ───────────────────────── [Fact] public void T3_UnknownRuleCode_IsRejectedWithDedicatedReason() { var catalog = Catalog(); Assert.Null(catalog.TryGet("RULE_THAT_A_USER_INVENTED")); Assert.False(catalog.IsDefined("RULE_THAT_A_USER_INVENTED")); var ex = Assert.Throws(() => catalog.GetRequired("RULE_THAT_A_USER_INVENTED")); // 必须是专属 reason,不能是 rule_not_configured / UnsupportedRule / NullReference。 Assert.Contains(S8RuleCatalog.ReasonNotFound, ex.Message); Assert.DoesNotContain("rule_not_configured", ex.Message); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public void T3_BlankRuleCode_IsRejected(string? ruleCode) { Assert.Throws(() => Catalog().GetRequired(ruleCode)); } // ───────────────────────── T4:params_json = NULL ───────────────────────── [Fact] public void T4_NullParamsJson_StillProducesValidRuntimeParameters() { // 这正是本地 Rule 01 当前的真实状态:一次 PUT /params {"enabled":false} 把它抹成了 NULL。 var effective = S8EffectiveRule.Resolve(Rule(paramsJson: null), Catalog().GetRequired(Rule01)); Assert.Equal(0, effective.Parameters.GraceMinutes); // 回落定义默认值 Assert.Equal(300, effective.Parameters.PollIntervalSeconds); // 来自实体列 Assert.Equal(2, effective.Parameters.RecoverCountRequired); Assert.Null(effective.Parameters.DefaultOccurrenceDeptId); } [Fact] public void T4_NullParamsJson_StillHits() { var effective = S8EffectiveRule.Resolve(Rule(paramsJson: null), Catalog().GetRequired(Rule01)); var hits = S8TimeoutRuleEvaluator.EvaluateRows( OverdueRow(), effective, 838257186181189L, new DateTime(2026, 9, 6, 13, 4, 13)); var hit = Assert.Single(hits); Assert.Equal("UATA-PO-001#1", hit.SourceObjectId); } [Theory] [InlineData(null)] [InlineData("")] [InlineData("not-a-json")] // 历史脏数据不该把规则打死 [InlineData("[]")] // 合法 JSON 但不是对象 public void T4_BrokenParamsJson_FallsBackToDefaults_WithoutThrowing(string? json) { var p = S8RuleRuntimeParameters.Resolve(Rule(json), Catalog().GetRequired(Rule01)); Assert.Equal(0, p.GraceMinutes); } // ───────────────────────── T5:定义权威(本文件的核心) ───────────────────────── [Fact] public void T5_HostileLegacyParamsJson_CannotRedefineJudgement() { var definition = Catalog().GetRequired(Rule01); var clean = S8EffectiveRule.Resolve(Rule(paramsJson: null), definition); var hostile = S8EffectiveRule.Resolve(Rule(HostileLegacyParamsJson), definition); var detectedAt = new DateTime(2026, 9, 6, 13, 4, 13); var cleanHits = S8TimeoutRuleEvaluator.EvaluateRows(OverdueRow(), clean, 1, detectedAt); var hostileHits = S8TimeoutRuleEvaluator.EvaluateRows(OverdueRow(), hostile, 1, detectedAt); // 恶意 JSON 把 completedStates 写成 ["DELAYED"] —— 若它还有话语权,这一行就不该命中。 Assert.Single(cleanHits); Assert.Single(hostileHits); Assert.Equal(cleanHits[0].SourceObjectId, hostileHits[0].SourceObjectId); Assert.Equal(cleanHits[0].DedupKey, hostileHits[0].DedupKey); Assert.Equal(cleanHits[0].ExceptionTypeCode, hostileHits[0].ExceptionTypeCode); Assert.Equal(cleanHits[0].SourceObjectType, hostileHits[0].SourceObjectType); Assert.Equal(cleanHits[0].SceneCode, hostileHits[0].SceneCode); } [Fact] public void T5_JudgementIdentity_ComesFromDefinition_NotDbColumnsNorJson() { var effective = S8EffectiveRule.Resolve(Rule(HostileLegacyParamsJson), Catalog().GetRequired(Rule01)); var hit = Assert.Single(S8TimeoutRuleEvaluator.EvaluateRows( OverdueRow(), effective, 838257186181189L, new DateTime(2026, 9, 6, 13, 4, 13))); // 身份三件套全部来自定义:JSON 写的 EVIL-ID / EVIL_TYPE、DB 列写的 EVIL_OBJECT_TYPE 都不生效。 Assert.Equal("UATA-PO-001#1", hit.SourceObjectId); Assert.Equal("UATA-PO-001", hit.RelatedObjectCode); Assert.Equal(S8PurchaseDeliveryRuleDefinitions.PurchaseOrderLineObjectType, hit.SourceObjectType); Assert.Equal(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryExceptionTypeCode, hit.ExceptionTypeCode); Assert.Equal("S4", hit.SceneCode); Assert.Equal(Rule01, hit.SourceRuleCode); Assert.DoesNotContain("EVIL", hit.SourceObjectId); Assert.DoesNotContain("EVIL", hit.DedupKey); Assert.DoesNotContain("EVIL", hit.SourceObjectType); } [Fact] public void T5_CompletedState_StillSkips_WhenDefinitionSaysSo() { // 反向对照:定义确实在起作用,而不是"恒命中所以看起来一样"。 var rowSet = OverdueRow(status: "COMPLETED"); var effective = S8EffectiveRule.Resolve(Rule(HostileLegacyParamsJson), Catalog().GetRequired(Rule01)); Assert.Empty(S8TimeoutRuleEvaluator.EvaluateRows( rowSet, effective, 1, new DateTime(2026, 9, 6, 13, 4, 13))); } [Fact] public void T5_LegacyGraceMinutes_IsStillHonoured_BecauseItIsARuntimeParameter() { // B 类字段必须**继续**生效 —— 否则就是把参数一起锁死了,那是矫枉过正。 var p = S8RuleRuntimeParameters.Resolve(Rule("""{"graceMinutes": 4321}"""), Catalog().GetRequired(Rule01)); Assert.Equal(4321, p.GraceMinutes); } [Fact] public void T5_LegacyDepartmentDefaults_AreStillHonoured() { var p = S8RuleRuntimeParameters.Resolve( Rule("""{"defaultOccurrenceDeptId": 245, "defaultResponsibleDeptId": 246}"""), Catalog().GetRequired(Rule01)); Assert.Equal(245L, p.DefaultOccurrenceDeptId); Assert.Equal(246L, p.DefaultResponsibleDeptId); } // ───────────────────────── 参数序列化:A 类不回写 ───────────────────────── [Fact] public void ToParamsJson_WritesOnlyRuntimeKeys_DroppingLegacyDefinitionFields() { var p = S8RuleRuntimeParameters.Resolve(Rule(HostileLegacyParamsJson), Catalog().GetRequired(Rule01)); var json = p.ToParamsJson(); // 一次正常保存之后,旧 A 类字段自然消失——无需任何 migration。 foreach (var legacy in new[] { "dueAtField", "statusField", "objectIdField", "objectCodeField", "completedStates", "exceptionTypeCode" }) Assert.DoesNotContain(legacy, json, StringComparison.Ordinal); Assert.Contains("graceMinutes", json, StringComparison.Ordinal); } /// 用于构造"写坏了的定义",验证 Catalog 的 fail-fast。 private sealed class BadSource : IS8RuleDefinitionSource { public string RuleType { get; init; } = S8TimeoutRuleEvaluator.RuleTypeCode; public string SceneCode { get; init; } = "S4"; public bool DropTimeout { get; init; } public IEnumerable GetDefinitions() => new[] { new S8RuleDefinition { RuleCode = "RULE_BAD_FOR_TEST", DisplayName = "坏定义", Description = "仅用于校验 Catalog 的 fail-fast", DatasetCode = S8BusinessDatasetDefinitions.PurchaseDeliveryCode, RuleType = RuleType, RuleMechanism = "DATE", SourceObjectType = "X", SceneCode = SceneCode, StageCode = "S4", ExceptionTypeCode = "X", Timeout = DropTimeout ? null : new S8TimeoutSemantics() } }; } }