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