Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripEquivalenceTests.cs
T
Joseph Doherty e498c6e1bd 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
2026-07-09 17:37:32 -04:00

914 lines
44 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}