using System.Reflection; using System.Text.RegularExpressions; using Admin.NET.Plugin.AiDOP.Entity.S8; using SqlSugar; 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.Plugin.AiDOP.Tests.S8; /// /// S8-TENANT-ONLY-BATCH5:规则执行主链收敛为租户级作用域。 /// /// 本批要消灭的是"伪 Factory Scope":取数从一开始就是租户级的 /// (唯一 Provider 的 WHERE 只有 tenant_id),但规则归属、调度作用域、 /// dedup 身份、抗抖状态唯一键却统统按工厂切。结果是两件事同时发生: /// /// 零工厂 / 多工厂租户被形式门槛拒之门外,而放不放行对取出的数据毫无影响; /// Batch 2 供给出来的 factory_id = 0 运行策略永远不被调度,且不报任何错 /// —— 页面显示已启用,实际从不执行。 /// /// /// 本文件的多数断言是**反向的**("不得出现 factory"),因为这类回归的特征是 /// 悄悄加回一个谓词就让一批规则静默失效,正向断言抓不到。 /// public class S8TenantOnlyMainChainTests { private const long TenantA = 838257186181189L; private const long TenantB = 838257212780613L; private const string Rule01 = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode; private static S8RuleDefinition Definition() => new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() }) .GetRequired(Rule01); // ───────────────── T1:取数请求契约 ───────────────── [Fact] public void T1_MonitoringDataRequest_HasNoFactoryScope() { Assert.Null(typeof(S8MonitoringDataRequest).GetProperty("FactoryId")); // 只缺租户才失败;工厂已不是作用域的一部分,无从缺失。 var req = new S8MonitoringDataRequest { TenantId = TenantA, DatasetCode = "D", RuleCode = Rule01, RuleType = "TIMEOUT" }; req.EnsureValid(); // 不抛即通过 var invalid = new S8MonitoringDataRequest { TenantId = 0, DatasetCode = "D", RuleCode = Rule01, RuleType = "TIMEOUT" }; var ex = Assert.Throws(() => invalid.EnsureValid()); Assert.Equal("dataset_scope_invalid", ex.Reason); Assert.DoesNotContain("FactoryId", ex.Message, StringComparison.Ordinal); } // ───────────────── T2 / T4 / T6 / T16:FactoryId = 0 不再阻塞 ───────────────── /// /// Enable Gate 不得再因 factory_id <= 0 拒绝规则。 /// 这条直接决定 Batch 2 供给出来的运行策略能不能被启用。 /// [Fact] public void T2_EnableGate_TakesTenantOnly_AndHasNoFactoryOverload() { var gate = typeof(S8DatasetEnableGate); Assert.NotNull(gate.GetMethod("Check", new[] { typeof(AdoS8WatchRule), typeof(long) })); Assert.Null(gate.GetMethod("Check", new[] { typeof(AdoS8WatchRule), typeof(long), typeof(long) })); Assert.NotNull(gate.GetMethod("EnsureCanEnable", new[] { typeof(AdoS8WatchRule), typeof(long) })); Assert.Null(gate.GetMethod("EnsureCanEnable", new[] { typeof(AdoS8WatchRule), typeof(long), typeof(long) })); } [Fact] public void T2_EnableGate_SourceHasNoInvalidFactoryScopeCheck() { var code = CodeOnly("Service/S8/Rules/DataAccess/S8DatasetEnableGate.cs"); Assert.DoesNotContain("InvalidFactoryScope", code); Assert.DoesNotContain("factoryId <= 0", code); // 反向对照:租户门槛必须还在,否则是把门整个拆了而不是收敛。 Assert.Contains("tenantId <= 0", code); Assert.Contains("InvalidTenantScope", code); } /// /// T4 / T16:Batch 2 供给出来的行 factory_id = 0。 /// 调度候选查询若还带工厂谓词,这些行会被静默排除 —— 断言源码里不存在该谓词。 /// [Fact] public void T4_T16_ProvisionedFactoryZeroRule_IsNotExcludedByScheduler() { Assert.Equal(0L, S8RuleProvisioningService.CompatibilityFactoryId); var code = CodeOnly("Service/S8/S8WatchSchedulerService.cs"); Assert.DoesNotContain("AND r.factory_id > 0", code); Assert.DoesNotContain("x.FactoryId == factoryId", code); } // ───────────────── T3 / T5 / T6:Tenant → Rule ───────────────── [Fact] public void T3_Scheduler_EnumeratesTenants_ReturningPlainTenantIds() { var m = typeof(S8WatchSchedulerService).GetMethod("ListEnabledTenantsAsync"); Assert.NotNull(m); Assert.Equal(typeof(Task>), m!.ReturnType); Assert.Null(typeof(S8WatchSchedulerService).GetMethod("ListEnabledScopesAsync")); } /// /// T5 / T6:0 / 1 / N 个工厂的租户都只产生**一次**调度。 /// /// 工厂数量根本不是调度遍历的输入 —— 这正是要断言的事实: /// 遍历返回的是租户 id 列表,一个租户在里面只可能出现一次(SQL 的 DISTINCT tenant_id), /// 因此无论它有几个工厂,规则都只跑一遍。 /// [Theory] [InlineData(0)] [InlineData(1)] [InlineData(3)] public void T5_T6_TenantIsDispatchedOnce_RegardlessOfFactoryCount(int factoryCount) { _ = factoryCount; // 不是输入,故意不用 var code = CodeOnly("Service/S8/S8WatchSchedulerService.cs"); Assert.Contains("SELECT DISTINCT r.tenant_id", code); Assert.DoesNotContain("DISTINCT r.tenant_id AS TenantId, r.factory_id", code); // Job 侧按租户遍历,不再按 (租户, 工厂) 对。 var job = CodeOnly("Job/S8WatchSchedulerJob.cs"); Assert.Contains("foreach (var tenantId in tenantIds)", job); Assert.Contains("RunDispatchTickAsync(tenantId, BatchSize, lockedBy)", job); } // ───────────────── T7:Evaluator 契约 ───────────────── [Fact] public void T7_EvaluatorContract_HasNoFactoryScope() { var names = typeof(IS8RuleEvaluator).GetMethod(nameof(IS8RuleEvaluator.EvaluateAsync))! .GetParameters().Select(p => p.Name).ToArray(); Assert.Equal(new[] { "tenantId", "rule", "cancellationToken" }, names); foreach (var impl in new[] { typeof(S8TimeoutRuleEvaluator), typeof(S8ShortageRuleEvaluator), typeof(S8OutOfRangeRuleEvaluator) }) { var ps = impl.GetMethod("EvaluateAsync")!.GetParameters().Select(p => p.Name).ToArray(); Assert.DoesNotContain("factoryId", ps); } } // ───────────────── T8 / T9 / T10:DedupKey vNext ───────────────── [Fact] public void T8_DedupKey_ContainsNoFactorySegment() { var key = S8TimeoutRuleEvaluator.BuildDedupKey(TenantA, Rule01, "PURCHASE_ORDER_LINE", "UATA-PO-001#1"); Assert.Equal($"T{TenantA}:R{Rule01}:PURCHASE_ORDER_LINE:UATA-PO-001#1", key); Assert.DoesNotMatch(new Regex("^T[0-9]+:F[0-9]+:"), key); // 租户前缀必须完整保留 —— 迁移脚本首版曾把它写掉过(MySQL 反向引用要用 $1 而非 \\1)。 Assert.StartsWith($"T{TenantA}:", key, StringComparison.Ordinal); } /// /// T9:同租户 / 同规则 / 同对象,无论工厂元数据是什么,dedup 身份必须相同。 /// 三个 evaluator 的公式必须逐字一致,否则跨类型的历史迁移会出现两套格式。 /// [Fact] public void T9_SameTenantRuleObject_YieldsSameDedup_AcrossEvaluators() { var t = S8TimeoutRuleEvaluator.BuildDedupKey(TenantA, "R", "ORDER", "ID"); var s = S8ShortageRuleEvaluator.BuildDedupKey(TenantA, "R", "ORDER", "ID"); var o = S8OutOfRangeRuleEvaluator.BuildDedupKey(TenantA, "R", "ORDER", "ID"); Assert.Equal(t, s); Assert.Equal(t, o); } [Fact] public void T10_DifferentTenant_SameObject_YieldsDifferentDedup() { var a = S8TimeoutRuleEvaluator.BuildDedupKey(TenantA, "R", "ORDER", "ID"); var b = S8TimeoutRuleEvaluator.BuildDedupKey(TenantB, "R", "ORDER", "ID"); Assert.NotEqual(a, b); Assert.StartsWith($"T{TenantA}:", a, StringComparison.Ordinal); Assert.StartsWith($"T{TenantB}:", b, StringComparison.Ordinal); } [Fact] public void T8_DedupKey_StillFitsVarchar128() { // 去掉 F 段后前缀更短,余量只会变大;本断言防的是将来又往键里塞东西。 var k = S8TimeoutRuleEvaluator.BuildDedupKey( 9999999999999999L, "VERY_LONG_RULE_CODE_FOR_PADDING_64____", "EXTREMELY_LONG_OBJECT_TYPE______", "EXTREMELY_LONG_OBJECT_ID________"); Assert.True(k.Length <= 128, $"dedup_key length {k.Length} exceeds 128: {k}"); } // ───────────────── T11:DetectionState identity ───────────────── [Fact] public void T11_DetectionStateUniqueKey_IsTenantRuleDedup() { var uk = typeof(AdoS8RuleDetectionState) .GetCustomAttributes() .Single(a => a.IndexName == "uk_s8_rule_detection_state_dedup"); Assert.True(uk.IsUnique); Assert.Equal(new[] { "TenantId", "RuleCode", "DedupKey" }, uk.IndexFields.Keys.ToArray()); Assert.DoesNotContain("FactoryId", uk.IndexFields.Keys); } [Fact] public void T11_DetectionStateUpsert_LooksUpWithoutFactory() { var code = CodeOnly("Service/S8/S8WatchSchedulerService.cs"); // 抗抖状态的 upsert / 累计必须只按 (tenant, rule_code, dedup_key)。 Assert.Contains("x.RuleCode == rule.RuleCode", code); Assert.DoesNotContain("x.FactoryId == factoryId", code); } // ───────────────── T14 / T15:Rule scope ───────────────── [Fact] public void T14_WatchRuleLookup_IsTenantOnly() { var code = CodeOnly("Service/S8/S8WatchRuleService.cs"); Assert.Contains("x.Id == id && x.TenantId == scope.TenantId", code); Assert.DoesNotContain("x.FactoryId == scope.FactoryId", code); } [Fact] public void T15_WatchRuleUniqueIdentity_IsTenantAndRuleCode() { var uk = typeof(AdoS8WatchRule) .GetCustomAttributes() .Single(a => a.IndexName == "uk_s8_watch_rule_tenant_code"); Assert.True(uk.IsUnique); Assert.Equal(new[] { "TenantId", "RuleCode" }, uk.IndexFields.Keys.ToArray()); } // ───────────────── T13:Preview 与真跑同源 ───────────────── /// /// 预演必须与调度器用同一套作用域与 dedup 口径。 /// 两边一旦分叉,预演给出的"会新建还是会刷新"就不再可信 —— 而那正是预演唯一的价值。 /// [Fact] public void T13_Preview_UsesSameTenantOnlyScopeAsScheduler() { var code = CodeOnly("Service/S8/S8WatchRulePreviewService.cs"); Assert.Contains("_gateway.LoadAsync(scope.TenantId, rule, ruleType, maxRows)", code); Assert.Contains("evaluator.EvaluateAsync(scope.TenantId, rule)", code); Assert.DoesNotContain("x.FactoryId == scope.FactoryId", code); } // ───────────────── T17–T22:迁移脚本自身的守卫 ───────────────── /// /// 迁移脚本必须自带碰撞门禁:去掉 F 段后若两个旧键映射到同一个新键, /// 自动挑一条会静默丢数据,必须让脚本失败。 /// [Fact] public void T17_Migration_HasCollisionPreflightGate() { var sql = MigrationSql(); Assert.Contains("@collision_exception", sql); Assert.Contains("@collision_state", sql); Assert.Contains("@dup_watch_rule", sql); // 门禁靠具名 CHECK 约束;约束名即失败原因,会出现在 error_message 里。 Assert.Contains("s8_tenant_only_BLOCKED_see_collision_table_1_0_493", sql); Assert.Contains("CHECK (probe = 0)", sql); } /// /// T18:改写必须用 $1 反向引用。 /// /// MySQL 的 REGEXP_REPLACE 用 ICU 语法,\\1 不是分组引用而会被吞成字面量 "1" /// —— 租户前缀整段丢失,得到既不是旧格式也不是新格式的坏键。 /// 本地隔离库首次执行时实测踩到过,本断言防它复发。 /// [Fact] public void T18_Migration_UsesDollarBackreference_NotBackslash() { var sql = MigrationSql(); Assert.Contains("'$1:'", sql); Assert.DoesNotContain("'\\\\1:'", sql); } /// T19:改写面必须锚定旧格式,不得做无界字符串替换。 [Fact] public void T19_Migration_RewritesOnlyAnchoredLegacyFormat() { var sql = MigrationSql(); Assert.Contains("'^T[0-9]+:F[0-9]+:R'", sql); // 无界替换会误伤任何恰好含 ":F" 的业务对象标识。 Assert.DoesNotContain("REPLACE(dedup_key, ':F'", sql); // 归档表必须先于改写建立,否则失败后无从恢复。 Assert.True(sql.IndexOf("_dedup_bak_1_0_493", StringComparison.Ordinal) < sql.IndexOf("UPDATE ado_s8_exception", StringComparison.Ordinal), "归档必须发生在改写之前"); } [Fact] public void T20_T21_Migration_RebuildsTenantOnlyUniqueKeys() { var sql = MigrationSql(); Assert.Contains("uk_s8_rule_detection_state_dedup (tenant_id, rule_code, dedup_key)", sql); Assert.Contains("uk_s8_watch_rule_tenant_code (tenant_id, rule_code)", sql); } /// /// T22:重复执行安全。改写谓词自带幂等(改完就不再匹配); /// DDL 全部由 information_schema 存在性守卫,不依赖事务回滚 /// —— MySQL 的 DDL 多数自动提交,假设能回滚是危险的。 /// [Fact] public void T22_Migration_IsRerunSafe() { var sql = MigrationSql(); Assert.Contains("information_schema.STATISTICS", sql); Assert.Contains("INSERT IGNORE INTO ado_s8_exception_dedup_bak_1_0_493", sql); Assert.Contains("CREATE TABLE IF NOT EXISTS ado_s8_exception_dedup_bak_1_0_493", sql); // 执行器不支持自定义语句分隔符指令,脚本通篇不得出现该关键字(它做的是全文子串匹配)。 Assert.DoesNotContain("DELIMITER", sql, StringComparison.OrdinalIgnoreCase); } /// 迁移刻意不碰的对象:1.0.487 的还原点、异常表的工厂唯一键、以及任何 factory_id 列。 [Fact] public void Migration_DoesNotTouchOutOfScopeObjects() { // 只看可执行 SQL:脚本注释里会**明确列出"不动哪些对象"**, // 那正是我们想要的留档,不该被当成触碰。 var sql = ExecutableSql(MigrationSql()); Assert.DoesNotContain("ado_s8_exception_bak_1_0_487", sql); Assert.DoesNotContain("uk_ado_s8_exception_tenant_factory_code", sql); Assert.DoesNotContain("DROP COLUMN", sql); Assert.DoesNotContain("mdp_entity", sql); } /// 去掉 -- 注释行,只留可执行语句。 private static string ExecutableSql(string sql) => string.Join('\n', sql.Split('\n').Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal))); // ───────────────── helpers ───────────────── private static string MigrationSql() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Web.Entry"))) dir = dir.Parent; Assert.NotNull(dir); var path = Path.Combine(dir!.FullName, "Admin.NET.Web.Entry", "UpdateScripts", "1.0.493.sql"); Assert.True(File.Exists(path), $"迁移脚本不存在,路径需同步更新:{path}"); return File.ReadAllText(path); } private static string CodeOnly(string relative) { 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); var full = Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP", relative.Replace('/', Path.DirectorySeparatorChar)); Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}"); // 只取可执行代码行:注释里为留档会复述旧写法,不应算违规。 return string.Join('\n', File.ReadAllLines(full) .Where(l => { var t = l.TrimStart(); return !t.StartsWith("///", StringComparison.Ordinal) && !t.StartsWith("//", StringComparison.Ordinal); })); } }