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()
}
};
}
}