using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ScadaLink.Commons.Entities.ExternalSystems; using ScadaLink.Commons.Entities.Scripts; using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Interfaces.Transport; using ScadaLink.Commons.Types.Transport; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Repositories; using ScadaLink.ConfigurationDatabase.Services; using ScadaLink.Transport; namespace ScadaLink.Transport.IntegrationTests.Import; /// /// Integration tests for . /// Reuses the same in-memory host pattern as the exporter tests: real /// repositories, real EF in-memory provider, real Transport pipeline. Each test /// seeds the target DB, exports a bundle, then loads + previews it via the /// importer. /// public sealed class BundleImporterPreviewTests : IDisposable { private readonly ServiceProvider _provider; public BundleImporterPreviewTests() { var services = new ServiceCollection(); services.AddSingleton( new ConfigurationBuilder().AddInMemoryCollection().Build()); var dbName = $"BundleImporterPreviewTests_{Guid.NewGuid()}"; services.AddDbContext(opts => opts.UseInMemoryDatabase(dbName)); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddTransport(); _provider = services.BuildServiceProvider(); } public void Dispose() => _provider.Dispose(); private async Task ExportTemplatesAsync() { await using var scope = _provider.CreateAsyncScope(); var exporter = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); var ids = await ctx.Templates.Select(t => t.Id).ToListAsync(); var selection = new ExportSelection( TemplateIds: ids, SharedScriptIds: Array.Empty(), ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiKeyIds: Array.Empty(), ApiMethodIds: Array.Empty(), IncludeDependencies: false); return await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None); } private static async Task StreamToBytes(Stream s) { using var ms = new MemoryStream(); await s.CopyToAsync(ms); return ms.ToArray(); } [Fact] public async Task PreviewAsync_classifies_artifact_as_Identical_when_fields_match() { // Arrange: seed a template, export it, leave target unchanged. The // bundle's DTO is the literal projection of the target, so the diff // should classify it as Identical. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("Pump") { Description = "stable" }); await ctx.SaveChangesAsync(); } var bundleStream = await ExportTemplatesAsync(); var bytes = await StreamToBytes(bundleStream); // Act ImportPreview preview; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null); preview = await importer.PreviewAsync(session.SessionId); } // Assert var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump"); Assert.Equal(ConflictKind.Identical, pumpItem.Kind); Assert.Null(pumpItem.FieldDiffJson); } [Fact] public async Task PreviewAsync_classifies_artifact_as_Modified_with_field_diff() { // Arrange: seed a template with Description="new", export it, then // overwrite the target template's Description with "old". The bundle's // version differs from the target, so the diff should flag the // Description field. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("Pump") { Description = "new" }); await ctx.SaveChangesAsync(); } var bundleStream = await ExportTemplatesAsync(); var bytes = await StreamToBytes(bundleStream); // Mutate the target between export and preview so the diff has // something to report. The bundle still carries Description="new". await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump"); t.Description = "old"; await ctx.SaveChangesAsync(); } // Act ImportPreview preview; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null); preview = await importer.PreviewAsync(session.SessionId); } // Assert var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump"); Assert.Equal(ConflictKind.Modified, pumpItem.Kind); Assert.NotNull(pumpItem.FieldDiffJson); // The diff should mention the Description field by name. Assert.Contains("Description", pumpItem.FieldDiffJson!, StringComparison.Ordinal); } [Fact] public async Task PreviewAsync_classifies_artifact_as_New_when_absent_from_target() { // Arrange: seed a template, export it, then delete it from the target // database. The bundle still contains the template, so the diff should // classify it as New (target is now empty). await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("Pump") { Description = "to-be-deleted" }); await ctx.SaveChangesAsync(); } var bundleStream = await ExportTemplatesAsync(); var bytes = await StreamToBytes(bundleStream); await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = await ctx.Templates.SingleAsync(); ctx.Templates.Remove(t); await ctx.SaveChangesAsync(); } // Act ImportPreview preview; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null); preview = await importer.PreviewAsync(session.SessionId); } // Assert var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump"); Assert.Equal(ConflictKind.New, pumpItem.Kind); Assert.Null(pumpItem.FieldDiffJson); } [Fact] public async Task PreviewAsync_emits_Blocker_when_required_dependency_missing() { // Arrange: seed a template whose script body calls MissingHelper(), and // an unrelated HelperFn() shared script that *is* defined but isn't the // referenced one. We then export WITHOUT IncludeDependencies and use a // selection that only pulls the template — the bundle won't carry // MissingHelper (it doesn't exist anywhere) so the preview must flag it. // // To get MissingHelper into the bundle script body without the export // resolver pulling it in (it can't — it doesn't exist), we just seed // the template with a script that mentions it; the resolver scan only // matters for entity discovery, the body text is preserved verbatim. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;")); ctx.ExternalSystemDefinitions.Add(new ExternalSystemDefinition("ErpSystem", "https://erp.example", "ApiKey")); var t = new Template("Pump") { Description = "broken" }; t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();")); ctx.Templates.Add(t); await ctx.SaveChangesAsync(); } var bundleStream = await ExportTemplatesAsync(); var bytes = await StreamToBytes(bundleStream); // Wipe the SharedScripts table so MissingHelper has no chance of being // resolved in the target either. (HelperFn is intentionally seeded so // we can verify the blocker check is specific — it should NOT flag // HelperFn since it's in the target.) await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); // Keep HelperFn + ErpSystem so they're in the target's resolved set. // Just confirm via assertion that MissingHelper is the blocker name. await ctx.SaveChangesAsync(); } // Act ImportPreview preview; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null); preview = await importer.PreviewAsync(session.SessionId); } // Assert: there's at least one Blocker, and the MissingHelper one is in there. Assert.Contains(preview.Items, i => i.Kind == ConflictKind.Blocker); Assert.Contains(preview.Items, i => i.Kind == ConflictKind.Blocker && i.Name == "MissingHelper" && i.BlockerReason is not null && i.BlockerReason.Contains("MissingHelper", StringComparison.Ordinal)); // Conversely, HelperFn must NOT be a blocker — it's seeded in the target. Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker && i.Name == "HelperFn"); } [Fact] public async Task PreviewAsync_does_not_flag_opcua_tag_paths_in_DataSourceReference_as_blockers() { // Arrange: a template with an attribute whose DataSourceReference is an // OPC UA node-address path -- e.g. "ns=3;s=Tank.Level". The segment // before the dot ("Tank") used to be parsed by the blocker heuristic as // a potential SharedScript reference, even though tag paths live in the // device's address space and are not script-callable. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = new Template("Pump") { Description = "tag-path-check" }; t.Attributes.Add(new TemplateAttribute("Level") { Value = "0", DataSourceReference = "ns=3;s=Tank.Level", }); ctx.Templates.Add(t); await ctx.SaveChangesAsync(); } var bundleStream = await ExportTemplatesAsync(); var bytes = await StreamToBytes(bundleStream); // Act ImportPreview preview; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null); preview = await importer.PreviewAsync(session.SessionId); } // Assert: "Tank" (the device-owned tag-path root segment) must not be // flagged as a missing SharedScript or ExternalSystem reference. Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker && i.Name == "Tank"); } [Fact] public async Task PreviewAsync_does_not_flag_stdlib_or_runtime_member_accesses_as_blockers() { // Arrange: a template script that uses a representative mix of stdlib // calls, runtime-API roots, and member-access patterns. None of these // are user-defined SharedScripts or ExternalSystems and the previous // heuristic was flagging every one of them. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = new Template("Pump") { Description = "noise-check" }; t.Scripts.Add(new TemplateScript("init", """ var now = DateTimeOffset.UtcNow; var s = Convert.ToString(123); await Notify.Send("alerts", "msg"); var x = await Database.ExecuteScalarAsync("SELECT COUNT(*) FROM t"); var y = await ExternalSystem.Call("erp", "ping"); obj.Dispose(); """)); ctx.Templates.Add(t); await ctx.SaveChangesAsync(); } var bundleStream = await ExportTemplatesAsync(); var bytes = await StreamToBytes(bundleStream); // Act ImportPreview preview; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null); preview = await importer.PreviewAsync(session.SessionId); } // Assert: none of the well-known names produce blocker rows. string[] noiseNames = { "DateTimeOffset", "UtcNow", "Convert", "ToString", "Notify", "Send", "Database", "ExecuteScalarAsync", "COUNT", "ExternalSystem", "Call", "Dispose", }; foreach (var name in noiseNames) { Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker && i.Name == name); } } }