test(transport): integration conflict resolution + rollback
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// T26 — integration conflict-resolution tests. The unit-level Apply paths
|
||||||
|
/// in <c>BundleImporterApplyTests</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConflictResolutionTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ServiceProvider _provider;
|
||||||
|
|
||||||
|
public ConflictResolutionTests()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddSingleton<IConfiguration>(
|
||||||
|
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||||
|
|
||||||
|
var dbName = $"ConflictResolutionTests_{Guid.NewGuid()}";
|
||||||
|
services.AddDbContext<ScadaLinkDbContext>(opts => opts
|
||||||
|
.UseInMemoryDatabase(dbName)
|
||||||
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||||
|
|
||||||
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
|
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||||
|
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||||
|
services.AddScoped<IAuditService, AuditService>();
|
||||||
|
services.AddTransport();
|
||||||
|
|
||||||
|
_provider = services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _provider.Dispose();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exports the current set of templates+shared scripts to a freshly built
|
||||||
|
/// bundle and immediately loads it into a session. Returns the
|
||||||
|
/// <see cref="BundleSession.SessionId"/> the caller can hand to ApplyAsync.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Guid> ExportAndLoadAsync()
|
||||||
|
{
|
||||||
|
byte[] bundleBytes;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||||
|
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<int>(),
|
||||||
|
DatabaseConnectionIds: Array.Empty<int>(),
|
||||||
|
NotificationListIds: Array.Empty<int>(),
|
||||||
|
SmtpConfigurationIds: Array.Empty<int>(),
|
||||||
|
ApiKeyIds: Array.Empty<int>(),
|
||||||
|
ApiMethodIds: Array.Empty<int>(),
|
||||||
|
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<IBundleImporter>();
|
||||||
|
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<ScadaLinkDbContext>();
|
||||||
|
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<ScadaLinkDbContext>();
|
||||||
|
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<IBundleImporter>();
|
||||||
|
result = await importer.ApplyAsync(sessionId,
|
||||||
|
new List<ImportResolution>
|
||||||
|
{
|
||||||
|
new("Template", "Pump", ResolutionAction.Overwrite, null),
|
||||||
|
},
|
||||||
|
user: "bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||||
|
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<ScadaLinkDbContext>();
|
||||||
|
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<ScadaLinkDbContext>();
|
||||||
|
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<IBundleImporter>();
|
||||||
|
result = await importer.ApplyAsync(sessionId,
|
||||||
|
new List<ImportResolution>
|
||||||
|
{
|
||||||
|
new("Template", "Pump", ResolutionAction.Skip, null),
|
||||||
|
},
|
||||||
|
user: "bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||||
|
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<ScadaLinkDbContext>();
|
||||||
|
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<ScadaLinkDbContext>();
|
||||||
|
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<IBundleImporter>();
|
||||||
|
result = await importer.ApplyAsync(sessionId,
|
||||||
|
new List<ImportResolution>
|
||||||
|
{
|
||||||
|
new("Template", "Pump", ResolutionAction.Rename, "Pump.Imported"),
|
||||||
|
},
|
||||||
|
user: "bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// T26 — integration validation-failure rollback test. Complements the unit
|
||||||
|
/// rollback covered by <c>BundleImporterApplyTests</c> by driving the full
|
||||||
|
/// export → load → apply path, so the failing validation runs against a
|
||||||
|
/// real export-side serialised bundle.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ValidationFailureTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ServiceProvider _provider;
|
||||||
|
|
||||||
|
public ValidationFailureTests()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddSingleton<IConfiguration>(
|
||||||
|
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||||
|
|
||||||
|
var dbName = $"ValidationFailureTests_{Guid.NewGuid()}";
|
||||||
|
services.AddDbContext<ScadaLinkDbContext>(opts => opts
|
||||||
|
.UseInMemoryDatabase(dbName)
|
||||||
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||||
|
|
||||||
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
|
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||||
|
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||||
|
services.AddScoped<IAuditService, AuditService>();
|
||||||
|
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<ScadaLinkDbContext>();
|
||||||
|
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<IBundleExporter>();
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||||
|
var ids = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||||
|
var selection = new ExportSelection(
|
||||||
|
TemplateIds: ids,
|
||||||
|
SharedScriptIds: Array.Empty<int>(),
|
||||||
|
ExternalSystemIds: Array.Empty<int>(),
|
||||||
|
DatabaseConnectionIds: Array.Empty<int>(),
|
||||||
|
NotificationListIds: Array.Empty<int>(),
|
||||||
|
SmtpConfigurationIds: Array.Empty<int>(),
|
||||||
|
ApiKeyIds: Array.Empty<int>(),
|
||||||
|
ApiMethodIds: Array.Empty<int>(),
|
||||||
|
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<ScadaLinkDbContext>();
|
||||||
|
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<ScadaLinkDbContext>();
|
||||||
|
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<IBundleImporter>();
|
||||||
|
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<IBundleImporter>();
|
||||||
|
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||||
|
importer.ApplyAsync(sessionId,
|
||||||
|
new List<ImportResolution>
|
||||||
|
{
|
||||||
|
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<ScadaLinkDbContext>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user