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);
}
}
}