using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; 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.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; using ZB.MOM.WW.ScadaBridge.Transport.Import; namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import; /// /// C3 — the import apply path must wire template inheritance edges /// (ParentTemplateId) from the bundle's name-keyed BaseTemplateName. /// Before the fix a derived template imported as a root (null parent) — silent /// data loss. Mirrors CompositionImportTests: full export → load → /// preview → apply, so the wire-level DTO carries the reference to resolve. /// public sealed class InheritanceImportTests : IDisposable { private readonly ServiceProvider _provider; public InheritanceImportTests() { var services = new ServiceCollection(); services.AddSingleton( new ConfigurationBuilder().AddInMemoryCollection().Build()); var dbName = $"InheritanceImportTests_{Guid.NewGuid()}"; services.AddDbContext(opts => opts .UseInMemoryDatabase(dbName) .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddTransport(); _provider = services.BuildServiceProvider(); } public void Dispose() => _provider.Dispose(); private async Task ExportAllTemplatesAsync() { await using var scope = _provider.CreateAsyncScope(); var exporter = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync(); var selection = new ExportSelection( TemplateIds: templateIds, SharedScriptIds: Array.Empty(), ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiMethodIds: Array.Empty(), IncludeDependencies: false); var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None); using var ms = new MemoryStream(); await stream.CopyToAsync(ms); return ms.ToArray(); } private async Task WipeTemplatesAsync() { await using var scope = _provider.CreateAsyncScope(); var ctx = scope.ServiceProvider.GetRequiredService(); ctx.TemplateCompositions.RemoveRange(ctx.TemplateCompositions); ctx.Templates.RemoveRange(ctx.Templates); ctx.AuditLogEntries.RemoveRange(ctx.AuditLogEntries); await ctx.SaveChangesAsync(); } /// Seed base "Pump" + derived "WaterPump" (ParentTemplateId -> Pump). private async Task SeedBaseAndDerivedAsync() { await using var scope = _provider.CreateAsyncScope(); var ctx = scope.ServiceProvider.GetRequiredService(); var pump = new Template("Pump") { Description = "base" }; ctx.Templates.Add(pump); await ctx.SaveChangesAsync(); ctx.Templates.Add(new Template("WaterPump") { Description = "derived", ParentTemplateId = pump.Id }); await ctx.SaveChangesAsync(); } private async Task LoadAsync(byte[] bundleBytes) { await using var scope = _provider.CreateAsyncScope(); var importer = scope.ServiceProvider.GetRequiredService(); using var ms = new MemoryStream(bundleBytes, writable: false); var session = await importer.LoadAsync(ms, passphrase: null); return session.SessionId; } [Fact] public async Task Import_DerivedTemplate_WiresParentTemplateId() { await SeedBaseAndDerivedAsync(); var bundle = await ExportAllTemplatesAsync(); await WipeTemplatesAsync(); var sessionId = await LoadAsync(bundle); await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); var preview = await importer.PreviewAsync(sessionId); var resolutions = preview.Items .Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Add, null)) .ToList(); await importer.ApplyAsync(sessionId, resolutions, user: "bob"); } await using (var assert = _provider.CreateAsyncScope()) { var ctx = assert.ServiceProvider.GetRequiredService(); var basePump = await ctx.Templates.SingleAsync(t => t.Name == "Pump"); var derived = await ctx.Templates.SingleAsync(t => t.Name == "WaterPump"); Assert.Equal(basePump.Id, derived.ParentTemplateId); // FAILS today: null } } [Fact] public async Task Import_Overwrite_UpdatesParentEdge_AndRenamedBaseIsHonoured() { // Bundle base renamed to "Pump2" via ResolutionAction.Rename; derived's // BaseTemplateName ("Pump") must resolve through the rename map to Pump2. await SeedBaseAndDerivedAsync(); var bundle = await ExportAllTemplatesAsync(); await WipeTemplatesAsync(); var sessionId = await LoadAsync(bundle); await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); var resolutions = new List { new("Template", "Pump", ResolutionAction.Rename, "Pump2"), new("Template", "WaterPump", ResolutionAction.Add, null), }; await importer.ApplyAsync(sessionId, resolutions, user: "bob"); } await using (var assert = _provider.CreateAsyncScope()) { var ctx = assert.ServiceProvider.GetRequiredService(); var pump2 = await ctx.Templates.SingleAsync(t => t.Name == "Pump2"); var derived = await ctx.Templates.SingleAsync(t => t.Name == "WaterPump"); Assert.Equal(pump2.Id, derived.ParentTemplateId); } } [Fact] public async Task Import_DerivedWithSkippedBase_LeavesNullAndAuditsUnresolved() { // Base "Pump" is Skip-resolved and absent from the wiped target, so the // derived's BaseTemplateName cannot resolve — the edge stays null and a // BundleImportBaseTemplateUnresolved audit row is emitted (never throws). await SeedBaseAndDerivedAsync(); var bundle = await ExportAllTemplatesAsync(); await WipeTemplatesAsync(); var sessionId = await LoadAsync(bundle); ImportResult result; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); var resolutions = new List { new("Template", "Pump", ResolutionAction.Skip, null), new("Template", "WaterPump", ResolutionAction.Add, null), }; result = await importer.ApplyAsync(sessionId, resolutions, user: "bob"); } await using (var assert = _provider.CreateAsyncScope()) { var ctx = assert.ServiceProvider.GetRequiredService(); var derived = await ctx.Templates.SingleAsync(t => t.Name == "WaterPump"); Assert.Null(derived.ParentTemplateId); Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "Pump")); var row = Assert.Single(await ctx.AuditLogEntries .Where(e => e.Action == "BundleImportBaseTemplateUnresolved") .ToListAsync()); Assert.Equal(result.BundleImportId, row.BundleImportId); Assert.Equal("Template", row.EntityType); Assert.Equal("WaterPump", row.EntityName); } } /// Seed base "root" + derived "leaf" (leaf ParentTemplateId -> root). private async Task SeedParentChildAsync(string rootName, string leafName) { await using var scope = _provider.CreateAsyncScope(); var ctx = scope.ServiceProvider.GetRequiredService(); var root = new Template(rootName) { Description = "root" }; ctx.Templates.Add(root); await ctx.SaveChangesAsync(); ctx.Templates.Add(new Template(leafName) { Description = "leaf", ParentTemplateId = root.Id }); await ctx.SaveChangesAsync(); } [Fact] public async Task Import_Overwrite_ThatClosesInheritanceCycle_Throws_AndRollsBack() { // Source: base "Alpha" (root) + derived "Beta" (parent Alpha), so the // exported Beta DTO carries BaseTemplateName = "Alpha". await SeedParentChildAsync("Alpha", "Beta"); var bundle = await ExportAllTemplatesAsync(); await WipeTemplatesAsync(); // Target: the MIRROR — "Beta" (root) + "Alpha" (parent Beta). Overwriting // Beta so it inherits from Alpha closes Alpha -> Beta -> Alpha. await SeedParentChildAsync("Beta", "Alpha"); var sessionId = await LoadAsync(bundle); await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); var resolutions = new List { new("Template", "Alpha", ResolutionAction.Skip, null), new("Template", "Beta", ResolutionAction.Overwrite, null), }; var ex = await Assert.ThrowsAsync( () => importer.ApplyAsync(sessionId, resolutions, user: "bob")); Assert.Contains("cycle", ex.Message, StringComparison.OrdinalIgnoreCase); } // Rolled back: target Beta's parent edge is untouched (still a root). await using (var assert = _provider.CreateAsyncScope()) { var ctx = assert.ServiceProvider.GetRequiredService(); var beta = await ctx.Templates.SingleAsync(t => t.Name == "Beta"); Assert.Null(beta.ParentTemplateId); } } [Fact] public async Task Import_Overwrite_ThatClosesCompositionCycle_Throws_AndRollsBack() { // Source: leaf "Gamma" + "Delta" that composes Gamma, so the exported // Delta DTO carries a composition edge to "Gamma". await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("Gamma") { Description = "leaf" }); await ctx.SaveChangesAsync(); var gamma = await ctx.Templates.SingleAsync(t => t.Name == "Gamma"); var delta = new Template("Delta") { Description = "composer" }; ctx.Templates.Add(delta); await ctx.SaveChangesAsync(); delta.Compositions.Add(new TemplateComposition("g1") { TemplateId = delta.Id, ComposedTemplateId = gamma.Id, }); await ctx.SaveChangesAsync(); } var bundle = await ExportAllTemplatesAsync(); await WipeTemplatesAsync(); // Target: the MIRROR — "Gamma" composes "Delta". Overwriting Delta so it // composes Gamma closes the composition loop Gamma -> Delta -> Gamma. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("Delta") { Description = "target-leaf" }); await ctx.SaveChangesAsync(); var delta = await ctx.Templates.SingleAsync(t => t.Name == "Delta"); var gamma = new Template("Gamma") { Description = "target-composer" }; ctx.Templates.Add(gamma); await ctx.SaveChangesAsync(); gamma.Compositions.Add(new TemplateComposition("d1") { TemplateId = gamma.Id, ComposedTemplateId = delta.Id, }); await ctx.SaveChangesAsync(); } var sessionId = await LoadAsync(bundle); await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); var resolutions = new List { new("Template", "Gamma", ResolutionAction.Skip, null), new("Template", "Delta", ResolutionAction.Overwrite, null), }; var ex = await Assert.ThrowsAsync( () => importer.ApplyAsync(sessionId, resolutions, user: "bob")); Assert.Contains("cycle", ex.Message, StringComparison.OrdinalIgnoreCase); } // Rolled back: target Delta still has NO composition rows (Overwrite did // not persist the loop-closing edge). await using (var assert = _provider.CreateAsyncScope()) { var ctx = assert.ServiceProvider.GetRequiredService(); var delta = await ctx.Templates .Include(t => t.Compositions) .SingleAsync(t => t.Name == "Delta"); Assert.Empty(delta.Compositions); } } }