| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339 |
- 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;
- /// <summary>
- /// S8-RULE-GOVERNANCE-BATCH1:规则定义的**运行时权威性**契约。
- ///
- /// <para><b>本文件要证明的不是"我们加了几个 Definition 类"</b>,而是一件具体的事:
- /// 规则的业务语义已经不再由数据库和 params_json 决定。
- /// 判据是 T5 —— 把库里的 params_json 写成一份恶意的旧格式 JSON
- /// (换判定字段、换已完成状态、换去重身份、换异常类型),判定结果必须**逐字不变**。</para>
- ///
- /// <para>不接 DB、不接 DI:Catalog 只依赖定义源,判定核心是 internal static。</para>
- /// </summary>
- public class S8RuleDefinitionContractTests
- {
- private const string Rule01 = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode;
- private static IS8RuleCatalog Catalog() =>
- new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() });
- /// <summary>一条**语义全错**的历史 params_json:每个 A 类字段都被写成足以改变判定结果的值。</summary>
- 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
- };
- /// <summary>
- /// 一行已超期的采购订单行。
- /// 刻意同时带上恶意 JSON 指向的四个列,且让它们指向**完全不同**的值:
- /// 若判定真的被 JSON 牵着走,结果会明显不同而不是碰巧一致。
- /// </summary>
- private static S8MonitoringRowSet OverdueRow(string status = "DELAYED") =>
- S8MonitoringRowSet.FromRows(new[]
- {
- S8MonitoringRow.FromValues(new Dictionary<string, object?>
- {
- [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<InvalidOperationException>(() =>
- 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<InvalidOperationException>(() =>
- new S8RuleCatalog(new IS8RuleDefinitionSource[] { new BadSource { RuleType = ruleType } }));
- Assert.Contains("rule_type", ex.Message);
- }
- [Fact]
- public void T2_TimeoutWithoutSemantics_FailsCatalogConstruction()
- {
- var ex = Assert.Throws<InvalidOperationException>(() =>
- new S8RuleCatalog(new IS8RuleDefinitionSource[] { new BadSource { DropTimeout = true } }));
- Assert.Contains("TIMEOUT 判定语义", ex.Message);
- }
- [Fact]
- public void T2_InvalidScene_FailsCatalogConstruction()
- {
- var ex = Assert.Throws<InvalidOperationException>(() =>
- 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<S8BizException>(() => 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<S8BizException>(() => 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, 838257186320453L, 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, 1, detectedAt);
- var hostileHits = S8TimeoutRuleEvaluator.EvaluateRows(OverdueRow(), hostile, 1, 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, 838257186320453L, 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, 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);
- }
- /// <summary>用于构造"写坏了的定义",验证 Catalog 的 fail-fast。</summary>
- 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<S8RuleDefinition> 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()
- }
- };
- }
- }
|