using System.Reflection; using System.Text.RegularExpressions; using System.Runtime.CompilerServices; 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.Service.S8; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-RULE-GOVERNANCE-BATCH3:业务侧根本不能创建 / 删除监控规则。 /// /// 本文件取代了原 S8DatasetOnlyRuleCreationTests。那个名字守护的是 /// 「创建规则时只能选治理数据集」—— 一条已经过时的口径:现在不存在"创建规则"这件事, /// 继续用那个名字会让读者以为 Rule Builder 还在,只是被限制了选项。 /// /// 最终模型: /// /// 业务需求 → 研发实现 S8RuleDefinition → S8RuleCatalog → Release /// → S8RuleProvisioningService → 每租户一条 Runtime Policy(默认停用) /// /// 用户能做的只有:调整已发布规则的运行参数、启停、预演、立即执行、暂停恢复。 /// /// 原文件中仍然有效的断言(Legacy 类型不存在 / 数据集端点只读 / Rule 01 形态可表达) /// 已逐条迁入本文件,未丢失覆盖。 /// /// 不接 DB、不接 DI:退役守卫在**任何 DB 访问之前**抛出,因此用 /// 绕开构造函数即可驱动。 /// 若将来有人把守卫挪到 DB 访问之后,本测试会因 NullReferenceException 失败 —— /// 这正是我们要的信号:守卫必须前置 /// public class S8RuleCreationRetiredTests { private static readonly Assembly PluginAssembly = typeof(AdoS8WatchRule).Assembly; private static S8WatchRuleService Svc() => (S8WatchRuleService)RuntimeHelpers.GetUninitializedObject(typeof(S8WatchRuleService)); private static readonly S8TrustedScope Scope = new(838257186181189L); private static AdoS8WatchRule Body() => new() { RuleCode = "RULE_A_USER_INVENTED", SceneCode = "S1", RuleType = "TIMEOUT", DatasetCode = "PURCHASE_DELIVERY", WatchObjectType = "ORDER", PollIntervalSeconds = 300 }; // ───────────────────────── T1:创建能力 ───────────────────────── [Fact] public void T1_CreateAsync_IsRetired_WithGuidanceMessage() { var ex = Assert.Throws(() => { _ = Svc().CreateAsync(Body(), Scope); }); Assert.Equal(S8WriteRetiredException.WatchRuleCreateMessage, ex.Message); // 文案必须说清"新规则从哪来",否则用户只会以为接口坏了。 Assert.Contains("系统版本定义", ex.Message); Assert.DoesNotContain("unsupported", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("unknown", ex.Message, StringComparison.OrdinalIgnoreCase); // 与"整实体更新退役"是不同的事,文案不得混用。 Assert.NotEqual(S8WriteRetiredException.WatchRuleUpdateMessage, ex.Message); } /// 入参是否"合法"与能力是否存在无关:任何 body 都必须 410,且零 DB 访问。 [Fact] public void T1_CreateAsync_IsRetired_RegardlessOfPayload() { Assert.Throws(() => { _ = Svc().CreateAsync(new AdoS8WatchRule(), Scope); }); var wellFormed = Body(); wellFormed.RuleCode = "RULE_S4_PURCHASE_DELIVERY_DATE_DELAY"; // 连"合法"的编码也不行 Assert.Throws(() => { _ = Svc().CreateAsync(wellFormed, Scope); }); } [Fact] public void T1_CreateEndpoint_Returns410_AndTouchesNoScope() { var action = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), "CreateAsync")); // 墓碑而非删 route:直接删会让遗留调用方拿到 405(该路径上还有 GET), // 而 405 说的是"方法不对",会让人以为换个动词就能建。 Assert.Single(action.GetCustomAttributes()); Assert.NotEmpty(action.GetCustomAttributes()); // 同步返回:能力已退役,不该为构造一个用不到的 scope 去 await 组织库查询。 Assert.Equal(typeof(IActionResult), action.ReturnType); } // ───────────────────────── T2:删除能力 ───────────────────────── [Fact] public void T2_DeleteAsync_IsRetired_AndPointsToDisable() { var ex = Assert.Throws(() => { _ = Svc().DeleteAsync(1329909460023L, Scope); }); Assert.Equal(S8WriteRetiredException.WatchRuleDeleteMessage, ex.Message); Assert.Contains("停用", ex.Message); // 必须给出正确的替代动作 } [Theory] [InlineData(0L)] [InlineData(-1L)] [InlineData(long.MaxValue)] public void T2_DeleteAsync_IsRetired_RegardlessOfId(long id) { // 任意 id(含越权 id)一律 410 而非 404: // 「这个能力没了」优先于「这条记录不属于你」,避免越权探测反推他租户数据是否存在。 Assert.Throws(() => { _ = Svc().DeleteAsync(id, Scope); }); } [Fact] public void T2_DeleteEndpoint_Returns410() { var action = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), "DeleteAsync")); Assert.Single(action.GetCustomAttributes()); Assert.NotEmpty(action.GetCustomAttributes()); Assert.Equal(typeof(IActionResult), action.ReturnType); } // ───────────────────────── T3 / T4 / T14 / T15:Wizard 全链消失 ───────────────────────── /// /// 向导后端整体退役。它的唯一出口是"生成规则",而创建规则已经不存在, /// 草稿也就失去了全部意义 —— 不为"以后可能用"保留死代码。 /// [Theory] [InlineData("Admin.NET.Plugin.AiDOP.Controllers.S8.AdoS8ConfigDraftsController")] [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.S8ConfigDraftService")] [InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftCreateDto")] [InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftUpdateDto")] [InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftGenerateRuleDto")] [InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftDetailDto")] public void T3_T4_WizardBackendType_NoLongerExists(string fullName) { Assert.Null(PluginAssembly.GetType(fullName, throwOnError: false)); } /// T14:整个程序集里不得再有任何 wizard-drafts 路由。 [Fact] public void T14_NoWizardDraftRouteRemains() { var offenders = PluginAssembly.GetTypes() .Where(t => typeof(ControllerBase).IsAssignableFrom(t)) .SelectMany(t => t.GetCustomAttributes().Select(r => r.Template ?? string.Empty)) .Where(t => t.Contains("wizard", StringComparison.OrdinalIgnoreCase) || t.Contains("draft", StringComparison.OrdinalIgnoreCase)) .ToArray(); Assert.Empty(offenders); } /// /// T15:实体与表按既有口径 KEEP_TEMPORARILY(先退役 API、后物理清理)。 /// /// Batch 4 修正:本用例原先断言「除实体自身外无任何类型消费它」, /// 那是基于 Batch 3 的一个错误结论 —— 当时审计认为草稿表只服务规则向导。 /// 实际上主动提报页把 mechanism = MANUAL_REPORT 的行当作提报模板在用, /// Batch 3 把整个 Controller 删掉后该功能静默失效。 /// /// 现在的口径是:只允许只读的模板服务消费它,且该服务不得有任何写能力。 /// 这比"零消费方"更准确,也仍然挡住"借草稿表复活建规则"。 /// [Fact] public void T15_ConfigDraftEntity_IsConsumedOnlyByReadOnlyTemplateService() { var entity = PluginAssembly.GetType("Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8ConfigDraft", throwOnError: false); Assert.NotNull(entity); // 表还在,等后续 schema cleanup var consumers = PluginAssembly.GetTypes() .Where(t => t != entity && !t.Name.Contains("Startup", StringComparison.Ordinal)) // 排除编译器生成的 async 状态机:它们把外层 this 与局部变量提升成字段, // 因此会连带"持有"仓储类型,但那不是一个独立的消费方。 .Where(t => !t.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false)) .Where(t => t.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) .Any(f => f.FieldType.IsGenericType && f.FieldType.GetGenericArguments().Contains(entity))) .Select(t => t.Name) .ToArray(); Assert.Equal(new[] { "S8ReportTemplateService" }, consumers); } /// 模板服务必须是纯读:出现任何写动词都意味着建规则那条路可能被绕回来。 [Fact] public void T15_ReportTemplateService_IsReadOnly() { var t = PluginAssembly.GetType("Admin.NET.Plugin.AiDOP.Service.S8.S8ReportTemplateService", throwOnError: true)!; var publicMethods = t.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .Select(m => m.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray(); Assert.Equal(new[] { "GetAsync", "ListAsync" }, publicMethods); foreach (var verb in new[] { "Create", "Update", "Delete", "Save", "Insert", "Generate", "Publish" }) Assert.DoesNotContain(publicMethods, n => n.StartsWith(verb, StringComparison.Ordinal)); } /// 模板端点只有 GET,且用只读权限码而非规则配置权限码。 [Fact] public void T15_ReportTemplateEndpoints_AreGetOnly() { var controller = PluginAssembly.GetType( "Admin.NET.Plugin.AiDOP.Controllers.S8.AdoS8ConfigReportTemplatesController", throwOnError: true)!; var actions = controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly).ToArray(); Assert.NotEmpty(actions); foreach (var m in actions) { Assert.NotEmpty(m.GetCustomAttributes()); Assert.Empty(m.GetCustomAttributes()); Assert.Empty(m.GetCustomAttributes()); Assert.Empty(m.GetCustomAttributes()); Assert.Empty(m.GetCustomAttributes()); } var route = Assert.Single(controller.GetCustomAttributes()).Template ?? string.Empty; Assert.DoesNotContain("draft", route, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("wizard", route, StringComparison.OrdinalIgnoreCase); } // ───────────────────────── T5 / T13:其余入口 ───────────────────────── /// T5:CreateAsync 不再是业务生产写路径 —— 名字还在,但恒抛退役异常。 [Fact] public void T5_CreateAsync_IsNotAProductionWritePath() { var m = typeof(S8WatchRuleService).GetMethod("CreateAsync", new[] { typeof(AdoS8WatchRule), typeof(S8TrustedScope) }); Assert.NotNull(m); // 保留方法名而非删掉:断言"恒抛退役异常"比断言"名字不存在"更能防住有人换个名字复活它。 Assert.Throws(() => { _ = Svc().CreateAsync(Body(), Scope); }); // 无作用域重载不得存在。 Assert.Null(typeof(S8WatchRuleService).GetMethod("CreateAsync", new[] { typeof(AdoS8WatchRule) })); } /// T13:TestAsync 已物理移除(能力被 GET /{id}/preview 完全覆盖)。 [Fact] public void T13_TestEndpointAndServiceMethod_AreGone() { Assert.Null(typeof(S8WatchRuleService).GetMethods().FirstOrDefault(m => m.Name == "TestAsync")); Assert.Empty(Actions(typeof(AdoS8ConfigWatchRulesController), "TestAsync")); } // ───────────────────────── T6:唯一合法运行时创建源 ───────────────────────── /// /// T6:Provisioning 是唯一合法的 runtime create source。 /// /// 注意这条守卫要表达的不是"任何 Insert 都非法" —— 那会误伤系统供给路径。 /// 要表达的是:用户/API 创建 = 禁止代码供给 = 允许。 /// 因此用源码扫描找出所有对 AdoS8WatchRule 的 Insert 语句, /// 断言它们只出现在 S8RuleProvisioningService 里。 /// [Fact] public void T6_OnlyProvisioningInsertsWatchRules() { var root = PluginSourceRoot(); var offenders = new List(); // 只认「声明为 SqlSugarRepository 的那个字段上的插入」。 // 粗放地扫 AsInsertable 会误伤同一文件里对别的实体的写入 —— // S8WatchSchedulerService 就同时持有 watch rule 仓储(只读)与 detection state 仓储(要写)。 var fieldDecl = new Regex( @"SqlSugarRepository\s+(_[A-Za-z0-9_]+)", RegexOptions.Compiled); foreach (var file in Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories)) { var name = Path.GetFileName(file); if (name == "S8RuleProvisioningService.cs") continue; // 唯一合法系统写路径 var code = ExecutableLines(File.ReadAllText(file)); var fields = fieldDecl.Matches(code).Select(m => m.Groups[1].Value).Distinct().ToArray(); foreach (var line in code.Split('\n')) { var trimmed = line.Trim(); // 形式一:显式泛型 Insertable if (trimmed.Contains("Insertable", StringComparison.Ordinal)) offenders.Add($"{name}: {trimmed}"); // 形式二:在 watch rule 仓储字段上调用 AsInsertable / InsertAsync foreach (var f in fields) { if (trimmed.Contains($"{f}.AsInsertable", StringComparison.Ordinal) || trimmed.Contains($"{f}.InsertAsync", StringComparison.Ordinal)) offenders.Add($"{name}: {trimmed}"); } } } Assert.True(offenders.Count == 0, "只有 S8RuleProvisioningService 允许创建监控规则运行策略行,以下位置出现了额外的写入:\n " + string.Join("\n ", offenders)); } /// 反向对照:守卫不是"恒为空"——供给服务里确实存在那唯一一处合法插入。 [Fact] public void T6_ProvisioningItselfDoesInsert() { var code = File.ReadAllText(Path.Combine(PluginSourceRoot(), "Service", "S8", "S8RuleProvisioningService.cs")); Assert.Contains("_rep.AsInsertable(row)", code); } // ───────────────────────── 迁自原文件:仍然有效的断言 ───────────────────────── /// Legacy 取数 / 数据源相关类型全部不存在(与 S8LegacySymbolsRemovedTests 互补)。 [Theory] [InlineData("Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8DataSource")] [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.S8DataSourceService")] [InlineData("Admin.NET.Plugin.AiDOP.Controllers.S8.AdoS8ConfigDataSourcesController")] [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8DataSourceRowLoader")] [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8SqlSugarScopeFactory")] [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8LegacySqlDataProvider")] [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8LegacySqlDataProvider")] [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8DataAccessMode")] [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8RuleCreationPolicy")] [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8RuleCreationOrigin")] // S8-RULE-GOVERNANCE-BATCH1:判定语义迁入代码定义后,三个 params 解析类一并删除。 [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8TimeoutParams")] [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8ShortageParams")] [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8OutOfRangeParams")] public void LegacyDataAccessType_NoLongerExists(string fullName) { Assert.Null(PluginAssembly.GetType(fullName, throwOnError: false)); } /// /// 取代数据源端点的是一个**只读**目录。二者性质不同,不是改名: /// 旧的是租户可写的物理连接配置,新的是平台级只读目录。 /// [Fact] public void DatasetsController_IsReadOnly() { var actions = typeof(AdoS8ConfigDatasetsController) .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .ToArray(); Assert.NotEmpty(actions); foreach (var m in actions) { Assert.Empty(m.GetCustomAttributes()); Assert.Empty(m.GetCustomAttributes()); Assert.Empty(m.GetCustomAttributes()); Assert.Empty(m.GetCustomAttributes()); } } /// Rule 01 的数据集仍在目录内(供给出来的行必须能通过 Enable Gate 的目录检查)。 [Fact] public void Rule01Dataset_RemainsDefinedInCatalog() { var catalog = new S8DatasetCatalog(new IS8DatasetDefinitionSource[] { new Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers.S8BusinessDatasetDefinitions() }); Assert.True(catalog.IsDefined("PURCHASE_DELIVERY")); } // ───────────────────────── 保留能力不得回归 ───────────────────────── /// /// T9–T11:退役不得误伤仍然合法的能力。 /// 少一个都意味着用户连"调整已发布规则的运行策略"都做不到了。 /// [Theory] [InlineData("UpdateParametersAsync")] [InlineData("EnableAsync")] [InlineData("DisableAsync")] [InlineData("UpdateScheduleAsync")] [InlineData("RunNowAsync")] [InlineData("PauseAsync")] [InlineData("ResumeAsync")] [InlineData("ListAsync")] public void SurvivingCapabilities_AreIntact(string method) { Assert.NotEmpty(typeof(S8WatchRuleService).GetMethods().Where(m => m.Name == method)); } [Theory] [InlineData("PreviewAsync", "GET")] [InlineData("ProvisionAsync", "POST")] [InlineData("EnableAsync", "POST")] [InlineData("DisableAsync", "POST")] [InlineData("UpdateParamsAsync", "PUT")] public void SurvivingEndpoints_AreIntact(string action, string verb) { var m = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), action)); var has = verb switch { "GET" => m.GetCustomAttributes().Any(), "POST" => m.GetCustomAttributes().Any(), "PUT" => m.GetCustomAttributes().Any(), _ => false }; Assert.True(has, $"{action} 必须仍是 {verb}"); } private static IEnumerable Actions(Type controller, string name) => controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .Where(m => m.Name == name); private static string PluginSourceRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP"))) dir = dir.Parent; Assert.NotNull(dir); return Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP"); } /// 只取可执行代码行:注释里为留档会复述旧写法,不应算违规。 private static string ExecutableLines(string code) => string.Join('\n', code.Split('\n') .Where(l => { var t = l.TrimStart(); return !t.StartsWith("///", StringComparison.Ordinal) && !t.StartsWith("//", StringComparison.Ordinal) && !t.StartsWith("*", StringComparison.Ordinal); })); }