e498c6e1bd
#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
914 lines
44 KiB
C#
914 lines
44 KiB
C#
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 3–5. 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);
|
||
}
|