| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316 |
- 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;
- 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.Definitions;
- using Xunit;
- namespace Admin.NET.Plugin.AiDOP.Tests.S8;
- /// <summary>
- /// S8-RULE-GOVERNANCE-BATCH1:运行参数写入契约 + <b>G1 回归</b>。
- ///
- /// <para><b>G1 是什么</b>(本地实测,非推演):旧的 <c>PUT /{id}/params</c> 是"整块覆盖 +
- /// 缺字段即置 NULL"。本地 Rule 01 在 13:34:13 还在正常命中 28 条,
- /// 13:40 一次 <c>{"enabled":false}</c> 之后 <c>params_json</c> 变成 NULL —— 规则的全部业务配置消失,
- /// 而旧 evaluator 在 params_json 为空时抛 <c>rule_not_configured</c>,等于这条规则被一次关开关打死。</para>
- ///
- /// <para>本文件用**行为测试**钉住修复:参数叠加是纯函数 <c>S8WatchRuleService.ApplyParameters</c>,
- /// 可以不接数据库逐字段断言"未提供 = 不变"。结构性约束(端点分离、Definition 不可写)
- /// 才用反射守卫。</para>
- /// </summary>
- public class S8RuleParameterContractTests
- {
- private static S8RuleDefinition Rule01() =>
- new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() })
- .GetRequired(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode);
- /// <summary>一条参数**齐全**的规则行,模拟 G1 发生前的真实状态。</summary>
- private static AdoS8WatchRule FullyConfiguredRule() => new()
- {
- Id = 1329909460023L,
- TenantId = 838257186181189L,
- FactoryId = 838257186320453L,
- RuleCode = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode,
- RuleType = "TIMEOUT",
- DatasetCode = "PURCHASE_DELIVERY",
- SourceObjectType = "PURCHASE_ORDER_LINE",
- SceneCode = "S4",
- Severity = S8SeverityCode.Serious,
- PollIntervalSeconds = 600,
- TriggerCountRequired = 3,
- RecoverCountRequired = 2,
- Enabled = true,
- ParamsJson = """{"graceMinutes":45,"defaultOccurrenceDeptId":245,"defaultResponsibleDeptId":246}"""
- };
- private static S8RuleRuntimeParameters Apply(S8RuleParametersPayload payload) =>
- S8WatchRuleService.ApplyParameters(FullyConfiguredRule(), payload, Rule01());
- // ───────────────────────── T6:参数白名单 · 单字段隔离 ─────────────────────────
- [Fact]
- public void T6_ChangingGraceMinutes_LeavesEveryOtherParameterUntouched()
- {
- var next = Apply(new S8RuleParametersPayload { GraceMinutes = 15 });
- Assert.Equal(15, next.GraceMinutes); // 改了的
- Assert.Equal(600, next.PollIntervalSeconds); // 没改的,逐个确认
- Assert.Equal(3, next.TriggerCountRequired);
- Assert.Equal(2, next.RecoverCountRequired);
- Assert.Equal(S8SeverityCode.Serious, next.Severity);
- Assert.Equal(245L, next.DefaultOccurrenceDeptId);
- Assert.Equal(246L, next.DefaultResponsibleDeptId);
- }
- [Fact]
- public void T6_EmptyPayload_ChangesNothing()
- {
- var before = S8RuleRuntimeParameters.Resolve(FullyConfiguredRule(), Rule01());
- var after = Apply(new S8RuleParametersPayload());
- Assert.Equal(before.PollIntervalSeconds, after.PollIntervalSeconds);
- Assert.Equal(before.TriggerCountRequired, after.TriggerCountRequired);
- Assert.Equal(before.RecoverCountRequired, after.RecoverCountRequired);
- Assert.Equal(before.GraceMinutes, after.GraceMinutes);
- Assert.Equal(before.Severity, after.Severity);
- Assert.Equal(before.DefaultOccurrenceDeptId, after.DefaultOccurrenceDeptId);
- Assert.Equal(before.DefaultResponsibleDeptId, after.DefaultResponsibleDeptId);
- }
- [Fact]
- public void T6_ChangingSeverity_LeavesGraceAndScheduleUntouched()
- {
- var next = Apply(new S8RuleParametersPayload { Severity = S8SeverityCode.Follow });
- Assert.Equal(S8SeverityCode.Follow, next.Severity);
- Assert.Equal(45, next.GraceMinutes);
- Assert.Equal(600, next.PollIntervalSeconds);
- }
- [Theory]
- [InlineData(59)] // < min
- [InlineData(86401)] // > max
- public void T6_OutOfRangePollInterval_IsRejected(int value)
- {
- var ex = Assert.Throws<S8BizException>(() =>
- Apply(new S8RuleParametersPayload { PollIntervalSeconds = value }));
- Assert.Contains("轮询间隔", ex.Message);
- }
- [Theory]
- [InlineData(0)]
- [InlineData(11)]
- public void T6_OutOfRangeAntiflapCounts_AreRejected(int value)
- {
- Assert.Throws<S8BizException>(() => Apply(new S8RuleParametersPayload { TriggerCountRequired = value }));
- Assert.Throws<S8BizException>(() => Apply(new S8RuleParametersPayload { RecoverCountRequired = value }));
- }
- [Fact]
- public void T6_NegativeGraceMinutes_IsRejected()
- {
- Assert.Throws<S8BizException>(() => Apply(new S8RuleParametersPayload { GraceMinutes = -1 }));
- }
- /// <summary>
- /// severity 越权值必须**报错**,不能像 <c>S8SeverityCode.Normalize</c> 那样静默降级成 FOLLOW。
- /// 库里 severity='HIGH' 的那一行当年正是被静默降级放进来的。
- /// </summary>
- [Theory]
- [InlineData("HIGH")]
- [InlineData("CRITICAL")]
- [InlineData("bogus")]
- public void T6_NonWhitelistedSeverity_IsRejected(string severity)
- {
- var ex = Assert.Throws<S8BizException>(() => Apply(new S8RuleParametersPayload { Severity = severity }));
- Assert.Contains(severity, ex.Message);
- }
- // ───────────────────────── T7:G1 —— 启停不得破坏参数 ─────────────────────────
- /// <summary>
- /// G1 的结构性修复:<c>enabled</c> 已经**不在**参数载荷里,因此"关开关顺手清空配置"
- /// 这条路径在类型层面就不存在了。
- /// </summary>
- [Fact]
- public void T7_ParametersPayload_HasNoEnabledWriteCapability()
- {
- var payload = new S8RuleParametersPayload { Enabled = false };
- // Enabled 属性仍然存在,但它是**毒丸**:出现即拒绝,绝不静默忽略。
- Assert.Contains("enabled", payload.RejectedDefinitionFields);
- var ex = Assert.Throws<S8BizException>(() => Apply(payload));
- Assert.Contains("/enable", ex.Message);
- }
- /// <summary>
- /// G1 正面回归:旧写法 <c>{"enabled":false}</c> 现在既不会成功、更不会清空 params_json。
- /// 与之对照,一次合法的参数更新必须**完整保留**其余全部参数。
- /// </summary>
- [Fact]
- public void T7_LegacyDisablePayload_CannotWipeParamsJson()
- {
- var rule = FullyConfiguredRule();
- var before = S8RuleRuntimeParameters.Resolve(rule, Rule01());
- Assert.Throws<S8BizException>(() =>
- S8WatchRuleService.ApplyParameters(rule, new S8RuleParametersPayload { Enabled = false }, Rule01()));
- // 拒绝路径不得产生任何副作用:实体上的 params_json 逐字未变。
- Assert.Equal(
- """{"graceMinutes":45,"defaultOccurrenceDeptId":245,"defaultResponsibleDeptId":246}""",
- rule.ParamsJson);
- // 且合法路径写回的 params_json 语义等价(键顺序可不同,值必须一致)。
- var after = S8WatchRuleService.ApplyParameters(rule, new S8RuleParametersPayload(), Rule01());
- Assert.Equal(before.GraceMinutes, after.GraceMinutes);
- Assert.Equal(before.DefaultOccurrenceDeptId, after.DefaultOccurrenceDeptId);
- Assert.Equal(before.DefaultResponsibleDeptId, after.DefaultResponsibleDeptId);
- }
- // ───────────────────────── T8 / T9:启停端点独立且幂等 ─────────────────────────
- [Fact]
- public void T8_EnableAndDisable_AreDedicatedEndpoints_SeparateFromParams()
- {
- var controller = typeof(AdoS8ConfigWatchRulesController);
- var enable = Assert.Single(Actions(controller, "EnableAsync"));
- var disable = Assert.Single(Actions(controller, "DisableAsync"));
- Assert.Equal("{id:long}/enable", Assert.Single(enable.GetCustomAttributes<HttpPostAttribute>()).Template);
- Assert.Equal("{id:long}/disable", Assert.Single(disable.GetCustomAttributes<HttpPostAttribute>()).Template);
- // 两者都不接 body:启停没有任何可配置项,接 body 就会重新打开"顺手改点别的"的口子。
- Assert.Empty(enable.GetParameters().Where(p => p.ParameterType != typeof(long)));
- Assert.Empty(disable.GetParameters().Where(p => p.ParameterType != typeof(long)));
- foreach (var action in new[] { enable, disable })
- Assert.NotEmpty(action.GetCustomAttributes()
- .Where(a => a.GetType().Name.Contains("S8Permission", StringComparison.Ordinal)));
- }
- [Fact]
- public void T8_ParamsEndpoint_TakesOnlyTheNarrowParametersPayload()
- {
- var action = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), "UpdateParamsAsync"));
- var body = Assert.Single(action.GetParameters().Where(p => p.ParameterType != typeof(long)));
- Assert.Equal(typeof(S8RuleParametersPayload), body.ParameterType);
- }
- [Fact]
- public void T9_ServiceExposes_EnableAndDisable_WithTrustedScope()
- {
- var t = typeof(S8WatchRuleService);
- Assert.NotNull(t.GetMethod("EnableAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
- Assert.NotNull(t.GetMethod("DisableAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
- // 旧的整块参数入口必须消失,否则 G1 的路径还在。
- Assert.Null(t.GetMethods().FirstOrDefault(m => m.Name == "UpdateParamsAsync"));
- Assert.NotNull(t.GetMethod("UpdateParametersAsync",
- new[] { typeof(long), typeof(S8RuleParametersPayload), typeof(S8TrustedScope) }));
- }
- // ───────────────────────── T10:Definition 字段一律不可写 ─────────────────────────
- [Theory]
- [InlineData("datasetCode")]
- [InlineData("ruleType")]
- [InlineData("sourceObjectType")]
- [InlineData("exceptionTypeCode")]
- [InlineData("dueAtField")]
- [InlineData("statusField")]
- [InlineData("objectIdField")]
- [InlineData("objectCodeField")]
- [InlineData("completedStates")]
- [InlineData("sceneCode")]
- [InlineData("stageCode")]
- [InlineData("orderFlowCode")]
- [InlineData("ruleMechanism")]
- [InlineData("paramsJson")]
- public void T10_DefinitionFields_AreRejected_NotSilentlyIgnored(string field)
- {
- var payload = new S8RuleParametersPayload();
- switch (field)
- {
- case "datasetCode": payload.DatasetCode = "X"; break;
- case "ruleType": payload.RuleType = "X"; break;
- case "sourceObjectType": payload.SourceObjectType = "X"; break;
- case "exceptionTypeCode": payload.ExceptionTypeCode = "X"; break;
- case "dueAtField": payload.DueAtField = "X"; break;
- case "statusField": payload.StatusField = "X"; break;
- case "objectIdField": payload.ObjectIdField = "X"; break;
- case "objectCodeField": payload.ObjectCodeField = "X"; break;
- case "completedStates": payload.CompletedStates = new[] { "X" }; break;
- case "sceneCode": payload.SceneCode = "X"; break;
- case "stageCode": payload.StageCode = "X"; break;
- case "orderFlowCode": payload.OrderFlowCode = "X"; break;
- case "ruleMechanism": payload.RuleMechanism = "X"; break;
- case "paramsJson": payload.ParamsJson = "{}"; break;
- }
- Assert.Contains(field, payload.RejectedDefinitionFields);
- var ex = Assert.Throws<S8BizException>(() => Apply(payload));
- Assert.Contains(field, ex.Message);
- }
- [Fact]
- public void T10_CleanPayload_ReportsNoRejectedFields()
- {
- // 反向对照:拒绝逻辑不是"全部拒绝"。
- var payload = new S8RuleParametersPayload
- {
- PollIntervalSeconds = 300,
- TriggerCountRequired = 1,
- RecoverCountRequired = 2,
- GraceMinutes = 0,
- Severity = S8SeverityCode.Serious,
- DefaultOccurrenceDeptId = 1,
- DefaultResponsibleDeptId = 2
- };
- Assert.Empty(payload.RejectedDefinitionFields);
- var next = Apply(payload);
- Assert.Equal(300, next.PollIntervalSeconds);
- Assert.Equal(0, next.GraceMinutes);
- }
- /// <summary>
- /// 白名单是**闭集**:新增可写属性必须是有意识的决定。
- /// 若有人往载荷上加一个新的可写字段却忘了想清楚它是 A 类还是 B 类,本用例会失败。
- /// </summary>
- [Fact]
- public void T10_PayloadShape_IsPinned()
- {
- var writable = typeof(S8RuleParametersPayload)
- .GetProperties(BindingFlags.Public | BindingFlags.Instance)
- .Where(p => p.CanWrite)
- .Select(p => p.Name)
- .OrderBy(n => n, StringComparer.Ordinal)
- .ToArray();
- var expected = new[]
- {
- // 白名单(B 类)
- "DefaultOccurrenceDeptId", "DefaultResponsibleDeptId", "GraceMinutes",
- "PollIntervalSeconds", "RecoverCountRequired", "Severity", "TriggerCountRequired",
- // 毒丸(A 类 + 已迁走的 enabled):存在只为显式报错
- "CompletedStates", "DatasetCode", "DueAtField", "Enabled", "ExceptionTypeCode",
- "ObjectCodeField", "ObjectIdField", "OrderFlowCode", "ParamsJson", "RuleMechanism",
- "RuleType", "SceneCode", "SourceObjectType", "StageCode", "StatusField"
- }.OrderBy(n => n, StringComparer.Ordinal).ToArray();
- Assert.Equal(expected, writable);
- }
- private static IEnumerable<MethodInfo> Actions(Type controller, string name) =>
- controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
- .Where(m => m.Name == name);
- }
|