Files
scadalink-design/tests/ScadaLink.Transport.IntegrationTests/Import/BundleImporterPreviewTests.cs
2026-05-24 04:41:24 -04:00

254 lines
11 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.Commons.Entities.ExternalSystems;
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;
namespace ScadaLink.Transport.IntegrationTests.Import;
/// <summary>
/// Integration tests for <see cref="ScadaLink.Transport.Import.BundleImporter.PreviewAsync"/>.
/// Reuses the same in-memory host pattern as the exporter tests: real
/// repositories, real EF in-memory provider, real Transport pipeline. Each test
/// seeds the target DB, exports a bundle, then loads + previews it via the
/// importer.
/// </summary>
public sealed class BundleImporterPreviewTests : IDisposable
{
private readonly ServiceProvider _provider;
public BundleImporterPreviewTests()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().AddInMemoryCollection().Build());
var dbName = $"BundleImporterPreviewTests_{Guid.NewGuid()}";
services.AddDbContext<ScadaLinkDbContext>(opts => opts.UseInMemoryDatabase(dbName));
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();
private async Task<Stream> ExportTemplatesAsync()
{
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);
return await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
}
private static async Task<byte[]> StreamToBytes(Stream s)
{
using var ms = new MemoryStream();
await s.CopyToAsync(ms);
return ms.ToArray();
}
[Fact]
public async Task PreviewAsync_classifies_artifact_as_Identical_when_fields_match()
{
// Arrange: seed a template, export it, leave target unchanged. The
// bundle's DTO is the literal projection of the target, so the diff
// should classify it as Identical.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "stable" });
await ctx.SaveChangesAsync();
}
var bundleStream = await ExportTemplatesAsync();
var bytes = await StreamToBytes(bundleStream);
// Act
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
preview = await importer.PreviewAsync(session.SessionId);
}
// Assert
var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
Assert.Equal(ConflictKind.Identical, pumpItem.Kind);
Assert.Null(pumpItem.FieldDiffJson);
}
[Fact]
public async Task PreviewAsync_classifies_artifact_as_Modified_with_field_diff()
{
// Arrange: seed a template with Description="new", export it, then
// overwrite the target template's Description with "old". The bundle's
// version differs from the target, so the diff should flag the
// Description field.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "new" });
await ctx.SaveChangesAsync();
}
var bundleStream = await ExportTemplatesAsync();
var bytes = await StreamToBytes(bundleStream);
// Mutate the target between export and preview so the diff has
// something to report. The bundle still carries Description="new".
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
t.Description = "old";
await ctx.SaveChangesAsync();
}
// Act
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
preview = await importer.PreviewAsync(session.SessionId);
}
// Assert
var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
Assert.Equal(ConflictKind.Modified, pumpItem.Kind);
Assert.NotNull(pumpItem.FieldDiffJson);
// The diff should mention the Description field by name.
Assert.Contains("Description", pumpItem.FieldDiffJson!, StringComparison.Ordinal);
}
[Fact]
public async Task PreviewAsync_classifies_artifact_as_New_when_absent_from_target()
{
// Arrange: seed a template, export it, then delete it from the target
// database. The bundle still contains the template, so the diff should
// classify it as New (target is now empty).
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "to-be-deleted" });
await ctx.SaveChangesAsync();
}
var bundleStream = await ExportTemplatesAsync();
var bytes = await StreamToBytes(bundleStream);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var t = await ctx.Templates.SingleAsync();
ctx.Templates.Remove(t);
await ctx.SaveChangesAsync();
}
// Act
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
preview = await importer.PreviewAsync(session.SessionId);
}
// Assert
var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
Assert.Equal(ConflictKind.New, pumpItem.Kind);
Assert.Null(pumpItem.FieldDiffJson);
}
[Fact]
public async Task PreviewAsync_emits_Blocker_when_required_dependency_missing()
{
// Arrange: seed a template whose script body calls MissingHelper(), and
// an unrelated HelperFn() shared script that *is* defined but isn't the
// referenced one. We then export WITHOUT IncludeDependencies and use a
// selection that only pulls the template — the bundle won't carry
// MissingHelper (it doesn't exist anywhere) so the preview must flag it.
//
// To get MissingHelper into the bundle script body without the export
// resolver pulling it in (it can't — it doesn't exist), we just seed
// the template with a script that mentions it; the resolver scan only
// matters for entity discovery, the body text is preserved verbatim.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
ctx.ExternalSystemDefinitions.Add(new ExternalSystemDefinition("ErpSystem", "https://erp.example", "ApiKey"));
var t = new Template("Pump") { Description = "broken" };
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
var bundleStream = await ExportTemplatesAsync();
var bytes = await StreamToBytes(bundleStream);
// Wipe the SharedScripts table so MissingHelper has no chance of being
// resolved in the target either. (HelperFn is intentionally seeded so
// we can verify the blocker check is specific — it should NOT flag
// HelperFn since it's in the target.)
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
// Keep HelperFn + ErpSystem so they're in the target's resolved set.
// Just confirm via assertion that MissingHelper is the blocker name.
await ctx.SaveChangesAsync();
}
// Act
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
preview = await importer.PreviewAsync(session.SessionId);
}
// Assert: there's at least one Blocker, and the MissingHelper one is in there.
Assert.Contains(preview.Items, i => i.Kind == ConflictKind.Blocker);
Assert.Contains(preview.Items, i =>
i.Kind == ConflictKind.Blocker
&& i.Name == "MissingHelper"
&& i.BlockerReason is not null
&& i.BlockerReason.Contains("MissingHelper", StringComparison.Ordinal));
// Conversely, HelperFn must NOT be a blocker — it's seeded in the target.
Assert.DoesNotContain(preview.Items, i =>
i.Kind == ConflictKind.Blocker && i.Name == "HelperFn");
}
}