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); } } }