312 lines
13 KiB
C#
312 lines
13 KiB
C#
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.Enums;
|
|
using ScadaLink.Commons.Types.Transport;
|
|
using ScadaLink.ConfigurationDatabase;
|
|
using ScadaLink.ConfigurationDatabase.Repositories;
|
|
using ScadaLink.ConfigurationDatabase.Services;
|
|
using ScadaLink.Transport;
|
|
|
|
namespace ScadaLink.Transport.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// FU-B — integration coverage for the post-flush second-pass rewire in
|
|
/// <c>BundleImporter.ApplyAsync</c>: composition edges (<c>#39</c>) and
|
|
/// alarm-script FKs (remainder of <c>#37</c>). All three scenarios drive the
|
|
/// full export → load → apply pipeline so the wire-level DTO carries the
|
|
/// name-keyed references the importer is expected to resolve.
|
|
/// </summary>
|
|
public sealed class CompositionImportTests : IDisposable
|
|
{
|
|
private readonly ServiceProvider _provider;
|
|
|
|
public CompositionImportTests()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton<IConfiguration>(
|
|
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
|
|
|
var dbName = $"CompositionImportTests_{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>
|
|
/// Builds a bundle of the templates currently in the DB and returns the
|
|
/// raw bytes. Mirrors the helper in <c>RoundTripTests</c> but parameterised
|
|
/// to keep the per-test setup terse.
|
|
/// </summary>
|
|
private async Task<byte[]> ExportAllTemplatesAsync()
|
|
{
|
|
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 selection = new ExportSelection(
|
|
TemplateIds: templateIds,
|
|
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);
|
|
return ms.ToArray();
|
|
}
|
|
|
|
private async Task WipeTemplatesAsync()
|
|
{
|
|
await using var scope = _provider.CreateAsyncScope();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
ctx.TemplateCompositions.RemoveRange(ctx.TemplateCompositions);
|
|
ctx.TemplateAlarms.RemoveRange(ctx.TemplateAlarms);
|
|
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
|
|
ctx.Templates.RemoveRange(ctx.Templates);
|
|
ctx.AuditLogEntries.RemoveRange(ctx.AuditLogEntries);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Composition_edges_are_restored_on_import()
|
|
{
|
|
// Seed: Template A composes Template B via InstanceName="b1".
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
ctx.Templates.Add(new Template("B") { Description = "leaf" });
|
|
await ctx.SaveChangesAsync();
|
|
var b = await ctx.Templates.SingleAsync(t => t.Name == "B");
|
|
|
|
var a = new Template("A") { Description = "composer" };
|
|
ctx.Templates.Add(a);
|
|
await ctx.SaveChangesAsync();
|
|
a.Compositions.Add(new TemplateComposition("b1")
|
|
{
|
|
TemplateId = a.Id,
|
|
ComposedTemplateId = b.Id,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var bundleBytes = await ExportAllTemplatesAsync();
|
|
await WipeTemplatesAsync();
|
|
|
|
// Load + preview + apply with Add for both.
|
|
Guid sessionId;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
using var ms = new MemoryStream(bundleBytes, writable: false);
|
|
var session = await importer.LoadAsync(ms, passphrase: null);
|
|
sessionId = session.SessionId;
|
|
}
|
|
|
|
ImportPreview preview;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
preview = await importer.PreviewAsync(sessionId);
|
|
}
|
|
var resolutions = preview.Items
|
|
.Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Add, null))
|
|
.ToList();
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
await importer.ApplyAsync(sessionId, resolutions, user: "bob");
|
|
}
|
|
|
|
// Assert: A has exactly one TemplateComposition pointing at B.
|
|
await using (var assertScope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = assertScope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
var a = await ctx.Templates
|
|
.Include(t => t.Compositions)
|
|
.SingleAsync(t => t.Name == "A");
|
|
var comp = Assert.Single(a.Compositions);
|
|
Assert.Equal("b1", comp.InstanceName);
|
|
|
|
var b = await ctx.Templates.SingleAsync(t => t.Name == "B");
|
|
Assert.Equal(b.Id, comp.ComposedTemplateId);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Composition_referencing_skipped_template_emits_unresolved_audit_and_skips_edge()
|
|
{
|
|
// Same seed as the happy-path test; the divergence is in the Apply
|
|
// resolutions — B is Skip-resolved so its composition reference is
|
|
// expected to surface as a BundleImportCompositionUnresolved audit row
|
|
// and the composition edge must NOT be written.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
ctx.Templates.Add(new Template("B") { Description = "leaf" });
|
|
await ctx.SaveChangesAsync();
|
|
var b = await ctx.Templates.SingleAsync(t => t.Name == "B");
|
|
|
|
var a = new Template("A") { Description = "composer" };
|
|
ctx.Templates.Add(a);
|
|
await ctx.SaveChangesAsync();
|
|
a.Compositions.Add(new TemplateComposition("b1")
|
|
{
|
|
TemplateId = a.Id,
|
|
ComposedTemplateId = b.Id,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var bundleBytes = await ExportAllTemplatesAsync();
|
|
await WipeTemplatesAsync();
|
|
|
|
Guid sessionId;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
using var ms = new MemoryStream(bundleBytes, writable: false);
|
|
var session = await importer.LoadAsync(ms, passphrase: null);
|
|
sessionId = session.SessionId;
|
|
}
|
|
|
|
ImportResult result;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
// Add A but Skip B. The composition's ComposedTemplateName="B"
|
|
// therefore can't resolve (B isn't being written and isn't in the
|
|
// target — we wiped) and must surface as an unresolved audit row.
|
|
var resolutions = new List<ImportResolution>
|
|
{
|
|
new("Template", "A", ResolutionAction.Add, null),
|
|
new("Template", "B", ResolutionAction.Skip, null),
|
|
};
|
|
result = await importer.ApplyAsync(sessionId, resolutions, user: "bob");
|
|
}
|
|
|
|
await using (var assertScope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = assertScope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
var a = await ctx.Templates
|
|
.Include(t => t.Compositions)
|
|
.SingleAsync(t => t.Name == "A");
|
|
// Composition couldn't resolve → zero edges on A.
|
|
Assert.Empty(a.Compositions);
|
|
|
|
// B was skipped → still not in target.
|
|
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "B"));
|
|
|
|
// BundleImportCompositionUnresolved audit row exists and is
|
|
// correlated by the run's BundleImportId.
|
|
var unresolved = await ctx.AuditLogEntries
|
|
.Where(e => e.Action == "BundleImportCompositionUnresolved")
|
|
.ToListAsync();
|
|
var row = Assert.Single(unresolved);
|
|
Assert.Equal(result.BundleImportId, row.BundleImportId);
|
|
Assert.Equal("TemplateComposition", row.EntityType);
|
|
// Entity name is "<owner>.<instance>" so an operator can find the
|
|
// offending row in the Configuration Audit Log Viewer quickly.
|
|
Assert.Equal("A.b1", row.EntityName);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OnTriggerScript_alarm_link_is_restored_on_import()
|
|
{
|
|
// Seed: a template with a "Startup" script and an alarm whose
|
|
// OnTriggerScriptId points at "Startup". The DTO carries
|
|
// OnTriggerScriptName="Startup" and the importer's second pass should
|
|
// re-resolve the FK to Startup's new id.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
var t = new Template("Pump") { Description = "with alarm" };
|
|
ctx.Templates.Add(t);
|
|
await ctx.SaveChangesAsync();
|
|
|
|
var script = new TemplateScript("Startup", "return 1;")
|
|
{
|
|
TemplateId = t.Id,
|
|
};
|
|
t.Scripts.Add(script);
|
|
await ctx.SaveChangesAsync();
|
|
|
|
t.Alarms.Add(new TemplateAlarm("High")
|
|
{
|
|
TemplateId = t.Id,
|
|
PriorityLevel = 2,
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
OnTriggerScriptId = script.Id,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var bundleBytes = await ExportAllTemplatesAsync();
|
|
await WipeTemplatesAsync();
|
|
|
|
Guid sessionId;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
using var ms = new MemoryStream(bundleBytes, writable: false);
|
|
var session = await importer.LoadAsync(ms, passphrase: null);
|
|
sessionId = session.SessionId;
|
|
}
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
var preview = await importer.PreviewAsync(sessionId);
|
|
var resolutions = preview.Items
|
|
.Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Add, null))
|
|
.ToList();
|
|
await importer.ApplyAsync(sessionId, resolutions, user: "bob");
|
|
}
|
|
|
|
await using (var assertScope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = assertScope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
var pump = await ctx.Templates
|
|
.Include(t => t.Alarms)
|
|
.Include(t => t.Scripts)
|
|
.SingleAsync(t => t.Name == "Pump");
|
|
|
|
var script = Assert.Single(pump.Scripts);
|
|
Assert.Equal("Startup", script.Name);
|
|
|
|
var alarm = Assert.Single(pump.Alarms);
|
|
Assert.Equal("High", alarm.Name);
|
|
// FK rewired to the imported Startup script's NEW id (not the
|
|
// pre-export id, which is gone after the wipe).
|
|
Assert.Equal(script.Id, alarm.OnTriggerScriptId);
|
|
}
|
|
}
|
|
}
|