|
|
@@ -1106,6 +1106,92 @@ public class S0DimContractTests
|
|
|
Assert.Contains("SUM(CASE WHEN g.r_cnt = 0 THEN g.l_cnt ELSE 0 END)", sql, Ord);
|
|
|
}
|
|
|
|
|
|
+ // ════════════════════════════════════════════════════════════════════════════
|
|
|
+ // Wave 0 · 自动刷新与租户硬门
|
|
|
+ // ════════════════════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void AutoRefreshJob_must_not_hardcode_any_dimension_key()
|
|
|
+ {
|
|
|
+ // 调度对象必须来自 S0DimCatalog.All,不得在 Job 里维护第二份 27 key 清单 ——
|
|
|
+ // 否则新增维度时会静默漏刷,而这种漏刷只在很久以后被消费者发现。
|
|
|
+ var code = StripComments(
|
|
|
+ ReadRepoFile("server/Plugins/Admin.NET.Plugin.AiDOP/Job/S0DimAutoRefreshJob.cs"));
|
|
|
+ foreach (var key in S0DimCatalog.All.Select(d => d.Key))
|
|
|
+ Assert.DoesNotContain($"\"{key}\"", code, Ord);
|
|
|
+ Assert.Contains("S0DimScopeCatalog", code, Ord);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void AutoRefreshJob_must_isolate_failures_and_stay_serial()
|
|
|
+ {
|
|
|
+ var src = StripComments(
|
|
|
+ ReadRepoFile("server/Plugins/Admin.NET.Plugin.AiDOP/Job/S0DimAutoRefreshJob.cs"));
|
|
|
+ // Concurrent=false:Furion 保证同一作业不重入
|
|
|
+ Assert.Contains("Concurrent = false", src, Ord);
|
|
|
+ // 组合级 try/catch:一个 (租户,维度) 失败不得阻断其余组合
|
|
|
+ Assert.Contains("catch (Exception ex)", src, Ord);
|
|
|
+ // 取消必须向上抛,否则 run log 会永久停在 RUNNING
|
|
|
+ Assert.Contains("catch (OperationCanceledException)", src, Ord);
|
|
|
+ // triggerType=AUTO:run log 里要能区分「没人点」与「自动刷挂了」
|
|
|
+ Assert.Contains("triggerType: \"AUTO\"", src, Ord);
|
|
|
+ // 🔴 禁止任何并行原语:ISqlSugarClient 是单例且 dim 写入走显式事务
|
|
|
+ foreach (var parallel in new[] { "Parallel.For", "Task.WhenAll", "SemaphoreSlim" })
|
|
|
+ Assert.DoesNotContain(parallel, src, Ord);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void ScopeCatalog_must_not_exclude_the_default_tenant()
|
|
|
+ {
|
|
|
+ // 🔴 S1MdpScopeCatalog / MdpRebuildScopeCatalog 都写了 Id != DefaultTenantId,
|
|
|
+ // 但 S0 照抄会造成实际缺口:默认租户在 ImageType 与 6 张 qms_* 里共有 28 行真实数据,
|
|
|
+ // 且现有 HTTP 入口对它结构性不可达(授权特性只放行超管,而租户作用域又拒绝
|
|
|
+ // 「超管 + 默认租户」),所以它从未被刷过一次。
|
|
|
+ var code = StripComments(
|
|
|
+ ReadRepoFile("server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S0Dim/S0DimScopeCatalog.cs"));
|
|
|
+ Assert.DoesNotContain("DefaultTenantId", code, Ord);
|
|
|
+ Assert.Contains("SysTenant", code, Ord);
|
|
|
+ // 必须剔除已停用 entity,否则自动作业每拍都为它产一条 FAILED,淹没真实告警
|
|
|
+ Assert.Contains("Status != 1", code, Ord);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void Materializer_must_widen_command_timeout_for_materialization()
|
|
|
+ {
|
|
|
+ // 全局 CommandTimeOut = 30s 对本管线不够:实测 ITEM 单维度平均 132.7s,
|
|
|
+ // ITEM_BOM 的同库 INSERT...SELECT 单条 ~27s 紧贴 30s 线,已实际炸过三次。
|
|
|
+ // 手工刷时人会重试所以被掩盖;自动刷新会变成每拍稳定失败。
|
|
|
+ var code = StripComments(
|
|
|
+ ReadRepoFile("server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S0Dim/S0DimMaterializer.cs"));
|
|
|
+ Assert.Contains("LongCommandTimeoutScope", code, Ord);
|
|
|
+ Assert.Contains("MaterializeCommandTimeoutSeconds", code, Ord);
|
|
|
+ // 必须是 using 作用域(异常路径也还原),不得直接改全局常量
|
|
|
+ Assert.Contains("using var commandTimeout", code, Ord);
|
|
|
+ Assert.DoesNotContain("db.Ado.CommandTimeOut = 30", code, Ord);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void ProductDesign_bom_and_routing_sql_must_carry_tenant_predicate_everywhere()
|
|
|
+ {
|
|
|
+ // 🔴 I-027:这两段裸 SQL 走 _db.Ado.SqlQueryAsync,完全绕过 SqlSugar 的 QueryFilter;
|
|
|
+ // 且该控制器是 [AllowAnonymous],匿名请求下 ITenantIdFilter 根本不注册。
|
|
|
+ // 实测修复前:料号 3124C0015 返回 1120 行(四个租户的拼盘),修复后按租户 32/31/22/22。
|
|
|
+ var src = ReadRepoFile("server/Plugins/Admin.NET.Plugin.AiDOP/Order/ProductDesignService.cs");
|
|
|
+
|
|
|
+ // 七处谓词缺一不可(字符串在全文件唯一,无需切片定位)
|
|
|
+ Assert.Contains("WHERE ParentItem=@itemNum AND tenant_id=@tenantId", src, Ord); // ① anchor
|
|
|
+ Assert.Contains("WHERE c.tenant_id=@tenantId", src, Ord); // ② recursive member
|
|
|
+ Assert.Contains("AND parentIm.tenant_id=@tenantId", src, Ord); // ③ 父表校验 join
|
|
|
+ Assert.Contains("AND im.tenant_id=@tenantId", src, Ord); // ④ 投影 join
|
|
|
+ Assert.Contains("AND pso.tenant_id=@tenantId", src, Ord); // ⑤ 投影 join
|
|
|
+ Assert.Contains("AND r.tenant_id=@tenantId", src, Ord); // ⑥ routing 驱动表
|
|
|
+ Assert.Contains("and p.tenant_id=@tenantId", src, Ord); // ⑦ routing join
|
|
|
+
|
|
|
+ // 参数必须真的传下去,否则谓词恒不成立、全部返回 0 行
|
|
|
+ Assert.Contains("new { itemNum, tenantId }", src, Ord);
|
|
|
+ Assert.Contains("var tenantId = _userManager.TenantId;", src, Ord);
|
|
|
+ }
|
|
|
+
|
|
|
[Fact]
|
|
|
public void Supplier_identity_must_be_tenant_and_supplier_code_only()
|
|
|
{
|
|
|
@@ -1215,4 +1301,23 @@ public class S0DimContractTests
|
|
|
Assert.NotEqual("mdp_stg_customer", d.StagingTable);
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ /// <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)));
|
|
|
}
|