fix(transport): flush created-site ids before the connection pass — create-missing import failed FK 547 on real SQL Server
Caught live 2026-08-01 importing a bundle into the empty env2 cluster: a create-missing Site has Id == 0 until SaveChanges on a relational provider, and ApplyDataConnectionsAsync stamps DataConnection.SiteId as a raw scalar (no navigation, no EF fix-up), so the insert violated FK_DataConnections_Sites_SiteId. Every importer integration test runs on the EF in-memory provider, which assigns ids eagerly on AddAsync — the exact masking the in-code comment predicted. Fix: one SaveChangesAsync between the site pass and the connection pass, riding the same outer transaction (all-or-nothing preserved; the failed live import rolled back cleanly). Regression test runs the create-missing path on SQLite (CreateMissingSiteRelationalTests) — red without the fix, green with it.
This commit is contained in:
+160
@@ -0,0 +1,160 @@
|
||||
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;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>AddAsync</c> —
|
||||
/// masking that a create-missing <c>Site</c> still has <c>Id == 0</c> until the
|
||||
/// context is flushed. <c>ApplyDataConnectionsAsync</c> stamps
|
||||
/// <c>DataConnection.SiteId</c> 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, <c>FK_DataConnections_Sites_SiteId</c>, on a cross-environment
|
||||
/// bundle import into an empty target).
|
||||
/// </summary>
|
||||
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<IConfiguration>(
|
||||
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||
|
||||
services.AddDbContext<ScadaBridgeDbContext>(opts => opts.UseSqlite(_connection));
|
||||
|
||||
// Secret-bearing columns require the encrypting two-arg ctor — same wiring
|
||||
// as RoundTripTests / BundleImporterRollbackFailureTests.
|
||||
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
|
||||
services.AddScoped(sp => new ScadaBridgeDbContext(
|
||||
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
||||
sp.GetRequiredService<IDataProtectionProvider>()));
|
||||
|
||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||
services.AddScoped<ISiteRepository, SiteRepository>();
|
||||
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
services.AddTransport();
|
||||
|
||||
_provider = services.BuildServiceProvider();
|
||||
|
||||
using var scope = _provider.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>().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<ScadaBridgeDbContext>();
|
||||
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<IBundleExporter>();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync();
|
||||
var bundleStream = await exporter.ExportAsync(
|
||||
new ExportSelection(
|
||||
TemplateIds: Array.Empty<int>(),
|
||||
SharedScriptIds: Array.Empty<int>(),
|
||||
ExternalSystemIds: Array.Empty<int>(),
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
ApiMethodIds: Array.Empty<int>(),
|
||||
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<IBundleImporter>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<IBundleImporter>();
|
||||
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<ImportResolution>(), 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<ScadaBridgeDbContext>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user