MdpDbSyncConfigContractTests.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. using Admin.NET.Plugin.AiDOP.DataPlatform;
  2. using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
  3. using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
  4. using Xunit;
  5. namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
  6. public class MdpDbSyncConfigContractTests
  7. {
  8. [Fact]
  9. public void Mapping_KeepsRawFields_AndAppliesDirectConstDefault()
  10. {
  11. var row = new Dictionary<string, object?> { ["ItemCode"] = "A", ["Qty"] = null };
  12. var mapped = MdpDbSyncRules.Apply(row,
  13. [
  14. new MdpFieldMapping { SourceField = "ItemCode", TargetField = "item_code", FieldType = "DIRECT", SortOrder = 1 },
  15. new MdpFieldMapping { SourceField = "Qty", TargetField = "qty", FieldType = "DIRECT", DefaultValue = "0", SortOrder = 2 },
  16. new MdpFieldMapping { SourceField = "", TargetField = "origin", FieldType = "CONST", ConstValue = "ERP", SortOrder = 3 }
  17. ]);
  18. Assert.Equal("A", mapped.Values["ItemCode"]);
  19. Assert.Equal("A", mapped.Values["item_code"]);
  20. Assert.Equal("0", mapped.Values["qty"]);
  21. Assert.Equal("ERP", mapped.Values["origin"]);
  22. Assert.Empty(mapped.Errors);
  23. }
  24. [Fact]
  25. public void Mapping_ReadsSafeJsonPath_AndRejectsScript()
  26. {
  27. var row = new Dictionary<string, object?> { ["payload"] = """{"buyer":{"name":"张三"}}""" };
  28. var ok = MdpDbSyncRules.Apply(row,
  29. [
  30. new MdpFieldMapping { SourceField = "payload", TargetField = "buyer_name", FieldType = "JSONPATH", TransformScript = "buyer.name" }
  31. ]);
  32. Assert.Equal("张三", ok.Values["buyer_name"]);
  33. var blocked = MdpDbSyncRules.Apply(row,
  34. [
  35. new MdpFieldMapping { SourceField = "payload", TargetField = "x", FieldType = "SCRIPT", TransformScript = "return 1" }
  36. ]);
  37. Assert.Contains(blocked.Errors, x => x.Contains("SCRIPT"));
  38. Assert.False(blocked.Values.TryGetValue("x", out var scriptValue) && scriptValue?.ToString() == "return 1");
  39. }
  40. [Fact]
  41. public void Mapping_RequiredMiss_IsRowError()
  42. {
  43. var mapped = MdpDbSyncRules.Apply(new Dictionary<string, object?>(),
  44. [
  45. new MdpFieldMapping { SourceField = "BillNo", TargetField = "bill_no", FieldType = "DIRECT", IsRequired = 1 }
  46. ]);
  47. Assert.Contains(mapped.Errors, x => x.Contains("bill_no"));
  48. }
  49. [Fact]
  50. public void BizKey_UsesMappedField()
  51. {
  52. var mapped = MdpDbSyncRules.Apply(
  53. new Dictionary<string, object?> { ["BillNo"] = "PO1" },
  54. [new MdpFieldMapping { SourceField = "BillNo", TargetField = "order_no", FieldType = "DIRECT" }]);
  55. Assert.Equal("PO1", MdpStagingWriter.BuildBizKey("order_no", mapped.Values));
  56. }
  57. [Fact]
  58. public void PhysicalTable_AndTransformRoute_AreIndependent()
  59. {
  60. var entity = new MdpEntity
  61. {
  62. SourceTableName = "third_party_po",
  63. TransformSourceTable = "PurOrdMaster"
  64. };
  65. Assert.Equal("PurOrdMaster", MdpDbPullExecutor.ResolveTransformSourceTable(entity));
  66. entity.TransformSourceTable = null;
  67. Assert.Equal("third_party_po", MdpDbPullExecutor.ResolveTransformSourceTable(entity));
  68. }
  69. [Fact]
  70. public void RebuildModules_ParseOnlySupportedDistinctModules()
  71. {
  72. var modules = MdpDbSyncRebuildTrigger.ParseModules("S1,S3|s1;S8 S7");
  73. Assert.Equal(["S1", "S3", "S7"], modules);
  74. }
  75. [Fact]
  76. public void DbEntity_FullCanOmitIncr_IncrRequiresTie()
  77. {
  78. var full = new MdpEntity
  79. {
  80. SourceTableName = "po",
  81. TargetTableName = "mdp_stg_po",
  82. SyncMode = "FULL",
  83. BizKeyExpr = "id",
  84. BatchSize = 100,
  85. TieBreakerColumn = "id"
  86. };
  87. Assert.Empty(MdpDbSyncRules.ValidateDbEntity(full, "DB"));
  88. var incr = new MdpEntity
  89. {
  90. SourceTableName = "po",
  91. TargetTableName = "mdp_stg_po",
  92. SyncMode = "INCR",
  93. BizKeyExpr = "id",
  94. BatchSize = 100
  95. };
  96. var issues = MdpDbSyncRules.ValidateDbEntity(incr, "DB");
  97. Assert.Contains(issues, x => x.Code == "TIE_BREAKER_MISSING");
  98. Assert.Contains(issues, x => x.Code == "INCR_COLUMN_MISSING");
  99. }
  100. [Fact]
  101. public void JobId_RejectsOverlengthInsteadOfHashing()
  102. {
  103. Assert.Null(MdpDbSyncRules.BuildJobId(1, new string('A', 80)));
  104. var jobId = MdpDbSyncRules.BuildJobId(12, "SRC_CUSTOMER");
  105. Assert.NotNull(jobId);
  106. Assert.True(jobId!.Length <= 64);
  107. Assert.StartsWith("job_mdp_dbsync_12_", jobId);
  108. }
  109. [Fact]
  110. public void Keyset_LegacyCursorIsInclusive_AndFullOrdersByTie()
  111. {
  112. var legacy = new MdpPullContext
  113. {
  114. UseKeysetCursor = true,
  115. CursorColumn = "UpdateTime",
  116. TieBreakerColumn = "RecID",
  117. CursorValue = "2026-01-01 00:00:00",
  118. LegacyCursorInclusive = true
  119. };
  120. var predicates = MdpDbPullExecutor.BuildKeysetPredicates(false, legacy, out _);
  121. Assert.Contains(predicates, x => x.Contains(">="));
  122. var sql = MdpDbPullExecutor.BuildSelectSql(new MdpEntity
  123. {
  124. SourceTableName = "po",
  125. TieBreakerColumn = "RecID"
  126. }, false, 10, new MdpPullContext { FullRefresh = true });
  127. Assert.Contains("ORDER BY `RecID`", sql);
  128. Assert.DoesNotContain("ORDER BY 1", sql);
  129. }
  130. [Fact]
  131. public void Dispatcher_FillsKeysetFromEntity_AndRejectsConflict()
  132. {
  133. var entity = new MdpEntity
  134. {
  135. EntityCode = "PO",
  136. SyncMode = "INCR",
  137. IncrColumn = "UpdateTime",
  138. TieBreakerColumn = "RecID",
  139. LastCursor = "2026-01-01"
  140. };
  141. var ctx = new MdpPullContext { RequireConfiguredDbSync = true };
  142. MdpSourcePullDispatcher.ApplyDbSyncDefaults(entity, new MdpSource { SourceType = "DB" }, ctx);
  143. Assert.True(ctx.UseKeysetCursor);
  144. Assert.Equal("UpdateTime", ctx.CursorColumn);
  145. Assert.True(ctx.LegacyCursorInclusive);
  146. Assert.Equal("2026-01-01", ctx.CursorValue);
  147. var conflict = new MdpPullContext { UseKeysetCursor = true, CursorColumn = "CreateTime", TieBreakerColumn = "Id" };
  148. Assert.Throws<InvalidOperationException>(() =>
  149. MdpSourcePullDispatcher.ApplyDbSyncDefaults(entity, new MdpSource { SourceType = "DB" }, conflict));
  150. }
  151. [Fact]
  152. public void Redact_HidesPhoneAndEmail()
  153. {
  154. var text = MdpDbSyncRules.Redact("手机 13812345678 邮箱 a@b.com");
  155. Assert.DoesNotContain("13812345678", text);
  156. Assert.DoesNotContain("a@b.com", text);
  157. }
  158. [Fact]
  159. public void SourceSave_UsesTenantAndEncryptsPassword()
  160. {
  161. var source = File.ReadAllText(Find("MdpSourceConfigService.cs"));
  162. Assert.Contains("x.TenantId == tenantId && x.SourceCode == code", source);
  163. Assert.Contains("CryptogramUtil.Encrypt", source);
  164. Assert.Contains("系统种子来源只能由迁移脚本维护", source);
  165. Assert.DoesNotContain("message = ex.Message", source);
  166. }
  167. [Fact]
  168. public void EntityUpdate_PreservesOmittedJobAndCursor()
  169. {
  170. var source = File.ReadAllText(Find("MdpSyncEntityConfigService.cs"));
  171. Assert.DoesNotContain("e.status = 1", source);
  172. Assert.Contains("if (!string.IsNullOrWhiteSpace(input.JobId))", source);
  173. Assert.Contains("if (input.BatchSize is > 0 and <= 10000)", source);
  174. Assert.DoesNotContain("LastCursor =", source);
  175. Assert.Contains("transform_source_table AS TransformSourceTable", source);
  176. }
  177. [Fact]
  178. public void BuiltInTemplate_CopyPreservesTargetAndMappings_ButHonorsExplicitRouteAndSource()
  179. {
  180. var source = File.ReadAllText(Find("MdpSyncEntityConfigService.cs"));
  181. Assert.Contains("input.TemplateEntityId", source);
  182. Assert.Contains("template?.TargetTableName", source);
  183. Assert.Contains("!string.IsNullOrWhiteSpace(input.TransformSourceTable)", source);
  184. Assert.Contains("!string.IsNullOrWhiteSpace(input.BizKeyExpr)", source);
  185. Assert.True(
  186. source.IndexOf("!string.IsNullOrWhiteSpace(input.SourceCode)", StringComparison.Ordinal)
  187. < source.IndexOf("if (input.SourceId > 0)", StringComparison.Ordinal));
  188. Assert.Contains("EntityId = id", source);
  189. Assert.Contains("await _db.Insertable(copies)", source);
  190. var drawer = File.ReadAllText(FindWeb("src/views/aidop/data-platform/components/SyncTaskEntityDrawer.vue"));
  191. Assert.Contains("sourceId: undefined", drawer);
  192. }
  193. [Fact]
  194. public void TaskRun_OnlyRebuildsAfterAllEntitiesAndRejectsQueueFailures()
  195. {
  196. var taskSource = File.ReadAllText(Find("MdpSyncTaskConfigService.cs"));
  197. Assert.True(
  198. taskSource.IndexOf("_rebuildTrigger.EnqueueForTaskAsync", StringComparison.Ordinal)
  199. > taskSource.IndexOf("_dbOperations.RunEntityAsync", StringComparison.Ordinal));
  200. Assert.True(
  201. taskSource.IndexOf("_neutralProjection.ProjectAsync", StringComparison.Ordinal)
  202. is var projectionAt && projectionAt > taskSource.IndexOf("_dbOperations.RunEntityAsync", StringComparison.Ordinal)
  203. && projectionAt < taskSource.IndexOf("_rebuildTrigger.EnqueueForTaskAsync", StringComparison.Ordinal));
  204. var triggerSource = File.ReadAllText(Find("MdpDbSyncRebuildTrigger.cs"));
  205. Assert.Contains("throw new InvalidOperationException", triggerSource);
  206. Assert.Contains("ParseModules(task.ConsumerModules)", triggerSource);
  207. Assert.Contains("if (status == 202)", triggerSource);
  208. Assert.DoesNotContain("status is 202 or 409", triggerSource);
  209. Assert.Contains("x.JobId == task.TaskCode || x.JobId == jobCode", taskSource);
  210. Assert.Contains("x.JobId == task.TaskCode || x.JobId == jobCode", triggerSource);
  211. var dispatcherSource = File.ReadAllText(FindPlugin("DataPlatform/Executors/IMdpSourcePullExecutor.cs"));
  212. Assert.Contains("FindPagedEntityAsync", dispatcherSource);
  213. Assert.Contains(".OrderByDescending(x => x.TenantId)", dispatcherSource);
  214. }
  215. [Fact]
  216. public void S1ToS4_StandardGatesHonorTenantRegisteredSource()
  217. {
  218. foreach (var relative in new[]
  219. {
  220. "Order/S1MdpSyncTransformService.cs",
  221. "Production/S2MdpSyncTransformService.cs",
  222. "Supply/S3MdpSyncTransformService.cs",
  223. "ProcurementExecution/S4MdpSyncTransformService.cs"
  224. })
  225. {
  226. var source = File.ReadAllText(FindPlugin(relative));
  227. Assert.Contains("GetSourceAsync", source);
  228. Assert.Contains("registeredSource", source);
  229. }
  230. }
  231. [Fact]
  232. public void SourceIdentity_PrefersRowSourceOverGlobalLegacyRegistry()
  233. {
  234. var sql = MdpSourceIdentity.Resolve("s.source_table", "s.source_system");
  235. Assert.True(
  236. sql.IndexOf("NULLIF(s.source_system", StringComparison.Ordinal)
  237. < sql.IndexOf("mdp_source_table_registry", StringComparison.Ordinal));
  238. }
  239. [Fact]
  240. public void DbConnectionError_IsActionableWithoutLeakingDriverDetails()
  241. {
  242. var auth = MdpDbConnectionError.Describe(
  243. new InvalidOperationException("Access denied for user 'secret_user'@'10.0.0.1'"));
  244. Assert.Equal("AUTH_FAILED", auth.Code);
  245. Assert.DoesNotContain("secret_user", auth.Message);
  246. Assert.DoesNotContain("10.0.0.1", auth.Message);
  247. var network = MdpDbConnectionError.Describe(new TimeoutException("connect timed out"));
  248. Assert.Equal("CONNECTION_FAILED", network.Code);
  249. Assert.Contains("网络连接失败", network.Message);
  250. }
  251. [Fact]
  252. public void DbSourceScope_UsesValidatedSourceAndRefreshesAfterConfigChange()
  253. {
  254. var scopeSource = File.ReadAllText(Find("MdpSourceScopeFactory.cs"));
  255. Assert.Contains("GetScopeAsync(MdpSource source", scopeSource);
  256. Assert.Contains("source.TenantId", scopeSource);
  257. Assert.Contains("source.Id", scopeSource);
  258. Assert.Contains("source.UpdateTime.Ticks", scopeSource);
  259. var metadataSource = File.ReadAllText(Find("MdpDbSourceMetadataService.cs"));
  260. Assert.Contains("sources/{sourceCode}/db/tables", metadataSource);
  261. Assert.Contains("_scopes.GetScopeAsync(source)", metadataSource);
  262. Assert.Contains("SafeMetadataError", metadataSource);
  263. }
  264. [Fact]
  265. public void NeutralProjection_SupportsDbSyncAndAuthoritySourceSwitch()
  266. {
  267. var source = File.ReadAllText(Find("InboundNeutralProjectionService.cs"));
  268. var dbProjection = File.ReadAllText(Find("MdpDbSyncNeutralProjection.cs"));
  269. Assert.Contains("MdpSourceIdentity.DbSync", dbProjection);
  270. Assert.Contains("entity.TransformSourceTable", dbProjection);
  271. Assert.Contains("@writtenBy", source);
  272. Assert.Contains("written_by=VALUES(written_by)", source);
  273. Assert.DoesNotContain("IF(source_system=VALUES(source_system)", source);
  274. Assert.Contains("source_system=VALUES(source_system)", source);
  275. var operation = File.ReadAllText(Find("MdpDbEntityOperationService.cs"));
  276. Assert.Contains("result.RowsError > 0", operation);
  277. Assert.Contains("本轮不触发中立层投影和模块重算", operation);
  278. Assert.DoesNotContain("BeginCacheLock(LockKey(entity), 0,", operation);
  279. }
  280. private static string Find(string file)
  281. {
  282. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  283. while (dir != null)
  284. {
  285. var candidate = Path.Combine(dir.FullName, "server", "Plugins", "Admin.NET.Plugin.AiDOP", "DataPlatform", file);
  286. if (File.Exists(candidate)) return candidate;
  287. dir = dir.Parent;
  288. }
  289. throw new FileNotFoundException(file);
  290. }
  291. private static string FindPlugin(string relative)
  292. {
  293. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  294. while (dir != null)
  295. {
  296. var candidate = Path.Combine(
  297. dir.FullName,
  298. "server",
  299. "Plugins",
  300. "Admin.NET.Plugin.AiDOP",
  301. relative.Replace('/', Path.DirectorySeparatorChar));
  302. if (File.Exists(candidate)) return candidate;
  303. dir = dir.Parent;
  304. }
  305. throw new FileNotFoundException(relative);
  306. }
  307. private static string FindWeb(string relative)
  308. {
  309. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  310. while (dir != null)
  311. {
  312. var candidate = Path.Combine(
  313. dir.FullName,
  314. "Web",
  315. relative.Replace('/', Path.DirectorySeparatorChar));
  316. if (File.Exists(candidate)) return candidate;
  317. dir = dir.Parent;
  318. }
  319. throw new FileNotFoundException(relative);
  320. }
  321. }