using System.Text.RegularExpressions; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S0.Dim; /// /// 消费者迁移契约(Wave 1:Supplier / Warehouse / Equipment)。 /// /// 这些断言守的是**迁移方向不被悄悄回退**: /// 智慧运营看板筛选下拉的三个主档来源必须读 S0 标准层 dim_*, /// 而不是各自的源表。此前全仓 27 个标准层对象的业务消费者数为 0—— /// 标准层建好了却没人读,这三条是第一批真实消费。 /// /// 用静态源码断言而非集成测试,是因为要守的性质是「SQL 文本读的是哪张表」, /// 集成测试只能证明「有结果」,证明不了「结果来自标准层」。 /// public class S0ConsumerAdoptionTests { private const StringComparison Ord = StringComparison.Ordinal; private const string ConsumerFile = "server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.SmartOpsFilterSearch.cs"; /// 取某个搜索方法的方法体(切到下一个同签名方法为止)。 private static string BodyOf(string methodName) { var src = StripComments(ReadRepoFile(ConsumerFile)); var sigIdx = src.IndexOf($"Task> {methodName}(", Ord); Assert.True(sigIdx >= 0, $"未找到方法 {methodName}"); var nextIdx = src.IndexOf("Task> ", sigIdx + 40, Ord); return nextIdx < 0 ? src[sigIdx..] : src[sigIdx..nextIdx]; } /// 第一分支(主档)必须来自 dim_*,且源表名彻底消失在该方法内。 [Theory] [InlineData("SearchSuppliersAsync", "dim_supplier", "SuppMaster")] [InlineData("SearchWarehousesAsync", "dim_location", "LocationMaster")] [InlineData("SearchEquipmentsAsync", "dim_work_center", "WorkCtrMaster")] public void Migrated_consumer_reads_standard_layer_not_source( string method, string dimTable, string sourceTable) { var body = BodyOf(method); Assert.Contains($"FROM {dimTable}", body, Ord); Assert.DoesNotContain($"FROM {sourceTable}", body, Ord); } /// /// 三条 dim 查询都必须带 tenant_id = @TenantId。 /// tenant_id 是唯一强制的隔离边界,dim_* 是跨租户共表,漏了就是跨租户泄漏。 /// [Theory] [InlineData("SearchSuppliersAsync", "dim_supplier")] [InlineData("SearchWarehousesAsync", "dim_location")] [InlineData("SearchEquipmentsAsync", "dim_work_center")] public void Migrated_consumer_filters_by_tenant(string method, string dimTable) { var stmt = DimStatement(BodyOf(method), dimTable); Assert.Contains("tenant_id = @TenantId", stmt, Ord); } /// /// 必须过滤停用行。实测省掉 is_active 会在租户 824585161322565 /// 多放出 113 个已停用供应商 —— 这不是洁癖,是可观测的错数。 /// [Theory] [InlineData("SearchSuppliersAsync", "dim_supplier")] [InlineData("SearchWarehousesAsync", "dim_location")] [InlineData("SearchEquipmentsAsync", "dim_work_center")] public void Migrated_consumer_excludes_inactive(string method, string dimTable) { var stmt = DimStatement(BodyOf(method), dimTable); Assert.Contains("is_active", stmt, Ord); } /// /// 必须 GROUP BY 业务码。dim_work_center / dim_location 的唯一键含 /// domain_code,同码跨 domain 可合法出现两行(实测已有 2 个租户持多 domain); /// 不聚合时重复行会被下游 seen 去重,却已白占 LIMIT 名额, /// 表现为「下拉莫名少一项」这种极难归因的静默缺陷。 /// [Theory] [InlineData("SearchSuppliersAsync", "dim_supplier", "supplier_code")] [InlineData("SearchWarehousesAsync", "dim_location", "location_code")] [InlineData("SearchEquipmentsAsync", "dim_work_center", "work_center_code")] public void Migrated_consumer_collapses_duplicate_codes( string method, string dimTable, string codeColumn) { var stmt = DimStatement(BodyOf(method), dimTable); Assert.Contains($"GROUP BY TRIM({codeColumn})", stmt, Ord); Assert.Matches(new Regex(@"MIN\(TRIM\(\w+_name\)\)\s+AS Name"), stmt); } /// /// 对外 Value 必须是业务码,绝不能是 dim_*.id 这类代理键。 /// 代理键每次 FULL REPLACE 都会重编,泄漏出去就是前端筛选值随刷新失效。 /// [Theory] [InlineData("SearchSuppliersAsync", "dim_supplier")] [InlineData("SearchWarehousesAsync", "dim_location")] [InlineData("SearchEquipmentsAsync", "dim_work_center")] public void Migrated_consumer_never_exposes_surrogate_id(string method, string dimTable) { var stmt = DimStatement(BodyOf(method), dimTable); Assert.DoesNotContain("id AS Code", stmt, Ord); Assert.DoesNotContain("`id`", stmt, Ord); Assert.Matches(new Regex(@"SELECT TRIM\(\w+_code\)\s+AS Code"), stmt); } /// /// dim 查询内不得 JOIN 回源表补数据 —— 那等于迁移没做完, /// 且会让标准层的缺口被源表悄悄填上、永远暴露不出来。 /// [Theory] [InlineData("SearchSuppliersAsync", "dim_supplier")] [InlineData("SearchWarehousesAsync", "dim_location")] [InlineData("SearchEquipmentsAsync", "dim_work_center")] public void Migrated_consumer_does_not_join_back_to_source(string method, string dimTable) { var stmt = DimStatement(BodyOf(method), dimTable); Assert.DoesNotContain("JOIN", stmt, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("COALESCE", stmt, StringComparison.OrdinalIgnoreCase); } /// /// 第二分支是「补充」不是「兜底」:它由 rows.Count >= limit 短路, /// 而不是 rows.Count == 0。区别是实质性的—— /// 兜底会在标准层返回 0 行时静默改读源表,让迁移失败看起来像成功。 /// [Theory] [InlineData("SearchSuppliersAsync")] [InlineData("SearchWarehousesAsync")] [InlineData("SearchEquipmentsAsync")] public void Second_branch_is_supplement_not_fallback(string method) { var body = BodyOf(method); Assert.Contains("if (rows.Count >= limit) return rows;", body, Ord); Assert.DoesNotContain("if (rows.Count == 0)", body, Ord); } /// /// 取数失败不得静默。降级返回空列表可以保留(该 helper 被 11 个下拉共用, /// 改成抛出会波及 8 个尚未迁移的消费者),但必须留日志—— /// 否则标准层掉线时前端只看到空下拉,排障无任何抓手。 /// [Fact] public void Query_helpers_do_not_swallow_exceptions_silently() { var src = StripComments(ReadRepoFile(ConsumerFile)); Assert.DoesNotContain("catch\n {", src, Ord); Assert.Equal(2, Regex.Matches(src, @"catch \(Exception ex\)").Count); Assert.Equal(2, Regex.Matches(src, @"Log\.Error\(").Count); } /// 整个消费者文件里,三张已迁移的源主档表必须彻底不再出现。 [Theory] [InlineData("SuppMaster")] [InlineData("LocationMaster")] [InlineData("WorkCtrMaster")] public void Migrated_source_tables_have_zero_references_in_consumer(string sourceTable) { Assert.DoesNotContain(sourceTable, StripComments(ReadRepoFile(ConsumerFile)), Ord); } /// 切出某个 dim 表所在的那条 SQL 语句(从 SELECT 到 LIMIT)。 private static string DimStatement(string body, string dimTable) { var from = body.IndexOf($"FROM {dimTable}", Ord); Assert.True(from >= 0, $"未找到 FROM {dimTable}"); var select = body.LastIndexOf("SELECT", from, Ord); var end = body.IndexOf("LIMIT @Limit", from, Ord); Assert.True(select >= 0 && end > select, "SQL 语句边界解析失败"); return body[select..(end + "LIMIT @Limit".Length)]; } /// 从仓库根读源文件做静态断言。测试进程工作目录在 bin 下,需向上回溯。 private static string ReadRepoFile(string relative) { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "server"))) dir = dir.Parent; Assert.NotNull(dir); var path = Path.Combine(dir!.FullName, relative); Assert.True(File.Exists(path), $"未找到 {path}"); return File.ReadAllText(path); } /// /// 剥掉 ///// 注释行后再做断言。 /// 否则「不得出现 X」这类断言会被解释为什么不能出现 X 的注释本身打败 —— 已踩过一次。 /// private static string StripComments(string source) => string.Join("\n", source.Split('\n').Where(l => !l.TrimStart().StartsWith("//", Ord))); }