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