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