From 9f1bb81993611b1be951cc031bc87d137a037e0d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 24 May 2026 05:50:11 -0400 Subject: [PATCH] test(transport): integration conflict resolution + rollback --- .../ConflictResolutionTests.cs | 231 ++++++++++++++++++ .../ValidationFailureTests.cs | 174 +++++++++++++ 2 files changed, 405 insertions(+) create mode 100644 tests/ScadaLink.Transport.IntegrationTests/ConflictResolutionTests.cs create mode 100644 tests/ScadaLink.Transport.IntegrationTests/ValidationFailureTests.cs diff --git a/tests/ScadaLink.Transport.IntegrationTests/ConflictResolutionTests.cs b/tests/ScadaLink.Transport.IntegrationTests/ConflictResolutionTests.cs new file mode 100644 index 0000000..616f195 --- /dev/null +++ b/tests/ScadaLink.Transport.IntegrationTests/ConflictResolutionTests.cs @@ -0,0 +1,231 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ScadaLink.Commons.Entities.Templates; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.Commons.Interfaces.Transport; +using ScadaLink.Commons.Types.Transport; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.ConfigurationDatabase.Repositories; +using ScadaLink.ConfigurationDatabase.Services; +using ScadaLink.Transport; + +namespace ScadaLink.Transport.IntegrationTests; + +/// +/// T26 — integration conflict-resolution tests. The unit-level Apply paths +/// in BundleImporterApplyTests exercise hand-built sessions; this +/// suite drives the full export→load→apply pipeline so the wire-level +/// session round-trip is part of the assertion. +/// +public sealed class ConflictResolutionTests : IDisposable +{ + private readonly ServiceProvider _provider; + + public ConflictResolutionTests() + { + var services = new ServiceCollection(); + services.AddSingleton( + new ConfigurationBuilder().AddInMemoryCollection().Build()); + + var dbName = $"ConflictResolutionTests_{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.AddTransport(); + + _provider = services.BuildServiceProvider(); + } + + public void Dispose() => _provider.Dispose(); + + /// + /// Exports the current set of templates+shared scripts to a freshly built + /// bundle and immediately loads it into a session. Returns the + /// the caller can hand to ApplyAsync. + /// + private async Task ExportAndLoadAsync() + { + byte[] bundleBytes; + 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 sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync(); + + var selection = new ExportSelection( + TemplateIds: templateIds, + SharedScriptIds: sharedScriptIds, + ExternalSystemIds: Array.Empty(), + DatabaseConnectionIds: Array.Empty(), + NotificationListIds: Array.Empty(), + SmtpConfigurationIds: Array.Empty(), + ApiKeyIds: 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); + bundleBytes = ms.ToArray(); + } + + await using var loadScope = _provider.CreateAsyncScope(); + var importer = loadScope.ServiceProvider.GetRequiredService(); + using var input = new MemoryStream(bundleBytes, writable: false); + var session = await importer.LoadAsync(input, passphrase: null); + return session.SessionId; + } + + [Fact] + public async Task Overwrite_replaces_existing_template_description() + { + // Arrange: seed Pump with Description="new", export it (the bundle + // therefore carries "new"), then mutate the target's Pump to "old". + // Apply with Overwrite must restore "new". + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.Templates.Add(new Template("Pump") { Description = "new" }); + await ctx.SaveChangesAsync(); + } + + var sessionId = await ExportAndLoadAsync(); + + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var existing = await ctx.Templates.SingleAsync(t => t.Name == "Pump"); + existing.Description = "old"; + await ctx.SaveChangesAsync(); + } + + // Act + ImportResult result; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + result = await importer.ApplyAsync(sessionId, + new List + { + new("Template", "Pump", ResolutionAction.Overwrite, null), + }, + user: "bob"); + } + + // Assert + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var pump = await ctx.Templates.SingleAsync(t => t.Name == "Pump"); + Assert.Equal("new", pump.Description); + } + Assert.Equal(1, result.Overwritten); + } + + [Fact] + public async Task Skip_leaves_existing_template_unchanged() + { + // Arrange: seed Pump with Description="keep", export, then mutate to + // "replace" so the bundle's body diverges. Skip must NOT touch the + // target row, and the summary must report Skipped=1. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.Templates.Add(new Template("Pump") { Description = "replace" }); + await ctx.SaveChangesAsync(); + } + var sessionId = await ExportAndLoadAsync(); + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var existing = await ctx.Templates.SingleAsync(t => t.Name == "Pump"); + existing.Description = "keep"; + await ctx.SaveChangesAsync(); + } + + // Act + ImportResult result; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + result = await importer.ApplyAsync(sessionId, + new List + { + new("Template", "Pump", ResolutionAction.Skip, null), + }, + user: "bob"); + } + + // Assert + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var pump = await ctx.Templates.SingleAsync(t => t.Name == "Pump"); + Assert.Equal("keep", pump.Description); + } + Assert.Equal(1, result.Skipped); + Assert.Equal(0, result.Added); + Assert.Equal(0, result.Overwritten); + } + + [Fact] + public async Task Rename_creates_new_template_alongside_existing() + { + // Arrange: seed Pump, export, mutate description so the rename target + // is obviously the bundle's version. The original Pump must survive + // untouched and a second Pump.Imported template must materialise. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.Templates.Add(new Template("Pump") { Description = "from-bundle" }); + await ctx.SaveChangesAsync(); + } + var sessionId = await ExportAndLoadAsync(); + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var existing = await ctx.Templates.SingleAsync(t => t.Name == "Pump"); + existing.Description = "kept-original"; + await ctx.SaveChangesAsync(); + } + + // Act + ImportResult result; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + result = await importer.ApplyAsync(sessionId, + new List + { + new("Template", "Pump", ResolutionAction.Rename, "Pump.Imported"), + }, + user: "bob"); + } + + // Assert + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var original = await ctx.Templates.SingleAsync(t => t.Name == "Pump"); + Assert.Equal("kept-original", original.Description); + + var renamed = await ctx.Templates.SingleAsync(t => t.Name == "Pump.Imported"); + Assert.Equal("from-bundle", renamed.Description); + } + Assert.Equal(1, result.Renamed); + } +} diff --git a/tests/ScadaLink.Transport.IntegrationTests/ValidationFailureTests.cs b/tests/ScadaLink.Transport.IntegrationTests/ValidationFailureTests.cs new file mode 100644 index 0000000..bfacca5 --- /dev/null +++ b/tests/ScadaLink.Transport.IntegrationTests/ValidationFailureTests.cs @@ -0,0 +1,174 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ScadaLink.Commons.Entities.Scripts; +using ScadaLink.Commons.Entities.Templates; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.Commons.Interfaces.Transport; +using ScadaLink.Commons.Types.Transport; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.ConfigurationDatabase.Repositories; +using ScadaLink.ConfigurationDatabase.Services; +using ScadaLink.Transport; +using ScadaLink.Transport.Import; + +namespace ScadaLink.Transport.IntegrationTests; + +/// +/// T26 — integration validation-failure rollback test. Complements the unit +/// rollback covered by BundleImporterApplyTests by driving the full +/// export → load → apply path, so the failing validation runs against a +/// real export-side serialised bundle. +/// +public sealed class ValidationFailureTests : IDisposable +{ + private readonly ServiceProvider _provider; + + public ValidationFailureTests() + { + var services = new ServiceCollection(); + services.AddSingleton( + new ConfigurationBuilder().AddInMemoryCollection().Build()); + + var dbName = $"ValidationFailureTests_{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.AddTransport(); + + _provider = services.BuildServiceProvider(); + } + + public void Dispose() => _provider.Dispose(); + + [Fact] + public async Task Semantic_validation_failure_rolls_back_all_writes() + { + // Arrange: + // - Seed a template whose script body references MissingHelper(), + // a SharedScript that is NOT in the bundle (we don't seed one) and + // also NOT in the target after the wipe. + // - Export the template only (no helper alongside). + // - Wipe the target so the apply runs through Add-paths. + // - Snapshot row counts so the rollback assertion is unambiguous. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var t = new Template("BrokenPump") { Description = "calls a missing helper" }; + t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();")); + ctx.Templates.Add(t); + await ctx.SaveChangesAsync(); + } + + // Export only the broken template. IncludeDependencies=false guarantees + // MissingHelper is NOT pulled in even if one happened to exist. + byte[] bundleBytes; + await using (var scope = _provider.CreateAsyncScope()) + { + var exporter = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var ids = await ctx.Templates.Select(t => t.Id).ToListAsync(); + var selection = new ExportSelection( + TemplateIds: ids, + SharedScriptIds: Array.Empty(), + ExternalSystemIds: Array.Empty(), + DatabaseConnectionIds: Array.Empty(), + NotificationListIds: Array.Empty(), + SmtpConfigurationIds: Array.Empty(), + ApiKeyIds: 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); + bundleBytes = ms.ToArray(); + } + + // Wipe target so the apply attempts an Add (the validation failure must + // not let it land). + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts); + ctx.Templates.RemoveRange(ctx.Templates); + ctx.SharedScripts.RemoveRange(ctx.SharedScripts); + await ctx.SaveChangesAsync(); + } + + int templatesBefore; + int sharedScriptsBefore; + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + templatesBefore = await ctx.Templates.CountAsync(); + sharedScriptsBefore = await ctx.SharedScripts.CountAsync(); + } + Assert.Equal(0, templatesBefore); + Assert.Equal(0, sharedScriptsBefore); + + // Load the bundle into a session. + Guid sessionId; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + using var input = new MemoryStream(bundleBytes, writable: false); + var session = await importer.LoadAsync(input, passphrase: null); + sessionId = session.SessionId; + } + + // Act — apply with Add. Validation MUST throw. + SemanticValidationException ex = default!; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + ex = await Assert.ThrowsAsync(() => + importer.ApplyAsync(sessionId, + new List + { + new("Template", "BrokenPump", ResolutionAction.Add, null), + }, + user: "bob")); + } + + // Assert + // 1. Errors list calls out the missing dependency by name. + Assert.NotEmpty(ex.Errors); + Assert.Contains(ex.Errors, + err => err.Contains("MissingHelper", StringComparison.Ordinal)); + + // 2. No new Template or SharedScript row was committed. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(templatesBefore, await ctx.Templates.CountAsync()); + Assert.Equal(sharedScriptsBefore, await ctx.SharedScripts.CountAsync()); + + // 3. A BundleImportFailed audit row exists. The BundleImporter + // contract (T17) is: failure row's BundleImportId is NULL (the + // rolled-back id is intentionally disowned) and its + // AfterStateJson.Reason calls out the validation failure. + var failure = await ctx.AuditLogEntries + .SingleAsync(a => a.Action == "BundleImportFailed"); + Assert.Equal("Bundle", failure.EntityType); + Assert.Null(failure.BundleImportId); + Assert.NotNull(failure.AfterStateJson); + // The exception message lands in the Reason field of the payload + // (BundleImporter.ApplyAsync wires it through). Spot-check for + // "validation" so the row's correlation to the failure is visible. + Assert.Contains("validation", failure.AfterStateJson!, + StringComparison.OrdinalIgnoreCase); + } + } +}