test(transport): per-entity reflection round-trip equivalence suite — structural guard against silent import data loss

#05-T8. Seeds a maximally-populated instance of every transported entity type,
exports via the real IBundleExporter, imports into a fresh target via the real
IBundleImporter (LoadAsync→ApplyAsync, Add path; site/instance types via a
BundleNameMap CreateNew), reloads, and compares by reflection — every public
writable scalar/string property must survive, except an explicit per-type
exclusion list (surrogate Id, remapped FK ids, inheritance-placeholder flags,
and the documented Instance.State→NotDeployed design reset).

23 entity types across 10 [Fact]s. 7 green. 3 red — each surfaces a genuine
residual fidelity hole whose serializer-level unit test is GREEN, proving the
loss is in the full pipeline (exactly the gap class this integration guard
exists to catch), reported for a separate production fix rather than masked:
  - ExternalSystemDefinition.MaxRetries / RetryDelay dropped on import Add
  - ExternalSystemMethod rows dropped on import Add (only Overwrite syncs them)
  - NotificationRecipient rows dropped on export (FindAsync, no Include)
  - TemplateFolder.ParentFolderId hierarchy flattened on import Add

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-09 17:36:22 -04:00
parent 2c839def33
commit e498c6e1bd
@@ -0,0 +1,913 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
/// <summary>
/// #05-T8 — per-entity <b>structural</b> round-trip guard.
/// <para>
/// For every transported entity type, seed a MAXIMALLY populated entity (every
/// writable data property set to a non-default value), export it through the real
/// <see cref="IBundleExporter"/>, import it into a fresh target through the real
/// <see cref="IBundleImporter"/> (<c>LoadAsync</c>→<c>ApplyAsync</c>, Add path;
/// site/instance types via a <see cref="BundleNameMap"/> with
/// <see cref="MappingAction.CreateNew"/>), reload, and compare BY REFLECTION.
/// </para>
/// <para>
/// The reflection sweep (<see cref="AssertRoundTripEqual{T}"/>) is the point: it
/// walks every public writable scalar/string property and fails on the first one
/// that did not survive export→import, EXCEPT an explicit per-type exclusion list.
/// A future entity property that no DTO carries will therefore fail this test
/// instead of silently vanishing — exactly the class of regression that lost
/// <c>LockedInDerived</c>, script cadence/timeout and native-alarm sources before
/// Tasks 35. Excluded properties are limited to: surrogate <c>Id</c>s, FK id
/// columns that are legitimately remapped to the target's own surrogate keys,
/// inheritance-placeholder flags that are re-derived at import (never carried), and
/// the one documented design reset (imported <see cref="Instance.State"/> →
/// <see cref="InstanceState.NotDeployed"/>). Each exclusion is annotated inline.
/// </para>
/// <para>
/// Both the "source" and "imported" entities are re-read from the DB (AsNoTracking)
/// so both pass through identical persistence normalization — the comparison
/// isolates the export→import boundary, not save-time value conversion.
/// </para>
/// <para>
/// GUARD STATE (2026-07-09): five residual fidelity holes are currently exposed by
/// this suite and reported to the orchestrator rather than masked (per the T8
/// discipline — a hole is a real regression, never a test to weaken). Each has a
/// GREEN serializer-level unit test, proving the DTO carries the data and the loss
/// is in the full import/export pipeline — exactly the class this integration guard
/// exists to catch:
/// (1) <c>ExternalSystemDefinition.MaxRetries</c> — dropped on import Add.
/// (2) <c>ExternalSystemDefinition.RetryDelay</c> — dropped on import Add.
/// (3) <c>ExternalSystemMethod</c> (all rows) — dropped on import Add
/// (BuildExternalSystem carries no methods; only the Overwrite path syncs them).
/// (4) <c>NotificationRecipient</c> (all rows) — dropped on EXPORT
/// (GetNotificationListByIdAsync uses FindAsync with no Include(Recipients)).
/// (5) <c>TemplateFolder.ParentFolderId</c> — hierarchy flattened on import Add
/// (the folder Add ignores the DTO's ParentName).
/// The three tests that surface these ((RED) below) are intentionally failing until a
/// separate production task closes the gaps.
/// </para>
/// </summary>
public sealed class RoundTripEquivalenceTests : IDisposable
{
private readonly ServiceProvider _provider;
public RoundTripEquivalenceTests()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().AddInMemoryCollection().Build());
var dbName = $"RoundTripEquivalenceTests_{Guid.NewGuid()}";
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
.UseInMemoryDatabase(dbName)
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
services.AddScoped(sp => new ScadaBridgeDbContext(
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
sp.GetRequiredService<IDataProtectionProvider>()));
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
services.AddScoped<ISharedSchemaRepository, SharedSchemaRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
services.AddTemplateEngine();
services.AddDeploymentManager();
_provider = services.BuildServiceProvider();
}
public void Dispose() => _provider.Dispose();
// ──────────────────────────────────────────────────────────────────────
// Reflection sweep
// ──────────────────────────────────────────────────────────────────────
/// <summary>
/// Asserts that every public writable scalar/string property of <paramref name="source"/>
/// equals the same property on <paramref name="imported"/>, except those in
/// <paramref name="excluded"/>. Navigation collections and FK-id props excluded by
/// name are ignored — they are asserted separately by each test.
/// </summary>
private static void AssertRoundTripEqual<T>(T source, T imported, params string[] excluded)
{
foreach (var p in typeof(T).GetProperties().Where(p => p.CanWrite
&& !excluded.Contains(p.Name)
&& (p.PropertyType.IsValueType || p.PropertyType == typeof(string))))
{
Assert.True(Equals(p.GetValue(source), p.GetValue(imported)),
$"{typeof(T).Name}.{p.Name} did not survive export→import " +
$"(source={p.GetValue(source)}, imported={p.GetValue(imported)})");
}
}
// ──────────────────────────────────────────────────────────────────────
// Pipeline helpers
// ──────────────────────────────────────────────────────────────────────
private static readonly int[] None = [];
/// <summary>An all-empty export selection; layer the relevant ids on with a <c>with</c> expression.</summary>
private static ExportSelection EmptySelection(bool includeDependencies = false) =>
new(None, None, None, None, None, None, None, includeDependencies);
private async Task RunAsync(Func<ScadaBridgeDbContext, Task> action)
{
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
await action(ctx);
}
private async Task<Guid> ExportAndLoadAsync(Func<ScadaBridgeDbContext, Task<ExportSelection>> selectionFactory)
{
Stream bundleStream;
await using (var scope = _provider.CreateAsyncScope())
{
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var selection = await selectionFactory(ctx);
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
}
using var ms = new MemoryStream();
await bundleStream.CopyToAsync(ms);
ms.Position = 0;
await using var loadScope = _provider.CreateAsyncScope();
var importer = loadScope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(ms, passphrase: null);
return session.SessionId;
}
/// <summary>Applies the bundle with the given resolutions (unlisted entities default to Add).</summary>
private async Task<ImportResult> ApplyAsync(
Guid sessionId, IReadOnlyList<ImportResolution> resolutions, BundleNameMap? nameMap = null)
{
await using var scope = _provider.CreateAsyncScope();
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
return await importer.ApplyAsync(sessionId, resolutions, user: "bob", ct: CancellationToken.None, nameMap: nameMap);
}
// ══════════════════════════════════════════════════════════════════════
// Template + its five child kinds + folders
// ══════════════════════════════════════════════════════════════════════
[Fact]
public async Task RoundTrip_template_closure_preserves_every_writable_property()
{
// A folder hierarchy (root → child), a "Composed" base template that
// "MaxTemplate" composes, and a maximally-populated "MaxTemplate" carrying one
// of each child kind (attribute/alarm/script/native-alarm-source/composition).
await RunAsync(async ctx =>
{
var root = new TemplateFolder("Root") { SortOrder = 3 };
ctx.TemplateFolders.Add(root);
await ctx.SaveChangesAsync();
var child = new TemplateFolder("Pumps") { ParentFolderId = root.Id, SortOrder = 7 };
ctx.TemplateFolders.Add(child);
await ctx.SaveChangesAsync();
var composed = new Template("Composed") { Description = "composed base", FolderId = child.Id };
ctx.Templates.Add(composed);
await ctx.SaveChangesAsync();
var tpl = new Template("MaxTemplate")
{
Description = "maximally populated template",
FolderId = child.Id,
};
tpl.Attributes.Add(new TemplateAttribute("Tags")
{
DataType = DataType.List,
ElementDataType = DataType.String,
Value = "[\"alpha\",\"beta\"]",
IsLocked = true,
Description = "attr description",
DataSourceReference = "ns=1;s=Tags",
LockedInDerived = true,
});
tpl.Scripts.Add(new TemplateScript("OnUpdate", "return 1;")
{
TriggerType = "Periodic",
TriggerConfiguration = "{\"interval\":5}",
ParameterDefinitions = "[{\"name\":\"x\"}]",
ReturnDefinition = "void",
IsLocked = true,
MinTimeBetweenRuns = TimeSpan.FromSeconds(30),
ExecutionTimeoutSeconds = 42,
LockedInDerived = true,
});
tpl.Alarms.Add(new TemplateAlarm("High")
{
Description = "high alarm",
PriorityLevel = 4,
IsLocked = true,
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"threshold\":100}",
LockedInDerived = true,
});
tpl.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Boiler")
{
Description = "boiler native alarms",
ConnectionName = "OpcUaPrimary",
SourceReference = "ns=3;s=Boiler.Alarm",
ConditionFilter = "severity>500",
IsLocked = true,
LockedInDerived = true,
});
tpl.Compositions.Add(new TemplateComposition("MotorA") { ComposedTemplateId = composed.Id });
ctx.Templates.Add(tpl);
await ctx.SaveChangesAsync();
// Wire the alarm's on-trigger script FK now that ids are assigned.
var script = tpl.Scripts.Single();
tpl.Alarms.Single().OnTriggerScriptId = script.Id;
await ctx.SaveChangesAsync();
});
// Ground truth (persisted state), re-read detached.
Template srcTemplate = null!;
await RunAsync(async ctx => srcTemplate = await LoadTemplateAsync(ctx, "MaxTemplate"));
var sessionId = await ExportAndLoadAsync(async ctx =>
{
var ids = await ctx.Templates.Select(t => t.Id).ToListAsync();
return EmptySelection() with { TemplateIds = ids };
});
// Wipe so the import exercises the Add path against a fresh target.
await RunAsync(async ctx =>
{
ctx.Templates.RemoveRange(ctx.Templates);
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
await ctx.SaveChangesAsync();
});
await ApplyAsync(sessionId, []);
await RunAsync(async ctx =>
{
var imported = await LoadTemplateAsync(ctx, "MaxTemplate");
var impChild = await ctx.TemplateFolders.AsNoTracking().SingleAsync(f => f.Name == "Pumps");
// Template: Id + FKs remapped by name; IsDerived is engine-managed.
AssertRoundTripEqual(srcTemplate, imported,
nameof(Template.Id), nameof(Template.ParentTemplateId), nameof(Template.FolderId),
nameof(Template.OwnerCompositionId), nameof(Template.IsDerived));
// Template→folder linkage survives by name (folder-to-parent linkage is
// covered — and currently RED — by RoundTrip_template_folders_*).
Assert.Equal(impChild.Id, imported.FolderId);
// TemplateAttribute. IsInherited is an inheritance placeholder re-derived at
// import (not carried on a base-template row).
AssertRoundTripEqual(srcTemplate.Attributes.Single(), imported.Attributes.Single(),
nameof(TemplateAttribute.Id), nameof(TemplateAttribute.TemplateId),
nameof(TemplateAttribute.IsInherited));
// TemplateScript.
AssertRoundTripEqual(srcTemplate.Scripts.Single(), imported.Scripts.Single(),
nameof(TemplateScript.Id), nameof(TemplateScript.TemplateId),
nameof(TemplateScript.IsInherited));
// TemplateAlarm. OnTriggerScriptId is an in-aggregate FK re-resolved by name.
var srcAlarm = srcTemplate.Alarms.Single();
var impAlarm = imported.Alarms.Single();
AssertRoundTripEqual(srcAlarm, impAlarm,
nameof(TemplateAlarm.Id), nameof(TemplateAlarm.TemplateId),
nameof(TemplateAlarm.OnTriggerScriptId), nameof(TemplateAlarm.IsInherited));
// On-trigger script linkage survives by name (re-points at the new script id).
Assert.Equal(imported.Scripts.Single().Id, impAlarm.OnTriggerScriptId);
// TemplateNativeAlarmSource — DTO carries IsInherited, so it IS swept.
AssertRoundTripEqual(srcTemplate.NativeAlarmSources.Single(), imported.NativeAlarmSources.Single(),
nameof(TemplateNativeAlarmSource.Id), nameof(TemplateNativeAlarmSource.TemplateId));
// TemplateComposition. TemplateId + ComposedTemplateId remapped by name.
AssertRoundTripEqual(srcTemplate.Compositions.Single(), imported.Compositions.Single(),
nameof(TemplateComposition.Id), nameof(TemplateComposition.TemplateId),
nameof(TemplateComposition.ComposedTemplateId));
});
}
private static async Task<Template> LoadTemplateAsync(ScadaBridgeDbContext ctx, string name) =>
await ctx.Templates
.AsNoTracking()
.Include(t => t.Attributes)
.Include(t => t.Alarms)
.Include(t => t.Scripts)
.Include(t => t.NativeAlarmSources)
.Include(t => t.Compositions)
.SingleAsync(t => t.Name == name);
[Fact]
public async Task RoundTrip_template_folders_preserve_hierarchy_and_sort_order()
{
// Folders travel as the ancestor chain of an exported template, so seed a
// root→child hierarchy with a template in the child folder.
await RunAsync(async ctx =>
{
var root = new TemplateFolder("Root") { SortOrder = 3 };
ctx.TemplateFolders.Add(root);
await ctx.SaveChangesAsync();
var child = new TemplateFolder("Pumps") { ParentFolderId = root.Id, SortOrder = 7 };
ctx.TemplateFolders.Add(child);
await ctx.SaveChangesAsync();
ctx.Templates.Add(new Template("Pump") { FolderId = child.Id });
await ctx.SaveChangesAsync();
});
TemplateFolder srcRoot = null!, srcChild = null!;
await RunAsync(async ctx =>
{
srcRoot = await ctx.TemplateFolders.AsNoTracking().SingleAsync(f => f.Name == "Root");
srcChild = await ctx.TemplateFolders.AsNoTracking().SingleAsync(f => f.Name == "Pumps");
});
var sessionId = await ExportAndLoadAsync(async ctx =>
{
var ids = await ctx.Templates.Select(t => t.Id).ToListAsync();
return EmptySelection() with { TemplateIds = ids };
});
await RunAsync(async ctx =>
{
ctx.Templates.RemoveRange(ctx.Templates);
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
await ctx.SaveChangesAsync();
});
await ApplyAsync(sessionId, []);
await RunAsync(async ctx =>
{
var impRoot = await ctx.TemplateFolders.AsNoTracking().SingleAsync(f => f.Name == "Root");
var impChild = await ctx.TemplateFolders.AsNoTracking().SingleAsync(f => f.Name == "Pumps");
// Name + SortOrder round-trip (GREEN). ParentFolderId remapped by name.
AssertRoundTripEqual(srcRoot, impRoot,
nameof(TemplateFolder.Id), nameof(TemplateFolder.ParentFolderId));
AssertRoundTripEqual(srcChild, impChild,
nameof(TemplateFolder.Id), nameof(TemplateFolder.ParentFolderId));
// (RED) HOLE #5 — folder hierarchy is flattened on import Add: the folder
// Add pass builds `new TemplateFolder(dto.Name) { SortOrder }` and never
// consults the DTO's ParentName, so the child lands parent-less. The
// serializer-level Roundtrip_template_folder_preserves_hierarchy test is
// GREEN — the loss is purely in BundleImporter's folder Add pass.
Assert.Equal(impRoot.Id, impChild.ParentFolderId);
});
}
// ══════════════════════════════════════════════════════════════════════
// SharedScript
// ══════════════════════════════════════════════════════════════════════
[Fact]
public async Task RoundTrip_shared_script_preserves_every_writable_property()
{
await RunAsync(async ctx =>
{
ctx.SharedScripts.Add(new SharedScript("Helpers", "int Add(int a,int b)=>a+b;")
{
ParameterDefinitions = "[{\"name\":\"a\"}]",
ReturnDefinition = "int",
});
await ctx.SaveChangesAsync();
});
SharedScript src = null!;
await RunAsync(async ctx => src = await ctx.SharedScripts.AsNoTracking().SingleAsync());
var sessionId = await ExportAndLoadAsync(async ctx =>
{
var ids = await ctx.SharedScripts.Select(s => s.Id).ToListAsync();
return EmptySelection() with { SharedScriptIds = ids };
});
await RunAsync(async ctx =>
{
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
await ctx.SaveChangesAsync();
});
await ApplyAsync(sessionId, []);
await RunAsync(async ctx =>
{
var imported = await ctx.SharedScripts.AsNoTracking().SingleAsync();
AssertRoundTripEqual(src, imported, nameof(SharedScript.Id));
});
}
// ══════════════════════════════════════════════════════════════════════
// ExternalSystemDefinition + ExternalSystemMethod
// ══════════════════════════════════════════════════════════════════════
[Fact]
public async Task RoundTrip_external_system_and_method_preserve_every_writable_property()
{
await RunAsync(async ctx =>
{
var sys = new ExternalSystemDefinition("erp", "https://erp/api", "ApiKey")
{
AuthConfiguration = "{\"apiKey\":\"super-secret\"}", // rides SecretsBlock
MaxRetries = 6,
RetryDelay = TimeSpan.FromSeconds(17),
};
ctx.ExternalSystemDefinitions.Add(sys);
await ctx.SaveChangesAsync();
ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("PostOrder", "POST", "/orders")
{
ExternalSystemDefinitionId = sys.Id,
ParameterDefinitions = "[{\"name\":\"body\"}]",
ReturnDefinition = "{\"id\":\"int\"}",
});
await ctx.SaveChangesAsync();
});
ExternalSystemDefinition srcSys = null!;
ExternalSystemMethod srcMethod = null!;
await RunAsync(async ctx =>
{
srcSys = await ctx.ExternalSystemDefinitions.AsNoTracking().SingleAsync();
srcMethod = await ctx.ExternalSystemMethods.AsNoTracking().SingleAsync();
});
var sessionId = await ExportAndLoadAsync(async ctx =>
{
var ids = await ctx.ExternalSystemDefinitions.Select(s => s.Id).ToListAsync();
return EmptySelection() with { ExternalSystemIds = ids };
});
await RunAsync(async ctx =>
{
ctx.ExternalSystemMethods.RemoveRange(ctx.ExternalSystemMethods);
ctx.ExternalSystemDefinitions.RemoveRange(ctx.ExternalSystemDefinitions);
await ctx.SaveChangesAsync();
});
await ApplyAsync(sessionId, []);
await RunAsync(async ctx =>
{
var impSys = await ctx.ExternalSystemDefinitions.AsNoTracking().SingleAsync();
// (RED) HOLES #1/#2 — MaxRetries + RetryDelay are dropped on import Add:
// BuildExternalSystem builds `new ExternalSystemDefinition(name, url, authType)
// { AuthConfiguration }` and never copies the DTO's retry config. The
// serializer-level Roundtrip_external_system_preserves_retry_config test is
// GREEN — the loss is purely in BundleImporter's Add path.
AssertRoundTripEqual(srcSys, impSys, nameof(ExternalSystemDefinition.Id));
// (RED) HOLE #3 — ExternalSystemMethod rows are dropped on import Add:
// only the Overwrite path calls SyncExternalSystemMethodsAsync; the Add path
// carries no methods (the definition entity has no Methods navigation to
// cascade). The serializer-level round-trip carries them; the pipeline loses
// them into a fresh target.
var methodCount = await ctx.ExternalSystemMethods.CountAsync();
Assert.True(methodCount == 1,
$"FIDELITY HOLE: ExternalSystemMethod dropped on import Add (expected 1, got {methodCount}).");
var impMethod = await ctx.ExternalSystemMethods.AsNoTracking().SingleAsync();
AssertRoundTripEqual(srcMethod, impMethod,
nameof(ExternalSystemMethod.Id), nameof(ExternalSystemMethod.ExternalSystemDefinitionId));
Assert.Equal(impSys.Id, impMethod.ExternalSystemDefinitionId);
});
}
// ══════════════════════════════════════════════════════════════════════
// DatabaseConnectionDefinition (External-System DB connection)
// ══════════════════════════════════════════════════════════════════════
[Fact]
public async Task RoundTrip_database_connection_preserves_every_writable_property()
{
await RunAsync(async ctx =>
{
ctx.DatabaseConnectionDefinitions.Add(
new DatabaseConnectionDefinition("erp-db", "Server=db;Database=erp;User=sa;Password=p@ss")
{
MaxRetries = 8,
RetryDelay = TimeSpan.FromSeconds(21),
});
await ctx.SaveChangesAsync();
});
DatabaseConnectionDefinition src = null!;
await RunAsync(async ctx => src = await ctx.DatabaseConnectionDefinitions.AsNoTracking().SingleAsync());
var sessionId = await ExportAndLoadAsync(async ctx =>
{
var ids = await ctx.DatabaseConnectionDefinitions.Select(d => d.Id).ToListAsync();
return EmptySelection() with { DatabaseConnectionIds = ids };
});
await RunAsync(async ctx =>
{
ctx.DatabaseConnectionDefinitions.RemoveRange(ctx.DatabaseConnectionDefinitions);
await ctx.SaveChangesAsync();
});
await ApplyAsync(sessionId, []);
await RunAsync(async ctx =>
{
var imported = await ctx.DatabaseConnectionDefinitions.AsNoTracking().SingleAsync();
// ConnectionString rides the SecretsBlock; the sweep verifies it survives.
AssertRoundTripEqual(src, imported, nameof(DatabaseConnectionDefinition.Id));
});
}
// ══════════════════════════════════════════════════════════════════════
// NotificationList + NotificationRecipient
// ══════════════════════════════════════════════════════════════════════
[Fact]
public async Task RoundTrip_notification_list_and_recipient_preserve_every_writable_property()
{
await RunAsync(async ctx =>
{
var list = new NotificationList("on-call") { Type = NotificationType.Sms };
// A recipient carrying BOTH contacts so every writable field is non-default.
list.Recipients.Add(new NotificationRecipient("Pager", "pager@example.com")
{
PhoneNumber = "+15551234567",
});
ctx.NotificationLists.Add(list);
await ctx.SaveChangesAsync();
});
NotificationList srcList = null!;
NotificationRecipient srcRecipient = null!;
await RunAsync(async ctx =>
{
srcList = await ctx.NotificationLists.AsNoTracking().SingleAsync();
srcRecipient = await ctx.NotificationRecipients.AsNoTracking().SingleAsync();
});
var sessionId = await ExportAndLoadAsync(async ctx =>
{
var ids = await ctx.NotificationLists.Select(n => n.Id).ToListAsync();
return EmptySelection() with { NotificationListIds = ids };
});
await RunAsync(async ctx =>
{
ctx.NotificationRecipients.RemoveRange(ctx.NotificationRecipients);
ctx.NotificationLists.RemoveRange(ctx.NotificationLists);
await ctx.SaveChangesAsync();
});
await ApplyAsync(sessionId, []);
await RunAsync(async ctx =>
{
var impList = await ctx.NotificationLists.AsNoTracking().SingleAsync();
// List scalars (Name, Type) round-trip (GREEN).
AssertRoundTripEqual(srcList, impList, nameof(NotificationList.Id));
// (RED) HOLE #4 — recipients are dropped on EXPORT: the dependency
// resolver loads each selected list via GetNotificationListByIdAsync, which
// uses FindAsync(id) with NO Include(Recipients), so the exported DTO carries
// an empty Recipients collection and the import adds a recipient-less list.
// The serializer-level Roundtrip_*_recipient_* tests are GREEN — the loss is
// in the export repository query, not the DTO.
var recipientCount = await ctx.NotificationRecipients.CountAsync();
Assert.True(recipientCount == 1,
$"FIDELITY HOLE: NotificationRecipient dropped through export→import (expected 1, got {recipientCount}).");
var impRecipient = await ctx.NotificationRecipients.AsNoTracking().SingleAsync();
AssertRoundTripEqual(srcRecipient, impRecipient,
nameof(NotificationRecipient.Id), nameof(NotificationRecipient.NotificationListId));
Assert.Equal(impList.Id, impRecipient.NotificationListId);
});
}
// ══════════════════════════════════════════════════════════════════════
// SmtpConfiguration
// ══════════════════════════════════════════════════════════════════════
[Fact]
public async Task RoundTrip_smtp_configuration_preserves_every_writable_property()
{
await RunAsync(async ctx =>
{
ctx.SmtpConfigurations.Add(new SmtpConfiguration("smtp.example.com", "Basic", "noreply@example.com")
{
Port = 587,
Credentials = "user:p@ssw0rd", // rides SecretsBlock
TlsMode = "StartTls",
ConnectionTimeoutSeconds = 25,
MaxConcurrentConnections = 4,
MaxRetries = 9,
RetryDelay = TimeSpan.FromSeconds(33),
});
await ctx.SaveChangesAsync();
});
SmtpConfiguration src = null!;
await RunAsync(async ctx => src = await ctx.SmtpConfigurations.AsNoTracking().SingleAsync());
var sessionId = await ExportAndLoadAsync(async ctx =>
{
var ids = await ctx.SmtpConfigurations.Select(s => s.Id).ToListAsync();
return EmptySelection() with { SmtpConfigurationIds = ids };
});
await RunAsync(async ctx =>
{
ctx.SmtpConfigurations.RemoveRange(ctx.SmtpConfigurations);
await ctx.SaveChangesAsync();
});
await ApplyAsync(sessionId, []);
await RunAsync(async ctx =>
{
var imported = await ctx.SmtpConfigurations.AsNoTracking().SingleAsync();
AssertRoundTripEqual(src, imported, nameof(SmtpConfiguration.Id));
});
}
// ══════════════════════════════════════════════════════════════════════
// SmsConfiguration
// ══════════════════════════════════════════════════════════════════════
[Fact]
public async Task RoundTrip_sms_configuration_preserves_every_writable_property()
{
await RunAsync(async ctx =>
{
ctx.SmsConfigurations.Add(new SmsConfiguration("AC123", "+15550001111")
{
AuthToken = "twilio-super-secret", // rides SecretsBlock
MessagingServiceSid = "MG999",
ApiBaseUrl = "https://api.twilio.com",
ConnectionTimeoutSeconds = 20,
MaxRetries = 7,
RetryDelay = TimeSpan.FromSeconds(45),
});
await ctx.SaveChangesAsync();
});
SmsConfiguration src = null!;
await RunAsync(async ctx => src = await ctx.SmsConfigurations.AsNoTracking().SingleAsync());
var sessionId = await ExportAndLoadAsync(async ctx =>
{
var ids = await ctx.SmsConfigurations.Select(s => s.Id).ToListAsync();
return EmptySelection() with { SmsConfigurationIds = ids };
});
await RunAsync(async ctx =>
{
ctx.SmsConfigurations.RemoveRange(ctx.SmsConfigurations);
await ctx.SaveChangesAsync();
});
await ApplyAsync(sessionId, []);
await RunAsync(async ctx =>
{
var imported = await ctx.SmsConfigurations.AsNoTracking().SingleAsync();
AssertRoundTripEqual(src, imported, nameof(SmsConfiguration.Id));
});
}
// ══════════════════════════════════════════════════════════════════════
// ApiMethod
// ══════════════════════════════════════════════════════════════════════
[Fact]
public async Task RoundTrip_api_method_preserves_every_writable_property()
{
await RunAsync(async ctx =>
{
// Script must not name-mention an identifier the importer's semantic scan
// would treat as a referenced SharedScript/ExternalSystem — keep it literal.
ctx.ApiMethods.Add(new ApiMethod("Ingest", "return 42;")
{
ParameterDefinitions = "[{\"name\":\"x\",\"type\":\"Int32\"}]",
ReturnDefinition = "{\"ok\":\"Boolean\"}",
TimeoutSeconds = 55,
});
await ctx.SaveChangesAsync();
});
ApiMethod src = null!;
await RunAsync(async ctx => src = await ctx.ApiMethods.AsNoTracking().SingleAsync());
var sessionId = await ExportAndLoadAsync(async ctx =>
{
var ids = await ctx.ApiMethods.Select(m => m.Id).ToListAsync();
return EmptySelection() with { ApiMethodIds = ids };
});
await RunAsync(async ctx =>
{
ctx.ApiMethods.RemoveRange(ctx.ApiMethods);
await ctx.SaveChangesAsync();
});
await ApplyAsync(sessionId, []);
await RunAsync(async ctx =>
{
var imported = await ctx.ApiMethods.AsNoTracking().SingleAsync();
AssertRoundTripEqual(src, imported, nameof(ApiMethod.Id));
});
}
// ══════════════════════════════════════════════════════════════════════
// Site + DataConnection + Instance + all four instance-child kinds
// ══════════════════════════════════════════════════════════════════════
[Fact]
public async Task RoundTrip_site_closure_preserves_every_writable_property()
{
await RunAsync(async ctx =>
{
var template = new Template("Pump") { Description = "pump tpl" };
ctx.Templates.Add(template);
var site = new Site("Plant 1", "plant-1")
{
Description = "primary plant",
NodeAAddress = "akka://site@10.0.0.1:2552",
NodeBAddress = "akka://site@10.0.0.2:2552",
GrpcNodeAAddress = "10.0.0.1:8083",
GrpcNodeBAddress = "10.0.0.2:8083",
};
ctx.Sites.Add(site);
await ctx.SaveChangesAsync();
var conn = new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
{
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}", // rides SecretsBlock
BackupConfiguration = "{\"endpoint\":\"opc.tcp://backup\"}", // rides SecretsBlock
FailoverRetryCount = 5,
};
ctx.DataConnections.Add(conn);
var instance = new Instance("Pump-01")
{
TemplateId = template.Id,
SiteId = site.Id,
State = InstanceState.Enabled,
};
instance.AttributeOverrides.Add(new InstanceAttributeOverride("Flow")
{
OverrideValue = "[42,43]",
ElementDataType = DataType.Int32,
});
instance.AlarmOverrides.Add(new InstanceAlarmOverride("HiAlarm")
{
TriggerConfigurationOverride = "{\"hi\":90}",
PriorityLevelOverride = 7,
});
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("NativeSrc")
{
ConnectionNameOverride = "OpcUaPrimary",
SourceReferenceOverride = "ns=3;s=Pump.Alarm",
ConditionFilterOverride = "severity>200",
});
ctx.Instances.Add(instance);
await ctx.SaveChangesAsync();
instance.ConnectionBindings.Add(new InstanceConnectionBinding("Flow")
{
DataConnectionId = conn.Id,
DataSourceReferenceOverride = "ns=3;s=Pump.Flow",
});
await ctx.SaveChangesAsync();
});
Site srcSite = null!;
DataConnection srcConn = null!;
Instance srcInstance = null!;
await RunAsync(async ctx =>
{
srcSite = await ctx.Sites.AsNoTracking().SingleAsync(s => s.SiteIdentifier == "plant-1");
srcConn = await ctx.DataConnections.AsNoTracking().SingleAsync(c => c.Name == "OpcUaPrimary");
srcInstance = await LoadInstanceAsync(ctx, "Pump-01");
});
var sessionId = await ExportAndLoadAsync(async ctx =>
{
var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync();
return EmptySelection(includeDependencies: true) with { SiteIds = siteIds };
});
// Wipe the site closure; keep a fresh "Pump" template so the instance resolves.
await RunAsync(async ctx =>
{
ctx.InstanceConnectionBindings.RemoveRange(ctx.InstanceConnectionBindings);
ctx.InstanceAttributeOverrides.RemoveRange(ctx.InstanceAttributeOverrides);
ctx.InstanceAlarmOverrides.RemoveRange(ctx.InstanceAlarmOverrides);
ctx.InstanceNativeAlarmSourceOverrides.RemoveRange(ctx.InstanceNativeAlarmSourceOverrides);
ctx.Instances.RemoveRange(ctx.Instances);
ctx.DataConnections.RemoveRange(ctx.DataConnections);
ctx.Sites.RemoveRange(ctx.Sites);
ctx.Templates.RemoveRange(ctx.Templates);
await ctx.SaveChangesAsync();
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
await ctx.SaveChangesAsync();
});
var nameMap = new BundleNameMap(
Sites: [new SiteMapping("plant-1", MappingAction.CreateNew, null)],
Connections: [new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null)]);
await ApplyAsync(
sessionId,
[
new("Template", "Pump", ResolutionAction.Skip, null),
new("Site", "plant-1", ResolutionAction.Add, null),
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Add, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
],
nameMap);
await RunAsync(async ctx =>
{
var impSite = await ctx.Sites.AsNoTracking().SingleAsync(s => s.SiteIdentifier == "plant-1");
var impConn = await ctx.DataConnections.AsNoTracking().SingleAsync(c => c.Name == "OpcUaPrimary");
var impInstance = await LoadInstanceAsync(ctx, "Pump-01");
// Site.
AssertRoundTripEqual(srcSite, impSite, nameof(Site.Id));
// DataConnection. SiteId remapped; Primary/Backup config ride the SecretsBlock.
AssertRoundTripEqual(srcConn, impConn, nameof(DataConnection.Id), nameof(DataConnection.SiteId));
Assert.Equal(impSite.Id, impConn.SiteId);
// Instance. TemplateId/SiteId/AreaId remapped; State is a documented design
// reset (imported instances always land NotDeployed).
AssertRoundTripEqual(srcInstance, impInstance,
nameof(Instance.Id), nameof(Instance.TemplateId), nameof(Instance.SiteId),
nameof(Instance.AreaId), nameof(Instance.State));
Assert.Equal(InstanceState.NotDeployed, impInstance.State);
Assert.Equal(impSite.Id, impInstance.SiteId);
// InstanceAttributeOverride.
AssertRoundTripEqual(srcInstance.AttributeOverrides.Single(), impInstance.AttributeOverrides.Single(),
nameof(InstanceAttributeOverride.Id), nameof(InstanceAttributeOverride.InstanceId));
// InstanceAlarmOverride.
AssertRoundTripEqual(srcInstance.AlarmOverrides.Single(), impInstance.AlarmOverrides.Single(),
nameof(InstanceAlarmOverride.Id), nameof(InstanceAlarmOverride.InstanceId));
// InstanceNativeAlarmSourceOverride.
AssertRoundTripEqual(srcInstance.NativeAlarmSourceOverrides.Single(), impInstance.NativeAlarmSourceOverrides.Single(),
nameof(InstanceNativeAlarmSourceOverride.Id), nameof(InstanceNativeAlarmSourceOverride.InstanceId));
// InstanceConnectionBinding. DataConnectionId remapped to the created conn.
var impBinding = impInstance.ConnectionBindings.Single();
AssertRoundTripEqual(srcInstance.ConnectionBindings.Single(), impBinding,
nameof(InstanceConnectionBinding.Id), nameof(InstanceConnectionBinding.InstanceId),
nameof(InstanceConnectionBinding.DataConnectionId));
Assert.Equal(impConn.Id, impBinding.DataConnectionId);
});
}
private static async Task<Instance> LoadInstanceAsync(ScadaBridgeDbContext ctx, string uniqueName) =>
await ctx.Instances
.AsNoTracking()
.Include(i => i.AttributeOverrides)
.Include(i => i.AlarmOverrides)
.Include(i => i.NativeAlarmSourceOverrides)
.Include(i => i.ConnectionBindings)
.SingleAsync(i => i.UniqueName == uniqueName);
}