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