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 +/// (LoadAsyncApplyAsync, 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