using Admin.NET.Plugin.AiDOP.DataPlatform; using Admin.NET.Plugin.AiDOP.DataPlatform.Executors; using Admin.NET.Plugin.AiDOP.Entity.DataPlatform; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform; public class MdpDbSyncConfigContractTests { [Fact] public void Mapping_KeepsRawFields_AndAppliesDirectConstDefault() { var row = new Dictionary { ["ItemCode"] = "A", ["Qty"] = null }; var mapped = MdpDbSyncRules.Apply(row, [ new MdpFieldMapping { SourceField = "ItemCode", TargetField = "item_code", FieldType = "DIRECT", SortOrder = 1 }, new MdpFieldMapping { SourceField = "Qty", TargetField = "qty", FieldType = "DIRECT", DefaultValue = "0", SortOrder = 2 }, new MdpFieldMapping { SourceField = "", TargetField = "origin", FieldType = "CONST", ConstValue = "ERP", SortOrder = 3 } ]); Assert.Equal("A", mapped.Values["ItemCode"]); Assert.Equal("A", mapped.Values["item_code"]); Assert.Equal("0", mapped.Values["qty"]); Assert.Equal("ERP", mapped.Values["origin"]); Assert.Empty(mapped.Errors); } [Fact] public void Mapping_ReadsSafeJsonPath_AndRejectsScript() { var row = new Dictionary { ["payload"] = """{"buyer":{"name":"张三"}}""" }; var ok = MdpDbSyncRules.Apply(row, [ new MdpFieldMapping { SourceField = "payload", TargetField = "buyer_name", FieldType = "JSONPATH", TransformScript = "buyer.name" } ]); Assert.Equal("张三", ok.Values["buyer_name"]); var blocked = MdpDbSyncRules.Apply(row, [ new MdpFieldMapping { SourceField = "payload", TargetField = "x", FieldType = "SCRIPT", TransformScript = "return 1" } ]); Assert.Contains(blocked.Errors, x => x.Contains("SCRIPT")); Assert.False(blocked.Values.TryGetValue("x", out var scriptValue) && scriptValue?.ToString() == "return 1"); } [Fact] public void Mapping_RequiredMiss_IsRowError() { var mapped = MdpDbSyncRules.Apply(new Dictionary(), [ new MdpFieldMapping { SourceField = "BillNo", TargetField = "bill_no", FieldType = "DIRECT", IsRequired = 1 } ]); Assert.Contains(mapped.Errors, x => x.Contains("bill_no")); } [Fact] public void BizKey_UsesMappedField() { var mapped = MdpDbSyncRules.Apply( new Dictionary { ["BillNo"] = "PO1" }, [new MdpFieldMapping { SourceField = "BillNo", TargetField = "order_no", FieldType = "DIRECT" }]); Assert.Equal("PO1", MdpStagingWriter.BuildBizKey("order_no", mapped.Values)); } [Fact] public void PhysicalTable_AndTransformRoute_AreIndependent() { var entity = new MdpEntity { SourceTableName = "third_party_po", TransformSourceTable = "PurOrdMaster" }; Assert.Equal("PurOrdMaster", MdpDbPullExecutor.ResolveTransformSourceTable(entity)); entity.TransformSourceTable = null; Assert.Equal("third_party_po", MdpDbPullExecutor.ResolveTransformSourceTable(entity)); } [Fact] public void RebuildModules_ParseOnlySupportedDistinctModules() { var modules = MdpDbSyncRebuildTrigger.ParseModules("S1,S3|s1;S8 S7"); Assert.Equal(["S1", "S3", "S7"], modules); } [Fact] public void DbEntity_FullCanOmitIncr_IncrRequiresTie() { var full = new MdpEntity { SourceTableName = "po", TargetTableName = "mdp_stg_po", SyncMode = "FULL", BizKeyExpr = "id", BatchSize = 100, TieBreakerColumn = "id" }; Assert.Empty(MdpDbSyncRules.ValidateDbEntity(full, "DB")); var incr = new MdpEntity { SourceTableName = "po", TargetTableName = "mdp_stg_po", SyncMode = "INCR", BizKeyExpr = "id", BatchSize = 100 }; var issues = MdpDbSyncRules.ValidateDbEntity(incr, "DB"); Assert.Contains(issues, x => x.Code == "TIE_BREAKER_MISSING"); Assert.Contains(issues, x => x.Code == "INCR_COLUMN_MISSING"); } [Fact] public void JobId_RejectsOverlengthInsteadOfHashing() { Assert.Null(MdpDbSyncRules.BuildJobId(1, new string('A', 80))); var jobId = MdpDbSyncRules.BuildJobId(12, "SRC_CUSTOMER"); Assert.NotNull(jobId); Assert.True(jobId!.Length <= 64); Assert.StartsWith("job_mdp_dbsync_12_", jobId); } [Fact] public void Keyset_LegacyCursorIsInclusive_AndFullOrdersByTie() { var legacy = new MdpPullContext { UseKeysetCursor = true, CursorColumn = "UpdateTime", TieBreakerColumn = "RecID", CursorValue = "2026-01-01 00:00:00", LegacyCursorInclusive = true }; var predicates = MdpDbPullExecutor.BuildKeysetPredicates(false, legacy, out _); Assert.Contains(predicates, x => x.Contains(">=")); var sql = MdpDbPullExecutor.BuildSelectSql(new MdpEntity { SourceTableName = "po", TieBreakerColumn = "RecID" }, false, 10, new MdpPullContext { FullRefresh = true }); Assert.Contains("ORDER BY `RecID`", sql); Assert.DoesNotContain("ORDER BY 1", sql); } [Fact] public void Dispatcher_FillsKeysetFromEntity_AndRejectsConflict() { var entity = new MdpEntity { EntityCode = "PO", SyncMode = "INCR", IncrColumn = "UpdateTime", TieBreakerColumn = "RecID", LastCursor = "2026-01-01" }; var ctx = new MdpPullContext { RequireConfiguredDbSync = true }; MdpSourcePullDispatcher.ApplyDbSyncDefaults(entity, new MdpSource { SourceType = "DB" }, ctx); Assert.True(ctx.UseKeysetCursor); Assert.Equal("UpdateTime", ctx.CursorColumn); Assert.True(ctx.LegacyCursorInclusive); Assert.Equal("2026-01-01", ctx.CursorValue); var conflict = new MdpPullContext { UseKeysetCursor = true, CursorColumn = "CreateTime", TieBreakerColumn = "Id" }; Assert.Throws(() => MdpSourcePullDispatcher.ApplyDbSyncDefaults(entity, new MdpSource { SourceType = "DB" }, conflict)); } [Fact] public void Redact_HidesPhoneAndEmail() { var text = MdpDbSyncRules.Redact("手机 13812345678 邮箱 a@b.com"); Assert.DoesNotContain("13812345678", text); Assert.DoesNotContain("a@b.com", text); } [Fact] public void SourceSave_UsesTenantAndEncryptsPassword() { var source = File.ReadAllText(Find("MdpSourceConfigService.cs")); Assert.Contains("x.TenantId == tenantId && x.SourceCode == code", source); Assert.Contains("CryptogramUtil.Encrypt", source); Assert.Contains("系统种子来源只能由迁移脚本维护", source); Assert.DoesNotContain("message = ex.Message", source); } [Fact] public void EntityUpdate_PreservesOmittedJobAndCursor() { var source = File.ReadAllText(Find("MdpSyncEntityConfigService.cs")); Assert.DoesNotContain("e.status = 1", source); Assert.Contains("if (!string.IsNullOrWhiteSpace(input.JobId))", source); Assert.Contains("if (input.BatchSize is > 0 and <= 10000)", source); Assert.DoesNotContain("LastCursor =", source); Assert.Contains("transform_source_table AS TransformSourceTable", source); } [Fact] public void BuiltInTemplate_CopyPreservesTargetAndMappings_ButHonorsExplicitRouteAndSource() { var source = File.ReadAllText(Find("MdpSyncEntityConfigService.cs")); Assert.Contains("input.TemplateEntityId", source); Assert.Contains("template?.TargetTableName", source); Assert.Contains("!string.IsNullOrWhiteSpace(input.TransformSourceTable)", source); Assert.Contains("!string.IsNullOrWhiteSpace(input.BizKeyExpr)", source); Assert.True( source.IndexOf("!string.IsNullOrWhiteSpace(input.SourceCode)", StringComparison.Ordinal) < source.IndexOf("if (input.SourceId > 0)", StringComparison.Ordinal)); Assert.Contains("EntityId = id", source); Assert.Contains("await _db.Insertable(copies)", source); var drawer = File.ReadAllText(FindWeb("src/views/aidop/data-platform/components/SyncTaskEntityDrawer.vue")); Assert.Contains("sourceId: undefined", drawer); } [Fact] public void TaskRun_OnlyRebuildsAfterAllEntitiesAndRejectsQueueFailures() { var taskSource = File.ReadAllText(Find("MdpSyncTaskConfigService.cs")); Assert.True( taskSource.IndexOf("_rebuildTrigger.EnqueueForTaskAsync", StringComparison.Ordinal) > taskSource.IndexOf("_dbOperations.RunEntityAsync", StringComparison.Ordinal)); Assert.True( taskSource.IndexOf("_neutralProjection.ProjectAsync", StringComparison.Ordinal) is var projectionAt && projectionAt > taskSource.IndexOf("_dbOperations.RunEntityAsync", StringComparison.Ordinal) && projectionAt < taskSource.IndexOf("_rebuildTrigger.EnqueueForTaskAsync", StringComparison.Ordinal)); var triggerSource = File.ReadAllText(Find("MdpDbSyncRebuildTrigger.cs")); Assert.Contains("throw new InvalidOperationException", triggerSource); Assert.Contains("ParseModules(task.ConsumerModules)", triggerSource); Assert.Contains("if (status == 202)", triggerSource); Assert.DoesNotContain("status is 202 or 409", triggerSource); Assert.Contains("x.JobId == task.TaskCode || x.JobId == jobCode", taskSource); Assert.Contains("x.JobId == task.TaskCode || x.JobId == jobCode", triggerSource); var dispatcherSource = File.ReadAllText(FindPlugin("DataPlatform/Executors/IMdpSourcePullExecutor.cs")); Assert.Contains("FindPagedEntityAsync", dispatcherSource); Assert.Contains(".OrderByDescending(x => x.TenantId)", dispatcherSource); } [Fact] public void S1ToS4_StandardGatesHonorTenantRegisteredSource() { foreach (var relative in new[] { "Order/S1MdpSyncTransformService.cs", "Production/S2MdpSyncTransformService.cs", "Supply/S3MdpSyncTransformService.cs", "ProcurementExecution/S4MdpSyncTransformService.cs" }) { var source = File.ReadAllText(FindPlugin(relative)); Assert.Contains("GetSourceAsync", source); Assert.Contains("registeredSource", source); } } [Fact] public void SourceIdentity_PrefersRowSourceOverGlobalLegacyRegistry() { var sql = MdpSourceIdentity.Resolve("s.source_table", "s.source_system"); Assert.True( sql.IndexOf("NULLIF(s.source_system", StringComparison.Ordinal) < sql.IndexOf("mdp_source_table_registry", StringComparison.Ordinal)); } [Fact] public void DbConnectionError_IsActionableWithoutLeakingDriverDetails() { var auth = MdpDbConnectionError.Describe( new InvalidOperationException("Access denied for user 'secret_user'@'10.0.0.1'")); Assert.Equal("AUTH_FAILED", auth.Code); Assert.DoesNotContain("secret_user", auth.Message); Assert.DoesNotContain("10.0.0.1", auth.Message); var network = MdpDbConnectionError.Describe(new TimeoutException("connect timed out")); Assert.Equal("CONNECTION_FAILED", network.Code); Assert.Contains("网络连接失败", network.Message); } [Fact] public void DbSourceScope_UsesValidatedSourceAndRefreshesAfterConfigChange() { var scopeSource = File.ReadAllText(Find("MdpSourceScopeFactory.cs")); Assert.Contains("GetScopeAsync(MdpSource source", scopeSource); Assert.Contains("source.TenantId", scopeSource); Assert.Contains("source.Id", scopeSource); Assert.Contains("source.UpdateTime.Ticks", scopeSource); var metadataSource = File.ReadAllText(Find("MdpDbSourceMetadataService.cs")); Assert.Contains("sources/{sourceCode}/db/tables", metadataSource); Assert.Contains("_scopes.GetScopeAsync(source)", metadataSource); Assert.Contains("SafeMetadataError", metadataSource); } [Fact] public void NeutralProjection_SupportsDbSyncAndAuthoritySourceSwitch() { var source = File.ReadAllText(Find("InboundNeutralProjectionService.cs")); var dbProjection = File.ReadAllText(Find("MdpDbSyncNeutralProjection.cs")); Assert.Contains("MdpSourceIdentity.DbSync", dbProjection); Assert.Contains("entity.TransformSourceTable", dbProjection); Assert.Contains("@writtenBy", source); Assert.Contains("written_by=VALUES(written_by)", source); Assert.DoesNotContain("IF(source_system=VALUES(source_system)", source); Assert.Contains("source_system=VALUES(source_system)", source); var operation = File.ReadAllText(Find("MdpDbEntityOperationService.cs")); Assert.Contains("result.RowsError > 0", operation); Assert.Contains("本轮不触发中立层投影和模块重算", operation); Assert.DoesNotContain("BeginCacheLock(LockKey(entity), 0,", operation); } private static string Find(string file) { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null) { var candidate = Path.Combine(dir.FullName, "server", "Plugins", "Admin.NET.Plugin.AiDOP", "DataPlatform", file); if (File.Exists(candidate)) return candidate; dir = dir.Parent; } throw new FileNotFoundException(file); } private static string FindPlugin(string relative) { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null) { var candidate = Path.Combine( dir.FullName, "server", "Plugins", "Admin.NET.Plugin.AiDOP", relative.Replace('/', Path.DirectorySeparatorChar)); if (File.Exists(candidate)) return candidate; dir = dir.Parent; } throw new FileNotFoundException(relative); } private static string FindWeb(string relative) { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null) { var candidate = Path.Combine( dir.FullName, "Web", relative.Replace('/', Path.DirectorySeparatorChar)); if (File.Exists(candidate)) return candidate; dir = dir.Parent; } throw new FileNotFoundException(relative); } }