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