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;
///
/// S8-RULE-GOVERNANCE-BATCH1:运行参数写入契约 + G1 回归。
///
/// G1 是什么(本地实测,非推演):旧的 PUT /{id}/params 是"整块覆盖 +
/// 缺字段即置 NULL"。本地 Rule 01 在 13:34:13 还在正常命中 28 条,
/// 13:40 一次 {"enabled":false} 之后 params_json 变成 NULL —— 规则的全部业务配置消失,
/// 而旧 evaluator 在 params_json 为空时抛 rule_not_configured,等于这条规则被一次关开关打死。
///
/// 本文件用**行为测试**钉住修复:参数叠加是纯函数 S8WatchRuleService.ApplyParameters,
/// 可以不接数据库逐字段断言"未提供 = 不变"。结构性约束(端点分离、Definition 不可写)
/// 才用反射守卫。
///
public class S8RuleParameterContractTests
{
private static S8RuleDefinition Rule01() =>
new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() })
.GetRequired(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode);
/// 一条参数**齐全**的规则行,模拟 G1 发生前的真实状态。
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(() =>
Apply(new S8RuleParametersPayload { PollIntervalSeconds = value }));
Assert.Contains("轮询间隔", ex.Message);
}
[Theory]
[InlineData(0)]
[InlineData(11)]
public void T6_OutOfRangeAntiflapCounts_AreRejected(int value)
{
Assert.Throws(() => Apply(new S8RuleParametersPayload { TriggerCountRequired = value }));
Assert.Throws(() => Apply(new S8RuleParametersPayload { RecoverCountRequired = value }));
}
[Fact]
public void T6_NegativeGraceMinutes_IsRejected()
{
Assert.Throws(() => Apply(new S8RuleParametersPayload { GraceMinutes = -1 }));
}
///
/// severity 越权值必须**报错**,不能像 S8SeverityCode.Normalize 那样静默降级成 FOLLOW。
/// 库里 severity='HIGH' 的那一行当年正是被静默降级放进来的。
///
[Theory]
[InlineData("HIGH")]
[InlineData("CRITICAL")]
[InlineData("bogus")]
public void T6_NonWhitelistedSeverity_IsRejected(string severity)
{
var ex = Assert.Throws(() => Apply(new S8RuleParametersPayload { Severity = severity }));
Assert.Contains(severity, ex.Message);
}
// ───────────────────────── T7:G1 —— 启停不得破坏参数 ─────────────────────────
///
/// G1 的结构性修复:enabled 已经**不在**参数载荷里,因此"关开关顺手清空配置"
/// 这条路径在类型层面就不存在了。
///
[Fact]
public void T7_ParametersPayload_HasNoEnabledWriteCapability()
{
var payload = new S8RuleParametersPayload { Enabled = false };
// Enabled 属性仍然存在,但它是**毒丸**:出现即拒绝,绝不静默忽略。
Assert.Contains("enabled", payload.RejectedDefinitionFields);
var ex = Assert.Throws(() => Apply(payload));
Assert.Contains("/enable", ex.Message);
}
///
/// G1 正面回归:旧写法 {"enabled":false} 现在既不会成功、更不会清空 params_json。
/// 与之对照,一次合法的参数更新必须**完整保留**其余全部参数。
///
[Fact]
public void T7_LegacyDisablePayload_CannotWipeParamsJson()
{
var rule = FullyConfiguredRule();
var before = S8RuleRuntimeParameters.Resolve(rule, Rule01());
Assert.Throws(() =>
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()).Template);
Assert.Equal("{id:long}/disable", Assert.Single(disable.GetCustomAttributes()).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(() => 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);
}
///
/// 白名单是**闭集**:新增可写属性必须是有意识的决定。
/// 若有人往载荷上加一个新的可写字段却忘了想清楚它是 A 类还是 B 类,本用例会失败。
///
[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 Actions(Type controller, string name) =>
controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(m => m.Name == name);
}