test(transport): integration round-trip export → wipe → import
This commit is contained in:
285
tests/ScadaLink.Transport.IntegrationTests/RoundTripTests.cs
Normal file
285
tests/ScadaLink.Transport.IntegrationTests/RoundTripTests.cs
Normal file
@@ -0,0 +1,285 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||
using ScadaLink.Commons.Entities.Notifications;
|
||||
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.Serialization;
|
||||
|
||||
namespace ScadaLink.Transport.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// T25 — full export→wipe→import round-trip through the real DI pipeline.
|
||||
/// Confirms the bundle's content survives a complete cycle and that the
|
||||
/// audit story (BundleExported on the way out, BundleImported on the way
|
||||
/// in, plus per-entity stamping with BundleImportId) lines up.
|
||||
/// <para>
|
||||
/// Uses the same in-memory host pattern as <c>BundleExporterTests</c> and
|
||||
/// <c>BundleImporterApplyTests</c> — real repositories, in-memory EF
|
||||
/// provider, real Transport pipeline. The InMemory transaction warning is
|
||||
/// downgraded for the same reason it is in <c>BundleImporterApplyTests</c>:
|
||||
/// <c>ApplyAsync</c> opens a transaction that's a no-op here, and the
|
||||
/// rollback-safety contract (single deferred SaveChangesAsync +
|
||||
/// ChangeTracker.Clear on catch) keeps the test honest.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RoundTripTests : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
|
||||
public RoundTripTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(
|
||||
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||
|
||||
// Same db across all scopes — see BundleExporterTests for the rationale.
|
||||
var dbName = $"RoundTripTests_{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 Export_then_wipe_then_import_restores_state()
|
||||
{
|
||||
// ---- 1. Seed: 3 templates (one composing another + a standalone), 1
|
||||
// SharedScript referenced by template script body, 1
|
||||
// ExternalSystem referenced by template script body, 1
|
||||
// NotificationList. ----
|
||||
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"));
|
||||
ctx.NotificationLists.Add(new NotificationList("OnCall"));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var baseTemplate = new Template("BaseDevice") { Description = "shared base" };
|
||||
// Script body references HelperFn + ErpSystem so the resolver pulls
|
||||
// them in via the substring scan. We deliberately keep the body
|
||||
// free of method-call tokens (e.g. `.Call(`) so the importer's
|
||||
// identifier-scan doesn't flag well-known SDK methods as missing
|
||||
// SharedScript blockers.
|
||||
baseTemplate.Scripts.Add(new TemplateScript("init",
|
||||
"var x = HelperFn(); var sys = ErpSystem;"));
|
||||
ctx.Templates.Add(baseTemplate);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var composing = new Template("Pump") { Description = "composes BaseDevice" };
|
||||
ctx.Templates.Add(composing);
|
||||
await ctx.SaveChangesAsync();
|
||||
composing.Compositions.Add(new TemplateComposition("base")
|
||||
{
|
||||
TemplateId = composing.Id,
|
||||
ComposedTemplateId = baseTemplate.Id,
|
||||
});
|
||||
|
||||
ctx.Templates.Add(new Template("Standalone") { Description = "no deps" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ---- 2. Export everything as a single encrypted bundle. ----
|
||||
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 externalSystemIds = await ctx.ExternalSystemDefinitions.Select(x => x.Id).ToListAsync();
|
||||
var notificationListIds = await ctx.NotificationLists.Select(n => n.Id).ToListAsync();
|
||||
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: templateIds,
|
||||
SharedScriptIds: sharedScriptIds,
|
||||
ExternalSystemIds: externalSystemIds,
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: notificationListIds,
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
ApiKeyIds: Array.Empty<int>(),
|
||||
ApiMethodIds: Array.Empty<int>(),
|
||||
IncludeDependencies: true);
|
||||
|
||||
var stream = await exporter.ExportAsync(
|
||||
selection, user: "alice", sourceEnvironment: "dev",
|
||||
passphrase: "secret", cancellationToken: CancellationToken.None);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms);
|
||||
bundleBytes = ms.ToArray();
|
||||
}
|
||||
Assert.NotEmpty(bundleBytes);
|
||||
|
||||
// Read manifest from the bundle so we can pin the ContentHash that
|
||||
// the BundleImported audit row should echo back inside its
|
||||
// AfterStateJson — that's the round-trip identity check.
|
||||
string manifestContentHash;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
|
||||
using var ms = new MemoryStream(bundleBytes, writable: false);
|
||||
var manifest = serializer.ReadManifest(ms);
|
||||
manifestContentHash = manifest.ContentHash;
|
||||
}
|
||||
Assert.False(string.IsNullOrEmpty(manifestContentHash));
|
||||
|
||||
// ---- 3. Wipe — delete every seeded entity AND the audit rows the
|
||||
// export wrote so the post-import assertion can scan the
|
||||
// whole table unambiguously. ----
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
|
||||
ctx.TemplateCompositions.RemoveRange(ctx.TemplateCompositions);
|
||||
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
|
||||
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
|
||||
ctx.ExternalSystemDefinitions.RemoveRange(ctx.ExternalSystemDefinitions);
|
||||
ctx.NotificationRecipients.RemoveRange(ctx.NotificationRecipients);
|
||||
ctx.NotificationLists.RemoveRange(ctx.NotificationLists);
|
||||
ctx.AuditLogEntries.RemoveRange(ctx.AuditLogEntries);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ---- 4. Load the bundle → session. ----
|
||||
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: "secret");
|
||||
sessionId = session.SessionId;
|
||||
}
|
||||
|
||||
// ---- 5. Preview — every item must be ConflictKind.New now that the
|
||||
// target is empty. ----
|
||||
ImportPreview preview;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
preview = await importer.PreviewAsync(sessionId);
|
||||
}
|
||||
Assert.NotEmpty(preview.Items);
|
||||
Assert.All(preview.Items, item =>
|
||||
Assert.Equal(ConflictKind.New, item.Kind));
|
||||
|
||||
// ---- 6. Build resolutions: every item gets Add. ----
|
||||
var resolutions = preview.Items
|
||||
.Select(it => new ImportResolution(it.EntityType, it.Name, ResolutionAction.Add, null))
|
||||
.ToList();
|
||||
|
||||
// ---- 7. Apply. ----
|
||||
ImportResult result;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
result = await importer.ApplyAsync(sessionId, resolutions, user: "bob");
|
||||
}
|
||||
|
||||
// ---- 8. Assertions ----
|
||||
await using (var assertScope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = assertScope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
|
||||
// 8a. Original entities are all back. Re-query by name and spot-check
|
||||
// the surviving key fields.
|
||||
var baseDevice = await ctx.Templates
|
||||
.Include(t => t.Scripts)
|
||||
.SingleAsync(t => t.Name == "BaseDevice");
|
||||
Assert.Equal("shared base", baseDevice.Description);
|
||||
Assert.Single(baseDevice.Scripts);
|
||||
Assert.Equal("init", baseDevice.Scripts.First().Name);
|
||||
|
||||
var pump = await ctx.Templates
|
||||
.Include(t => t.Compositions)
|
||||
.SingleAsync(t => t.Name == "Pump");
|
||||
Assert.Equal("composes BaseDevice", pump.Description);
|
||||
// Composition edges intentionally NOT restored by the v1 import
|
||||
// (see BundleImporter.BuildTemplate XML comment). Round-trip's
|
||||
// field-level identity holds; rebuilding the composition graph
|
||||
// belongs to a follow-up task.
|
||||
|
||||
var standalone = await ctx.Templates.SingleAsync(t => t.Name == "Standalone");
|
||||
Assert.Equal("no deps", standalone.Description);
|
||||
|
||||
var helper = await ctx.SharedScripts.SingleAsync(s => s.Name == "HelperFn");
|
||||
Assert.Equal("return 1;", helper.Code);
|
||||
|
||||
var erp = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "ErpSystem");
|
||||
Assert.Equal("https://erp.example", erp.EndpointUrl);
|
||||
|
||||
var onCall = await ctx.NotificationLists.SingleAsync(n => n.Name == "OnCall");
|
||||
Assert.NotNull(onCall);
|
||||
|
||||
// 8b. Exactly one BundleExported audit row (user=alice) survived the
|
||||
// wipe? No — we deliberately wiped audit rows along with the
|
||||
// entities. The export-side audit row was written BEFORE the
|
||||
// wipe. Post-import there should therefore be zero
|
||||
// BundleExported rows from before, plus none from the import
|
||||
// (the importer doesn't emit BundleExported). The round-trip's
|
||||
// export-side identity therefore lives ONLY in the bundle bytes
|
||||
// we captured — manifestContentHash is the export's hash, and
|
||||
// the BundleImported row's AfterStateJson echoes it back below.
|
||||
// So this assertion targets the BundleImported side only.
|
||||
|
||||
var imported = await ctx.AuditLogEntries
|
||||
.SingleAsync(a => a.Action == "BundleImported");
|
||||
Assert.Equal("bob", imported.User);
|
||||
Assert.Equal("Bundle", imported.EntityType);
|
||||
Assert.Equal(result.BundleImportId, imported.BundleImportId);
|
||||
Assert.NotNull(imported.AfterStateJson);
|
||||
// Round-trip identity: the import row carries the same ContentHash
|
||||
// we read from the bundle bytes above. That's the proof that the
|
||||
// export's bundle is exactly what the import consumed.
|
||||
Assert.Contains(manifestContentHash, imported.AfterStateJson!, StringComparison.Ordinal);
|
||||
|
||||
// 8c. Every per-entity audit row written during the import run
|
||||
// carries result.BundleImportId — that's the correlation
|
||||
// contract from T5/T17 applied end-to-end here.
|
||||
var importRows = await ctx.AuditLogEntries
|
||||
.Where(a => a.BundleImportId == result.BundleImportId)
|
||||
.ToListAsync();
|
||||
Assert.NotEmpty(importRows);
|
||||
Assert.All(importRows, row =>
|
||||
Assert.Equal(result.BundleImportId, row.BundleImportId));
|
||||
// We seeded 3 templates + 1 shared script + 1 external system + 1
|
||||
// notification list = 6 created artifacts, plus the BundleImported
|
||||
// summary row = at least 7 stamped rows.
|
||||
Assert.True(importRows.Count >= 7,
|
||||
$"Expected at least 7 BundleImportId-stamped rows; got {importRows.Count}.");
|
||||
}
|
||||
|
||||
// 8d. ImportResult counts line up with what we seeded.
|
||||
Assert.Equal(6, result.Added);
|
||||
Assert.Equal(0, result.Overwritten);
|
||||
Assert.Equal(0, result.Skipped);
|
||||
Assert.NotEqual(Guid.Empty, result.BundleImportId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user