diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripEquivalenceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripEquivalenceTests.cs
new file mode 100644
index 00000000..4b31bdf7
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripEquivalenceTests.cs
@@ -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;
+
+///
+/// #05-T8 — per-entity structural round-trip guard.
+///
+/// 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
+/// , import it into a fresh target through the real
+/// (LoadAsync→ApplyAsync, Add path;
+/// site/instance types via a with
+/// ), reload, and compare BY REFLECTION.
+///
+///
+/// The reflection sweep () 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
+/// LockedInDerived, script cadence/timeout and native-alarm sources before
+/// Tasks 3–5. Excluded properties are limited to: surrogate Ids, 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 →
+/// ). Each exclusion is annotated inline.
+///
+///
+/// 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.
+///
+///
+/// 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) ExternalSystemDefinition.MaxRetries — dropped on import Add.
+/// (2) ExternalSystemDefinition.RetryDelay — dropped on import Add.
+/// (3) ExternalSystemMethod (all rows) — dropped on import Add
+/// (BuildExternalSystem carries no methods; only the Overwrite path syncs them).
+/// (4) NotificationRecipient (all rows) — dropped on EXPORT
+/// (GetNotificationListByIdAsync uses FindAsync with no Include(Recipients)).
+/// (5) TemplateFolder.ParentFolderId — 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.
+///
+///
+public sealed class RoundTripEquivalenceTests : IDisposable
+{
+ private readonly ServiceProvider _provider;
+
+ public RoundTripEquivalenceTests()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(
+ new ConfigurationBuilder().AddInMemoryCollection().Build());
+
+ var dbName = $"RoundTripEquivalenceTests_{Guid.NewGuid()}";
+ services.AddDbContext(opts => opts
+ .UseInMemoryDatabase(dbName)
+ .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
+
+ services.AddSingleton(new EphemeralDataProtectionProvider());
+ services.AddScoped(sp => new ScadaBridgeDbContext(
+ sp.GetRequiredService>(),
+ sp.GetRequiredService()));
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddTransport();
+ services.AddTemplateEngine();
+ services.AddDeploymentManager();
+
+ _provider = services.BuildServiceProvider();
+ }
+
+ public void Dispose() => _provider.Dispose();
+
+ // ──────────────────────────────────────────────────────────────────────
+ // Reflection sweep
+ // ──────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Asserts that every public writable scalar/string property of
+ /// equals the same property on , except those in
+ /// . Navigation collections and FK-id props excluded by
+ /// name are ignored — they are asserted separately by each test.
+ ///
+ private static void AssertRoundTripEqual(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 = [];
+
+ /// An all-empty export selection; layer the relevant ids on with a with expression.
+ private static ExportSelection EmptySelection(bool includeDependencies = false) =>
+ new(None, None, None, None, None, None, None, includeDependencies);
+
+ private async Task RunAsync(Func action)
+ {
+ await using var scope = _provider.CreateAsyncScope();
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ await action(ctx);
+ }
+
+ private async Task ExportAndLoadAsync(Func> selectionFactory)
+ {
+ Stream bundleStream;
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var exporter = scope.ServiceProvider.GetRequiredService();
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ 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();
+ var session = await importer.LoadAsync(ms, passphrase: null);
+ return session.SessionId;
+ }
+
+ /// Applies the bundle with the given resolutions (unlisted entities default to Add).
+ private async Task ApplyAsync(
+ Guid sessionId, IReadOnlyList resolutions, BundleNameMap? nameMap = null)
+ {
+ await using var scope = _provider.CreateAsyncScope();
+ var importer = scope.ServiceProvider.GetRequiredService();
+ 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 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 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);
+}