From 623aa8d06109b22df0199cd5bcdb926893ebe974 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 24 May 2026 05:48:24 -0400 Subject: [PATCH] =?UTF-8?q?test(transport):=20integration=20round-trip=20e?= =?UTF-8?q?xport=20=E2=86=92=20wipe=20=E2=86=92=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RoundTripTests.cs | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 tests/ScadaLink.Transport.IntegrationTests/RoundTripTests.cs diff --git a/tests/ScadaLink.Transport.IntegrationTests/RoundTripTests.cs b/tests/ScadaLink.Transport.IntegrationTests/RoundTripTests.cs new file mode 100644 index 0000000..7c7c9d5 --- /dev/null +++ b/tests/ScadaLink.Transport.IntegrationTests/RoundTripTests.cs @@ -0,0 +1,285 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ScadaLink.Commons.Entities.ExternalSystems; +using ScadaLink.Commons.Entities.Notifications; +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.Serialization; + +namespace ScadaLink.Transport.IntegrationTests; + +/// +/// T25 — full export→wipe→import round-trip through the real DI pipeline. +/// Confirms the bundle's content survives a complete cycle and that the +/// audit story (BundleExported on the way out, BundleImported on the way +/// in, plus per-entity stamping with BundleImportId) lines up. +/// +/// Uses the same in-memory host pattern as BundleExporterTests and +/// BundleImporterApplyTests — real repositories, in-memory EF +/// provider, real Transport pipeline. The InMemory transaction warning is +/// downgraded for the same reason it is in BundleImporterApplyTests: +/// ApplyAsync opens a transaction that's a no-op here, and the +/// rollback-safety contract (single deferred SaveChangesAsync + +/// ChangeTracker.Clear on catch) keeps the test honest. +/// +/// +public sealed class RoundTripTests : IDisposable +{ + private readonly ServiceProvider _provider; + + public RoundTripTests() + { + var services = new ServiceCollection(); + services.AddSingleton( + new ConfigurationBuilder().AddInMemoryCollection().Build()); + + // Same db across all scopes — see BundleExporterTests for the rationale. + var dbName = $"RoundTripTests_{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 Export_then_wipe_then_import_restores_state() + { + // ---- 1. Seed: 3 templates (one composing another + a standalone), 1 + // SharedScript referenced by template script body, 1 + // ExternalSystem referenced by template script body, 1 + // NotificationList. ---- + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + + ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;")); + ctx.ExternalSystemDefinitions.Add( + new ExternalSystemDefinition("ErpSystem", "https://erp.example", "ApiKey")); + ctx.NotificationLists.Add(new NotificationList("OnCall")); + await ctx.SaveChangesAsync(); + + var baseTemplate = new Template("BaseDevice") { Description = "shared base" }; + // Script body references HelperFn + ErpSystem so the resolver pulls + // them in via the substring scan. We deliberately keep the body + // free of method-call tokens (e.g. `.Call(`) so the importer's + // identifier-scan doesn't flag well-known SDK methods as missing + // SharedScript blockers. + baseTemplate.Scripts.Add(new TemplateScript("init", + "var x = HelperFn(); var sys = ErpSystem;")); + ctx.Templates.Add(baseTemplate); + await ctx.SaveChangesAsync(); + + var composing = new Template("Pump") { Description = "composes BaseDevice" }; + ctx.Templates.Add(composing); + await ctx.SaveChangesAsync(); + composing.Compositions.Add(new TemplateComposition("base") + { + TemplateId = composing.Id, + ComposedTemplateId = baseTemplate.Id, + }); + + ctx.Templates.Add(new Template("Standalone") { Description = "no deps" }); + await ctx.SaveChangesAsync(); + } + + // ---- 2. Export everything as a single encrypted bundle. ---- + 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 externalSystemIds = await ctx.ExternalSystemDefinitions.Select(x => x.Id).ToListAsync(); + var notificationListIds = await ctx.NotificationLists.Select(n => n.Id).ToListAsync(); + + var selection = new ExportSelection( + TemplateIds: templateIds, + SharedScriptIds: sharedScriptIds, + ExternalSystemIds: externalSystemIds, + DatabaseConnectionIds: Array.Empty(), + NotificationListIds: notificationListIds, + SmtpConfigurationIds: Array.Empty(), + ApiKeyIds: Array.Empty(), + ApiMethodIds: Array.Empty(), + IncludeDependencies: true); + + var stream = await exporter.ExportAsync( + selection, user: "alice", sourceEnvironment: "dev", + passphrase: "secret", cancellationToken: CancellationToken.None); + + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + bundleBytes = ms.ToArray(); + } + Assert.NotEmpty(bundleBytes); + + // Read manifest from the bundle so we can pin the ContentHash that + // the BundleImported audit row should echo back inside its + // AfterStateJson — that's the round-trip identity check. + string manifestContentHash; + await using (var scope = _provider.CreateAsyncScope()) + { + var serializer = scope.ServiceProvider.GetRequiredService(); + using var ms = new MemoryStream(bundleBytes, writable: false); + var manifest = serializer.ReadManifest(ms); + manifestContentHash = manifest.ContentHash; + } + Assert.False(string.IsNullOrEmpty(manifestContentHash)); + + // ---- 3. Wipe — delete every seeded entity AND the audit rows the + // export wrote so the post-import assertion can scan the + // whole table unambiguously. ---- + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + + ctx.TemplateCompositions.RemoveRange(ctx.TemplateCompositions); + ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts); + ctx.Templates.RemoveRange(ctx.Templates); + ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders); + ctx.SharedScripts.RemoveRange(ctx.SharedScripts); + ctx.ExternalSystemDefinitions.RemoveRange(ctx.ExternalSystemDefinitions); + ctx.NotificationRecipients.RemoveRange(ctx.NotificationRecipients); + ctx.NotificationLists.RemoveRange(ctx.NotificationLists); + ctx.AuditLogEntries.RemoveRange(ctx.AuditLogEntries); + await ctx.SaveChangesAsync(); + } + + // ---- 4. Load the bundle → session. ---- + Guid sessionId; + 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: "secret"); + sessionId = session.SessionId; + } + + // ---- 5. Preview — every item must be ConflictKind.New now that the + // target is empty. ---- + ImportPreview preview; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + preview = await importer.PreviewAsync(sessionId); + } + Assert.NotEmpty(preview.Items); + Assert.All(preview.Items, item => + Assert.Equal(ConflictKind.New, item.Kind)); + + // ---- 6. Build resolutions: every item gets Add. ---- + var resolutions = preview.Items + .Select(it => new ImportResolution(it.EntityType, it.Name, ResolutionAction.Add, null)) + .ToList(); + + // ---- 7. Apply. ---- + ImportResult result; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + result = await importer.ApplyAsync(sessionId, resolutions, user: "bob"); + } + + // ---- 8. Assertions ---- + await using (var assertScope = _provider.CreateAsyncScope()) + { + var ctx = assertScope.ServiceProvider.GetRequiredService(); + + // 8a. Original entities are all back. Re-query by name and spot-check + // the surviving key fields. + var baseDevice = await ctx.Templates + .Include(t => t.Scripts) + .SingleAsync(t => t.Name == "BaseDevice"); + Assert.Equal("shared base", baseDevice.Description); + Assert.Single(baseDevice.Scripts); + Assert.Equal("init", baseDevice.Scripts.First().Name); + + var pump = await ctx.Templates + .Include(t => t.Compositions) + .SingleAsync(t => t.Name == "Pump"); + Assert.Equal("composes BaseDevice", pump.Description); + // Composition edges intentionally NOT restored by the v1 import + // (see BundleImporter.BuildTemplate XML comment). Round-trip's + // field-level identity holds; rebuilding the composition graph + // belongs to a follow-up task. + + var standalone = await ctx.Templates.SingleAsync(t => t.Name == "Standalone"); + Assert.Equal("no deps", standalone.Description); + + var helper = await ctx.SharedScripts.SingleAsync(s => s.Name == "HelperFn"); + Assert.Equal("return 1;", helper.Code); + + var erp = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "ErpSystem"); + Assert.Equal("https://erp.example", erp.EndpointUrl); + + var onCall = await ctx.NotificationLists.SingleAsync(n => n.Name == "OnCall"); + Assert.NotNull(onCall); + + // 8b. Exactly one BundleExported audit row (user=alice) survived the + // wipe? No — we deliberately wiped audit rows along with the + // entities. The export-side audit row was written BEFORE the + // wipe. Post-import there should therefore be zero + // BundleExported rows from before, plus none from the import + // (the importer doesn't emit BundleExported). The round-trip's + // export-side identity therefore lives ONLY in the bundle bytes + // we captured — manifestContentHash is the export's hash, and + // the BundleImported row's AfterStateJson echoes it back below. + // So this assertion targets the BundleImported side only. + + var imported = await ctx.AuditLogEntries + .SingleAsync(a => a.Action == "BundleImported"); + Assert.Equal("bob", imported.User); + Assert.Equal("Bundle", imported.EntityType); + Assert.Equal(result.BundleImportId, imported.BundleImportId); + Assert.NotNull(imported.AfterStateJson); + // Round-trip identity: the import row carries the same ContentHash + // we read from the bundle bytes above. That's the proof that the + // export's bundle is exactly what the import consumed. + Assert.Contains(manifestContentHash, imported.AfterStateJson!, StringComparison.Ordinal); + + // 8c. Every per-entity audit row written during the import run + // carries result.BundleImportId — that's the correlation + // contract from T5/T17 applied end-to-end here. + var importRows = await ctx.AuditLogEntries + .Where(a => a.BundleImportId == result.BundleImportId) + .ToListAsync(); + Assert.NotEmpty(importRows); + Assert.All(importRows, row => + Assert.Equal(result.BundleImportId, row.BundleImportId)); + // We seeded 3 templates + 1 shared script + 1 external system + 1 + // notification list = 6 created artifacts, plus the BundleImported + // summary row = at least 7 stamped rows. + Assert.True(importRows.Count >= 7, + $"Expected at least 7 BundleImportId-stamped rows; got {importRows.Count}."); + } + + // 8d. ImportResult counts line up with what we seeded. + Assert.Equal(6, result.Added); + Assert.Equal(0, result.Overwritten); + Assert.Equal(0, result.Skipped); + Assert.NotEqual(Guid.Empty, result.BundleImportId); + } +}