using System.Collections.Concurrent; using System.Reflection; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services; using ZB.MOM.WW.ScadaBridge.Transport.Import; namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import; /// /// ScadaBridge#93: preview looked database connections, notification lists and API /// methods up one name at a time, and re-keyed template by-name results with an /// ordinal comparer although SQL Server matches names case-insensitively. /// public sealed class ImportNameLookupTests : IDisposable { private readonly ServiceProvider _provider; private readonly ConcurrentDictionary _calls = new(StringComparer.Ordinal); /// Initializes the test class with counting repositories over an in-memory database. public ImportNameLookupTests() { var services = new ServiceCollection(); services.AddSingleton( new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider()); services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); var dbName = $"ImportNameLookupTests_{Guid.NewGuid()}"; services.AddDbContext(opts => opts .UseInMemoryDatabase(dbName) .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))); services.AddSingleton(); // The in-memory provider compares strings ordinally; SQL Server's collation does // not. The proxy gives the by-name template reads the server's case-insensitive // matching so a re-cased target behaves as it would in production. services.AddScoped(sp => CollationProxy.Wrap( new TemplateEngineRepository( sp.GetRequiredService(), sp.GetRequiredService()), sp.GetRequiredService())); services.AddScoped(sp => ImportRoundTripBudgetTests.CountingProxy.Wrap( new ExternalSystemRepository(sp.GetRequiredService()), _calls)); services.AddScoped(sp => ImportRoundTripBudgetTests.CountingProxy.Wrap( new NotificationRepository(sp.GetRequiredService()), _calls)); services.AddScoped(sp => ImportRoundTripBudgetTests.CountingProxy.Wrap( new InboundApiRepository(sp.GetRequiredService()), _calls)); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddTransport(); _provider = services.BuildServiceProvider(); } /// Releases the resources used by this instance. public void Dispose() => _provider.Dispose(); private int Calls(string method) => _calls.TryGetValue(method, out var n) ? n : 0; /// /// Preview of a bundle with several database connections, notification lists and /// API methods must read each library once (no per-name query) and must match the /// target's rows case-insensitively, as SQL Server's collation does. /// /// A task that represents the asynchronous operation. [Fact] public async Task Preview_batches_name_lookups_and_matches_case_insensitively() { await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); for (var i = 0; i < 4; i++) { ctx.DatabaseConnectionDefinitions.Add(new DatabaseConnectionDefinition($"db-{i}", "Server=s;Database=d;")); ctx.NotificationLists.Add(new NotificationList($"list-{i}")); ctx.ApiMethods.Add(new ApiMethod($"Method{i}", "return 1;")); } await ctx.SaveChangesAsync(); } Stream bundle; await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); bundle = await scope.ServiceProvider.GetRequiredService().ExportAsync( new ExportSelection( TemplateIds: Array.Empty(), SharedScriptIds: Array.Empty(), ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: await ctx.DatabaseConnectionDefinitions.Select(x => x.Id).ToListAsync(), NotificationListIds: await ctx.NotificationLists.Select(x => x.Id).ToListAsync(), SmtpConfigurationIds: Array.Empty(), ApiMethodIds: await ctx.ApiMethods.Select(x => x.Id).ToListAsync(), IncludeDependencies: false), user: "alice", sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None); } Guid sessionId; await using (var scope = _provider.CreateAsyncScope()) { sessionId = (await scope.ServiceProvider.GetRequiredService() .LoadAsync(bundle, passphrase: null)).SessionId; } // The target environment spells two of the names with different casing; the // production collation treats them as the SAME row. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); (await ctx.DatabaseConnectionDefinitions.SingleAsync(x => x.Name == "db-1")).Name = "DB-1"; (await ctx.NotificationLists.SingleAsync(x => x.Name == "list-2")).Name = "LIST-2"; (await ctx.ApiMethods.SingleAsync(x => x.Name == "Method3")).Name = "METHOD3"; await ctx.SaveChangesAsync(); } _calls.Clear(); ImportPreview preview; await using (var scope = _provider.CreateAsyncScope()) { preview = await scope.ServiceProvider.GetRequiredService().PreviewAsync(sessionId); } Assert.Equal(0, Calls(nameof(IExternalSystemRepository.GetDatabaseConnectionByNameAsync))); Assert.Equal(0, Calls(nameof(INotificationRepository.GetListByNameAsync))); Assert.Equal(0, Calls(nameof(IInboundApiRepository.GetMethodByNameAsync))); Assert.Equal(1, Calls(nameof(IExternalSystemRepository.GetAllDatabaseConnectionsAsync))); Assert.Equal(1, Calls(nameof(INotificationRepository.GetAllNotificationListsAsync))); Assert.Equal(1, Calls(nameof(IInboundApiRepository.GetAllApiMethodsAsync))); foreach (var item in preview.Items.Where(i => i.EntityType is "DatabaseConnection" or "NotificationList" or "ApiMethod")) { Assert.NotEqual(ConflictKind.New, item.Kind); } Assert.Equal(12, preview.Items.Count(i => i.EntityType is "DatabaseConnection" or "NotificationList" or "ApiMethod")); } /// /// Two rows whose names differ only in case (possible for derived templates, which /// the filtered unique index on Name does not cover) must not throw, and a base /// template wins over a derived one. /// [Fact] public void Template_lookup_tolerates_names_differing_only_in_case() { var derived = new Template("Pump") { Id = 1, IsDerived = true }; var other = new Template("pump") { Id = 2, IsDerived = true }; var baseT = new Template("PUMP") { Id = 3, IsDerived = false }; var lookup = BundleImporter.ToTemplateLookup(new[] { derived, other, baseT }); Assert.Single(lookup); Assert.Same(baseT, lookup["pump"]); var onlyDerived = BundleImporter.ToTemplateLookup(new[] { derived, other }); Assert.Same(derived, onlyDerived["PUMP"]); } /// A lookup built from by-name rows never throws on duplicate case variants. [Fact] public void Name_lookup_is_case_insensitive_and_first_wins() { var a = new NotificationList("List"); var b = new NotificationList("LIST"); var lookup = BundleImporter.ToNameLookup(new[] { a, b }, x => x.Name); Assert.Same(a, lookup["list"]); } // ---- ScadaBridge#95: the remaining libraries + derived templates ------------------ private async Task ExportAndLoadAsync(Func> select) { Stream bundle; await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); bundle = await scope.ServiceProvider.GetRequiredService().ExportAsync( await select(ctx), user: "alice", sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None); } await using (var scope = _provider.CreateAsyncScope()) { return (await scope.ServiceProvider.GetRequiredService() .LoadAsync(bundle, passphrase: null)).SessionId; } } private async Task PreviewAsync(Guid sessionId) { await using var scope = _provider.CreateAsyncScope(); return await scope.ServiceProvider.GetRequiredService().PreviewAsync(sessionId); } /// /// Folders, shared scripts, external systems, SMTP and SMS configurations whose target /// spelling differs only in case are the SAME rows under SQL Server's collation: preview /// must report them as conflicts (not New), and an Overwrite must update them in place /// instead of adding a second row. /// /// A task that represents the asynchronous operation. [Fact] public async Task Preview_and_apply_match_the_remaining_libraries_case_insensitively() { await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var folder = new TemplateFolder("pumps"); ctx.TemplateFolders.Add(folder); await ctx.SaveChangesAsync(); ctx.Templates.Add(new Template("Pump") { FolderId = folder.Id }); ctx.SharedScripts.Add(new SharedScript("Helper", "return 1;")); ctx.ExternalSystemDefinitions.Add(new ExternalSystemDefinition("Erp", "http://erp", "None")); ctx.SmtpConfigurations.Add(new SmtpConfiguration("mail.local", "None", "a@b.c")); ctx.SmsConfigurations.Add(new SmsConfiguration("AC123")); await ctx.SaveChangesAsync(); } var sessionId = await ExportAndLoadAsync(async ctx => new ExportSelection( TemplateIds: await ctx.Templates.Select(x => x.Id).ToListAsync(), SharedScriptIds: await ctx.SharedScripts.Select(x => x.Id).ToListAsync(), ExternalSystemIds: await ctx.ExternalSystemDefinitions.Select(x => x.Id).ToListAsync(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: await ctx.SmtpConfigurations.Select(x => x.Id).ToListAsync(), ApiMethodIds: Array.Empty(), IncludeDependencies: false, SmsConfigurationIds: await ctx.SmsConfigurations.Select(x => x.Id).ToListAsync())); await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); (await ctx.TemplateFolders.SingleAsync()).Name = "PUMPS"; (await ctx.SharedScripts.SingleAsync()).Name = "HELPER"; (await ctx.ExternalSystemDefinitions.SingleAsync()).Name = "ERP"; (await ctx.SmtpConfigurations.SingleAsync()).Host = "MAIL.LOCAL"; (await ctx.SmsConfigurations.SingleAsync()).AccountSid = "ac123"; await ctx.SaveChangesAsync(); } var preview = await PreviewAsync(sessionId); string[] types = ["TemplateFolder", "SharedScript", "ExternalSystem", "SmtpConfiguration", "SmsConfiguration"]; foreach (var type in types) { var item = Assert.Single(preview.Items, i => i.EntityType == type); Assert.NotEqual(ConflictKind.New, item.Kind); } var resolutions = preview.Items .Where(i => i.EntityType is "TemplateFolder" or "SmtpConfiguration" or "SmsConfiguration") .Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Overwrite, null)) .Concat(preview.Items .Where(i => i.EntityType is not ("TemplateFolder" or "SmtpConfiguration" or "SmsConfiguration")) .Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Skip, null))) .ToList(); await using (var scope = _provider.CreateAsyncScope()) { await scope.ServiceProvider.GetRequiredService() .ApplyAsync(sessionId, resolutions, user: "bob"); } await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); Assert.Equal(1, await ctx.TemplateFolders.CountAsync()); Assert.Equal(1, await ctx.SmtpConfigurations.CountAsync()); Assert.Equal(1, await ctx.SmsConfigurations.CountAsync()); } } /// /// A derived template carries its composition slot name; a bundled template of that name /// targets a BASE template, so a target whose only row of that name is derived must see /// the bundled template as New, and applying it must add a base template rather than /// overwrite the derived one. /// /// A task that represents the asynchronous operation. [Fact] public async Task A_derived_template_sharing_the_bundled_name_is_not_the_import_target() { await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("Motor")); await ctx.SaveChangesAsync(); } var sessionId = await ExportAndLoadAsync(async ctx => new ExportSelection( TemplateIds: await ctx.Templates.Select(x => x.Id).ToListAsync(), SharedScriptIds: Array.Empty(), ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiMethodIds: Array.Empty(), IncludeDependencies: false)); // The target's only "Motor" is a derived slot template. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); (await ctx.Templates.SingleAsync()).IsDerived = true; await ctx.SaveChangesAsync(); } var preview = await PreviewAsync(sessionId); Assert.Equal(ConflictKind.New, Assert.Single(preview.Items, i => i.EntityType == "Template").Kind); await using (var scope = _provider.CreateAsyncScope()) { await scope.ServiceProvider.GetRequiredService().ApplyAsync( sessionId, [new ImportResolution("Template", "Motor", ResolutionAction.Overwrite, null)], user: "bob"); } await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var motors = await ctx.Templates.Where(t => t.Name == "Motor").ToListAsync(); Assert.Equal(2, motors.Count); Assert.Single(motors, t => !t.IsDerived); } } // ---- ScadaBridge#100: names inside a record --------------------------------------- /// /// Template members (attributes, alarms, scripts, native alarm sources) and external /// system methods are unique per parent case-insensitively under SQL Server's /// collation. A target whose member names differ from the bundle's only in case holds /// the SAME members: preview must report them unchanged, and an Overwrite must keep /// the rows (same ids, no delete + re-add) and the alarm's on-trigger script link — /// also when the template name itself differs only in case. /// /// A task that represents the asynchronous operation. [Fact] public async Task Members_inside_a_record_match_case_insensitively() { await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var template = new Template("Pump"); template.Attributes.Add(new TemplateAttribute("Speed") { Value = "1", DataType = DataType.Int32 }); template.Scripts.Add(new TemplateScript("OnTrip", "return 1;")); template.Alarms.Add(new TemplateAlarm("HighTemp") { PriorityLevel = 1 }); template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Boiler") { ConnectionName = "c", SourceReference = "r" }); ctx.Templates.Add(template); var erp = new ExternalSystemDefinition("Erp", "http://erp", "None"); ctx.ExternalSystemDefinitions.Add(erp); await ctx.SaveChangesAsync(); template.Alarms.Single().OnTriggerScriptId = template.Scripts.Single().Id; ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("GetOrder", "GET", "/orders") { ExternalSystemDefinitionId = erp.Id }); await ctx.SaveChangesAsync(); } var sessionId = await ExportAndLoadAsync(async ctx => new ExportSelection( TemplateIds: await ctx.Templates.Select(x => x.Id).ToListAsync(), SharedScriptIds: Array.Empty(), ExternalSystemIds: await ctx.ExternalSystemDefinitions.Select(x => x.Id).ToListAsync(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiMethodIds: Array.Empty(), IncludeDependencies: false)); // Re-case every name on the target; ids are what an in-place update must keep. int[] memberIds; await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); (await ctx.Templates.SingleAsync()).Name = "PUMP"; var attribute = await ctx.TemplateAttributes.SingleAsync(); var script = await ctx.TemplateScripts.SingleAsync(); var alarm = await ctx.TemplateAlarms.SingleAsync(); var source = await ctx.TemplateNativeAlarmSources.SingleAsync(); var method = await ctx.ExternalSystemMethods.SingleAsync(); attribute.Name = "SPEED"; script.Name = "ontrip"; alarm.Name = "hightemp"; source.Name = "BOILER"; method.Name = "getorder"; await ctx.SaveChangesAsync(); memberIds = [attribute.Id, script.Id, alarm.Id, source.Id, method.Id]; } var preview = await PreviewAsync(sessionId); Assert.Equal(ConflictKind.Identical, Assert.Single(preview.Items, i => i.EntityType == "Template").Kind); Assert.Equal(ConflictKind.Identical, Assert.Single(preview.Items, i => i.EntityType == "ExternalSystem").Kind); await using (var scope = _provider.CreateAsyncScope()) { await scope.ServiceProvider.GetRequiredService().ApplyAsync( sessionId, [ new ImportResolution("Template", "Pump", ResolutionAction.Overwrite, null), new ImportResolution("ExternalSystem", "Erp", ResolutionAction.Overwrite, null), ], user: "bob"); } await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var attribute = await ctx.TemplateAttributes.SingleAsync(); var script = await ctx.TemplateScripts.SingleAsync(); var alarm = await ctx.TemplateAlarms.SingleAsync(); var source = await ctx.TemplateNativeAlarmSources.SingleAsync(); var method = await ctx.ExternalSystemMethods.SingleAsync(); Assert.Equal(memberIds, new[] { attribute.Id, script.Id, alarm.Id, source.Id, method.Id }); Assert.Equal(script.Id, alarm.OnTriggerScriptId); Assert.DoesNotContain(await ctx.AuditLogEntries.Select(e => e.Action).ToListAsync(), a => a.EndsWith("Deleted", StringComparison.Ordinal) || a.EndsWith("Added", StringComparison.Ordinal)); } } /// /// Gives 's by-name template reads SQL Server's /// case-insensitive name matching over the in-memory provider: the requested names are /// replaced by every stored template name equal to one of them ignoring case. /// public class CollationProxy : DispatchProxy { private ITemplateEngineRepository _inner = null!; private ScadaBridgeDbContext _context = null!; /// Creates the proxy over . /// The real repository. /// The context the repository reads. /// The proxy. public static ITemplateEngineRepository Wrap(ITemplateEngineRepository inner, ScadaBridgeDbContext context) { var proxy = Create(); var self = (CollationProxy)(object)proxy; self._inner = inner; self._context = context; return proxy; } /// protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) { if (targetMethod!.Name is nameof(ITemplateEngineRepository.GetTemplatesWithChildrenAsync) or nameof(ITemplateEngineRepository.GetBaseTemplatesWithChildrenAsync) && args![0] is IEnumerable names) { var wanted = names.Where(n => n is not null).ToHashSet(StringComparer.OrdinalIgnoreCase); args[0] = _context.Templates.AsNoTracking().Select(t => t.Name).AsEnumerable() .Where(wanted.Contains).Distinct(StringComparer.Ordinal).ToList(); } try { return targetMethod.Invoke(_inner, args); } catch (TargetInvocationException ex) when (ex.InnerException is not null) { throw ex.InnerException; } } } }