using Microsoft.AspNetCore.DataProtection; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; 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.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.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import; /// /// Regression guard for the create-missing site/connection apply path on a REAL /// relational provider (SQLite). Every other importer integration test runs on the /// EF in-memory provider, which assigns surrogate ids eagerly on AddAsync — /// masking that a create-missing Site still has Id == 0 until the /// context is flushed. ApplyDataConnectionsAsync stamps /// DataConnection.SiteId as a raw scalar (no EF navigation, so no fix-up), /// so without a flush between the site pass and the connection pass the insert /// fails the FK on a relational provider (live failure 2026-08-01: SQL Server /// error 547, FK_DataConnections_Sites_SiteId, on a cross-environment /// bundle import into an empty target). /// public sealed class CreateMissingSiteRelationalTests : IDisposable { private readonly SqliteConnection _connection; private readonly ServiceProvider _provider; public CreateMissingSiteRelationalTests() { // One open in-memory SQLite connection for the fixture's lifetime — the // database lives exactly as long as the connection. _connection = new SqliteConnection("DataSource=:memory:"); _connection.Open(); var services = new ServiceCollection(); services.AddSingleton( new ConfigurationBuilder().AddInMemoryCollection().Build()); services.AddDbContext(opts => opts.UseSqlite(_connection)); // Secret-bearing columns require the encrypting two-arg ctor — same wiring // as RoundTripTests / BundleImporterRollbackFailureTests. services.AddSingleton(new EphemeralDataProtectionProvider()); services.AddScoped(sp => new ScadaBridgeDbContext( sp.GetRequiredService>(), sp.GetRequiredService())); services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddTransport(); _provider = services.BuildServiceProvider(); using var scope = _provider.CreateScope(); scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); } public void Dispose() { _provider.Dispose(); _connection.Dispose(); } [Fact] public async Task Import_create_missing_site_and_connection_wires_the_materialised_site_id() { // ---- Seed a source site + connection, export the site closure. ---- await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var site = new Site("Plant 1", "plant-1") { Description = "source 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(); ctx.DataConnections.Add(new DataConnection("OpcUaPrimary", "OpcUa", site.Id) { PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}", FailoverRetryCount = 5, }); await ctx.SaveChangesAsync(); } Guid sessionId; await using (var scope = _provider.CreateAsyncScope()) { var exporter = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync(); var bundleStream = await exporter.ExportAsync( new ExportSelection( TemplateIds: Array.Empty(), SharedScriptIds: Array.Empty(), ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiMethodIds: Array.Empty(), IncludeDependencies: true, SiteIds: siteIds), user: "alice", sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None); using var ms = new MemoryStream(); await bundleStream.CopyToAsync(ms); ms.Position = 0; var importer = scope.ServiceProvider.GetRequiredService(); var session = await importer.LoadAsync(ms, passphrase: null); sessionId = session.SessionId; } // ---- Wipe the target so the apply exercises CreateNew on both. ---- await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.DataConnections.RemoveRange(ctx.DataConnections); ctx.Sites.RemoveRange(ctx.Sites); await ctx.SaveChangesAsync(); } // ---- Apply with create-missing mappings (the CLI's --create-missing-*). ---- await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); var nameMap = new BundleNameMap( Sites: [new SiteMapping("plant-1", MappingAction.CreateNew, null)], Connections: [new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null)]); await importer.ApplyAsync( sessionId, Array.Empty(), user: "bob", ct: CancellationToken.None, nameMap: nameMap); } // ---- The created connection must reference the created site's REAL id. ---- await using (var verify = _provider.CreateAsyncScope()) { var ctx = verify.ServiceProvider.GetRequiredService(); var site = Assert.Single(await ctx.Sites.ToListAsync()); var conn = Assert.Single(await ctx.DataConnections.ToListAsync()); Assert.True(site.Id > 0, "created site id was never materialised"); Assert.Equal(site.Id, conn.SiteId); } } }