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