InventoryDataPagesScopeTests.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. using System.Text.RegularExpressions;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure;
  3. using SqlSugar;
  4. using Xunit;
  5. namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
  6. /// <summary>
  7. /// S5「库存数据」菜单 008 / 009 / 013 收口测试。
  8. /// **门禁**:仅当环境变量 AIDOP_IT=1 时执行。全程只读,不写任何表。
  9. /// </summary>
  10. [Trait("Category", "Integration")]
  11. public class InventoryDataPagesScopeTests
  12. {
  13. private const long UatTenant = 838257186181189L;
  14. private const long AidopTenant = 797403760988229L;
  15. private const long SecondDomainTenant = 824585161322565L;
  16. private const string SourceCode = "DOPDEMORQ_SQLSERVER";
  17. private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
  18. private readonly Xunit.Abstractions.ITestOutputHelper _out;
  19. public InventoryDataPagesScopeTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output;
  20. private static ISqlSugarClient BuildAidopdev()
  21. {
  22. const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json";
  23. if (!File.Exists(path)) return null;
  24. var cs = File.ReadLines(path)
  25. .Select(l => l.Trim())
  26. .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"") && l.Contains("Database=aidopdev") && l.Contains("123.60.180.165"))
  27. .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value)
  28. .FirstOrDefault(v => !string.IsNullOrEmpty(v));
  29. if (string.IsNullOrEmpty(cs)) return null;
  30. return new SqlSugarScope(new ConnectionConfig
  31. {
  32. ConfigId = "aidopdev-invpages-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
  33. });
  34. }
  35. // ===================== FUNC-S5-008 标签查询 =====================
  36. /// <summary>
  37. /// 缺陷复现 + 修复验证:码表 JOIN 若不按 Domain+租户限定,会把行数放大。
  38. /// 这是本批 008 的核心断言。
  39. /// </summary>
  40. [Fact]
  41. public async Task F008_StatusDictionaryJoin_MustNotAmplifyRowCount()
  42. {
  43. if (!Enabled) return;
  44. var db = BuildAidopdev();
  45. Assert.NotNull(db);
  46. var baseRows = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint m");
  47. // 旧写法:只按 (FldName, Val) 关联 —— 负向对照,证明缺陷真实存在
  48. var oldJoin = await db.Ado.GetIntAsync(
  49. """
  50. SELECT COUNT(1) FROM MissedPrint m
  51. LEFT JOIN GeneralizedCodeMaster g ON g.FldName='BarcodeStatus' AND g.Val=m.Status
  52. """);
  53. // 新写法:Domain + 租户双限定
  54. var newJoin = await db.Ado.GetIntAsync(
  55. """
  56. SELECT COUNT(1) FROM MissedPrint m
  57. LEFT JOIN GeneralizedCodeMaster g
  58. ON g.FldName='BarcodeStatus' AND g.Val=m.Status
  59. AND g.Domain=m.Domain AND g.tenant_id=@TenantId
  60. """,
  61. new List<SugarParameter> { new("@TenantId", UatTenant) });
  62. _out.WriteLine($"base={baseRows} oldJoin={oldJoin} newJoin={newJoin}");
  63. Assert.True(oldJoin > baseRows, "旧写法应当放大行数,否则本用例失去意义");
  64. Assert.Equal(baseRows, newJoin); // 修复后必须与基表行数一致
  65. }
  66. [Theory]
  67. [InlineData(UatTenant)]
  68. [InlineData(AidopTenant)]
  69. [InlineData(SecondDomainTenant)]
  70. public async Task F008_StatusDictionaryJoin_YieldsAtMostOneRowPerLabel(long tenantId)
  71. {
  72. if (!Enabled) return;
  73. var db = BuildAidopdev();
  74. Assert.NotNull(db);
  75. // 任一标签行经码表 JOIN 后不得命中多于 1 条字典
  76. var worst = await db.Ado.GetIntAsync(
  77. """
  78. SELECT IFNULL(MAX(c),0) FROM (
  79. SELECT m.RecID, COUNT(g.RecID) c
  80. FROM MissedPrint m
  81. LEFT JOIN GeneralizedCodeMaster g
  82. ON g.FldName='BarcodeStatus' AND g.Val=m.Status
  83. AND g.Domain=m.Domain AND g.tenant_id=@TenantId
  84. GROUP BY m.RecID
  85. ) x
  86. """,
  87. new List<SugarParameter> { new("@TenantId", tenantId) });
  88. _out.WriteLine($"tenant={tenantId} 单行最多命中字典条数={worst}");
  89. Assert.True(worst <= 1, $"tenant={tenantId} 存在一行命中 {worst} 条字典,会造成分页放大");
  90. }
  91. /// <summary>状态文案不得来自其它租户:当前租户无字典时应为空(由服务层 COALESCE 回落原始码)。</summary>
  92. [Fact]
  93. public async Task F008_StatusText_NeverBorrowedFromAnotherTenant()
  94. {
  95. if (!Enabled) return;
  96. var db = BuildAidopdev();
  97. Assert.NotNull(db);
  98. var uatOwnDict = await db.Ado.GetIntAsync(
  99. "SELECT COUNT(1) FROM GeneralizedCodeMaster WHERE FldName='BarcodeStatus' AND tenant_id=@T",
  100. new List<SugarParameter> { new("@T", UatTenant) });
  101. var borrowed = await db.Ado.GetIntAsync(
  102. """
  103. SELECT COUNT(1) FROM MissedPrint m
  104. JOIN GeneralizedCodeMaster g
  105. ON g.FldName='BarcodeStatus' AND g.Val=m.Status
  106. AND g.Domain=m.Domain AND g.tenant_id=@TenantId
  107. WHERE g.tenant_id <> @TenantId
  108. """,
  109. new List<SugarParameter> { new("@TenantId", UatTenant) });
  110. _out.WriteLine($"UAT 自有 BarcodeStatus 字典条数={uatOwnDict};借用他租户文案的行数={borrowed}");
  111. Assert.Equal(0, borrowed);
  112. }
  113. /// <summary>ORDER BY 的 tiebreaker 必须唯一:MissedPrint 主键为 RecID。</summary>
  114. [Fact]
  115. public async Task F008_RecIdIsUniqueTiebreaker()
  116. {
  117. if (!Enabled) return;
  118. var db = BuildAidopdev();
  119. Assert.NotNull(db);
  120. var total = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint");
  121. var distinct = await db.Ado.GetIntAsync("SELECT COUNT(DISTINCT RecID) FROM MissedPrint");
  122. _out.WriteLine($"MissedPrint rows={total} distinct RecID={distinct}");
  123. Assert.Equal(total, distinct);
  124. }
  125. /// <summary>
  126. /// 原「数据模型债守卫」的反转版:1.0.442 回填后 MissedPrint.tenant_id 必须全部有值。
  127. /// 一旦又出现 NULL,说明有写路径漏写 tenant_id(008/013 的租户过滤会把这些行整体查空)。
  128. /// </summary>
  129. [Fact]
  130. public async Task F008_MissedPrintTenantColumn_MustBeFullyPopulated()
  131. {
  132. if (!Enabled) return;
  133. var db = BuildAidopdev();
  134. Assert.NotNull(db);
  135. var total = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint");
  136. var nullTenant = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE tenant_id IS NULL");
  137. _out.WriteLine($"MissedPrint rows={total} tenant_id 为空={nullTenant}");
  138. Assert.Equal(0, nullTenant);
  139. }
  140. /// <summary>
  141. /// 回填正确性:归属只能来自 scm_shd.tenant_id(ShipperNbr → shddh),
  142. /// 且必须与 CreateUser → SysUser.TenantId 的独立推导完全一致。
  143. /// </summary>
  144. [Fact]
  145. public async Task F008_MissedPrintTenant_MatchesShipperAndCreateUser()
  146. {
  147. if (!Enabled) return;
  148. var db = BuildAidopdev();
  149. Assert.NotNull(db);
  150. var vsShipper = await db.Ado.GetIntAsync(
  151. """
  152. SELECT COUNT(1) FROM MissedPrint m
  153. JOIN (SELECT shddh, MIN(tenant_id) tenant_id FROM scm_shd
  154. GROUP BY shddh HAVING COUNT(DISTINCT tenant_id)=1) d ON d.shddh = m.ShipperNbr
  155. WHERE IFNULL(m.tenant_id,0) <> d.tenant_id
  156. """);
  157. var vsCreateUser = await db.Ado.GetIntAsync(
  158. """
  159. SELECT COUNT(1) FROM MissedPrint m
  160. JOIN (SELECT Account, MIN(TenantId) TenantId FROM SysUser
  161. GROUP BY Account HAVING COUNT(DISTINCT TenantId)=1) u ON u.Account = m.CreateUser
  162. WHERE IFNULL(m.tenant_id,0) <> u.TenantId
  163. """);
  164. _out.WriteLine($"与 scm_shd 不一致={vsShipper};与 CreateUser→SysUser 不一致={vsCreateUser}");
  165. Assert.Equal(0, vsShipper);
  166. Assert.Equal(0, vsCreateUser);
  167. }
  168. /// <summary>
  169. /// 双向不可见:任一租户按 tenant_id 过滤后,不得看到其它租户的标签;
  170. /// 且各租户可见行数之和必须等于全表行数(不重不漏)。
  171. /// </summary>
  172. [Fact]
  173. public async Task F008_F013_TenantScopedLabels_AreMutuallyInvisible()
  174. {
  175. if (!Enabled) return;
  176. var db = BuildAidopdev();
  177. Assert.NotNull(db);
  178. var total = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint");
  179. var uat = await db.Ado.GetIntAsync(
  180. "SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T", new List<SugarParameter> { new("@T", UatTenant) });
  181. var aidop = await db.Ado.GetIntAsync(
  182. "SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T", new List<SugarParameter> { new("@T", AidopTenant) });
  183. var leaked = await db.Ado.GetIntAsync(
  184. "SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T AND tenant_id<>@T2",
  185. new List<SugarParameter> { new("@T", UatTenant), new("@T2", UatTenant) });
  186. _out.WriteLine($"total={total} UAT可见={uat} 797可见={aidop} 越界={leaked}");
  187. Assert.Equal(0, leaked);
  188. Assert.True(uat > 0 && aidop > 0, "两个租户都应各有标签,否则本用例失去意义");
  189. Assert.Equal(total, uat + aidop);
  190. }
  191. /// <summary>
  192. /// 生成标签 INSERT ... SELECT 的 ItemMaster 关联必须限定租户:
  193. /// ItemNum 单独并不唯一(跨租户重名),不限定会把一行箱码放大成多行 MissedPrint,
  194. /// 并可能取到别的租户的保质期主数据。
  195. /// </summary>
  196. [Fact]
  197. public async Task LabelInsert_ItemMasterJoin_MustBeTenantScoped()
  198. {
  199. if (!Enabled) return;
  200. var db = BuildAidopdev();
  201. Assert.NotNull(db);
  202. // (tenant_id, ItemNum) 唯一 —— 这是加租户条件之后不再放大的前提
  203. var dupWithinTenant = await db.Ado.GetIntAsync(
  204. "SELECT COUNT(1) FROM (SELECT tenant_id,ItemNum FROM ItemMaster GROUP BY tenant_id,ItemNum HAVING COUNT(*)>1) x");
  205. // 单独 ItemNum 不唯一 —— 这是缺陷成立的前提(负向对照)
  206. var dupAcrossTenant = await db.Ado.GetIntAsync(
  207. "SELECT COUNT(1) FROM (SELECT ItemNum FROM ItemMaster GROUP BY ItemNum HAVING COUNT(DISTINCT tenant_id)>1) x");
  208. var amplified = await db.Ado.GetIntAsync(
  209. """
  210. SELECT IFNULL(SUM((SELECT COUNT(*) FROM ItemMaster i WHERE i.ItemNum=s.sh_material_code)),0)
  211. FROM scm_shbq s
  212. """);
  213. var scoped = await db.Ado.GetIntAsync(
  214. """
  215. SELECT IFNULL(SUM((SELECT COUNT(*) FROM ItemMaster i
  216. WHERE i.ItemNum=s.sh_material_code AND i.tenant_id=s.tenant_id)),0)
  217. FROM scm_shbq s
  218. """);
  219. var shbq = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM scm_shbq");
  220. _out.WriteLine($"shbq={shbq} 不限租户JOIN={amplified} 限租户JOIN={scoped} " +
  221. $"(tenant,ItemNum)重复={dupWithinTenant} ItemNum跨租户重名={dupAcrossTenant}");
  222. Assert.Equal(0, dupWithinTenant);
  223. Assert.True(dupAcrossTenant > 0, "ItemNum 若已全局唯一,本缺陷不成立,用例失去意义");
  224. Assert.True(amplified > shbq, "旧写法应当放大,否则本用例失去意义");
  225. Assert.Equal(shbq, scoped);
  226. }
  227. // ===================== FUNC-S5-009 / 013 Domain 解析 =====================
  228. [Theory]
  229. [InlineData(UatTenant, "8010")]
  230. [InlineData(AidopTenant, "8010")]
  231. [InlineData(SecondDomainTenant, "2410")]
  232. public async Task F009_F013_DomainResolvedPerTenant(long tenantId, string expected)
  233. {
  234. if (!Enabled) return;
  235. var db = BuildAidopdev();
  236. Assert.NotNull(db);
  237. var domain = await new SourceDomainTenantResolver(db).ResolveDomainAsync(SourceCode, tenantId);
  238. _out.WriteLine($"tenant={tenantId} → domain={domain}");
  239. Assert.Equal(expected, domain);
  240. }
  241. /// <summary>
  242. /// 负向对照:旧的「全局取第一条 CompanyCode」写法对所有租户返回同一个 Domain,
  243. /// 与按租户解析的结果不一致 —— 证明 009/013 换用统一 resolver 是必要的。
  244. /// </summary>
  245. [Fact]
  246. public async Task F009_F013_LegacyGlobalFirstDomain_DiffersFromPerTenantResolution()
  247. {
  248. if (!Enabled) return;
  249. var db = BuildAidopdev();
  250. Assert.NotNull(db);
  251. var legacy = (await db.Ado.SqlQueryAsync<string>(
  252. "SELECT Domain FROM GeneralizedCodeMaster WHERE FldName='SystemConfig' AND Val='CompanyCode' ORDER BY RecID LIMIT 1"))
  253. .FirstOrDefault();
  254. var resolver = new SourceDomainTenantResolver(db);
  255. var second = await resolver.ResolveDomainAsync(SourceCode, SecondDomainTenant);
  256. _out.WriteLine($"旧写法(全局第一条)={legacy};按租户解析 {SecondDomainTenant} → {second}");
  257. Assert.NotEqual(legacy, second); // 旧写法会把 824 错解析成 8010
  258. }
  259. [Fact]
  260. public async Task F009_F013_TenantWithoutDomainMapping_FailsClosed()
  261. {
  262. if (!Enabled) return;
  263. var db = BuildAidopdev();
  264. Assert.NotNull(db);
  265. var orphan = (await db.Ado.SqlQueryAsync<long>(
  266. """
  267. SELECT t.Id FROM SysTenant t
  268. WHERE t.Status = 1
  269. AND NOT EXISTS (SELECT 1 FROM GeneralizedCodeMaster g
  270. WHERE g.tenant_id=t.Id AND g.FldName='SystemConfig' AND g.Val='CompanyCode'
  271. AND IFNULL(g.Domain,'')<>'')
  272. AND NOT EXISTS (SELECT 1 FROM LocationMaster l
  273. WHERE l.tenant_id=t.Id AND IFNULL(l.Domain,'')<>'')
  274. ORDER BY t.Id LIMIT 1
  275. """)).FirstOrDefault();
  276. Assert.True(orphan > 0, "需要一个无 Domain 映射的启用租户来验证 fail closed");
  277. var ex = await Assert.ThrowsAsync<InvalidOperationException>(
  278. () => new SourceDomainTenantResolver(db).ResolveDomainAsync(SourceCode, orphan));
  279. Assert.DoesNotContain("8010", ex.Message); // 不得悄悄回落固定 Domain
  280. }
  281. }