Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs
T

670 lines
34 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
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.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
/// <summary>
/// Integration tests for the M8 D1 site/instance-scoped apply path of
/// <see cref="ZB.MOM.WW.ScadaBridge.Transport.Import.BundleImporter.ApplyAsync"/>:
/// resolve-or-create target sites + data connections from a <see cref="BundleNameMap"/>,
/// upsert instances, and rewire every cross-environment FK (connection-binding
/// <c>DataConnectionId</c>, native-alarm-source <c>ConnectionNameOverride</c>) onto
/// the target's surrogate keys.
/// <para>
/// Reuses the in-memory host pattern from <c>BundleImporterApplyTests</c> /
/// <c>BundleImporterPreviewTests</c>: real repositories, real EF in-memory provider,
/// real Transport pipeline. Bundles are produced by the real exporter (site closure)
/// or hand-packed via <see cref="BundleSerializer"/> for negative cases.
/// </para>
/// </summary>
public sealed class SiteInstanceImportTests : IDisposable
{
private readonly ServiceProvider _provider;
public SiteInstanceImportTests()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().AddInMemoryCollection().Build());
var dbName = $"SiteInstanceImportTests_{Guid.NewGuid()}";
// Same in-memory caveat as BundleImporterApplyTests: ApplyAsync opens a
// transaction (no-op on in-memory) and defers the single real
// SaveChangesAsync to just before CommitAsync; intermediate flushes are
// undone on the catch path via ChangeTracker.Clear(). Downgrade the
// transaction-ignored warning so the in-memory run proceeds.
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
.UseInMemoryDatabase(dbName)
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
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();
}
public void Dispose() => _provider.Dispose();
// ──────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────
/// <summary>
/// Seeds a full site closure (template + site + site-scoped data connection +
/// instance bound to that connection, with an attribute override, an alarm
/// override and a native-alarm-source override whose ConnectionNameOverride
/// names the seeded connection) so the export carries every FK shape D1 must
/// rewire. The instance is seeded <see cref="InstanceState.Enabled"/> so the
/// import's NotDeployed reset is observable.
/// </summary>
private async Task SeedSiteClosureAsync(
string siteIdentifier = "plant-1",
string siteName = "Plant 1",
string connectionName = "OpcUaPrimary",
string templateName = "Pump",
string instanceName = "Pump-01")
{
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var template = new Template(templateName) { Description = "pump tpl" };
template.Attributes.Add(new TemplateAttribute("Flow") { Value = "0" });
ctx.Templates.Add(template);
var site = new Site(siteName, siteIdentifier)
{
Description = "primary 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();
var conn = new DataConnection(connectionName, "OpcUa", site.Id)
{
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}",
BackupConfiguration = "{\"endpoint\":\"opc.tcp://backup\"}",
FailoverRetryCount = 5,
};
ctx.DataConnections.Add(conn);
var instance = new Instance(instanceName)
{
TemplateId = template.Id,
SiteId = site.Id,
State = InstanceState.Enabled,
};
instance.AttributeOverrides.Add(new InstanceAttributeOverride("Flow") { OverrideValue = "42" });
instance.AlarmOverrides.Add(new InstanceAlarmOverride("HiAlarm") { PriorityLevelOverride = 7 });
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("NativeSrc")
{
ConnectionNameOverride = connectionName,
SourceReferenceOverride = "ns=3;s=Pump.Alarm",
});
ctx.Instances.Add(instance);
await ctx.SaveChangesAsync();
instance.ConnectionBindings.Add(new InstanceConnectionBinding("Flow")
{
DataConnectionId = conn.Id,
DataSourceReferenceOverride = "ns=3;s=Pump.Flow",
});
await ctx.SaveChangesAsync();
}
/// <summary>Exports every seeded site (and its instance/connection closure) into a bundle, then loads it.</summary>
private async Task<Guid> ExportAllSitesAndLoadAsync()
{
Stream bundleStream;
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 selection = 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);
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<IBundleImporter>();
var session = await importer.LoadAsync(ms, passphrase: null);
return session.SessionId;
}
/// <summary>Removes all site/instance-scoped rows so the import exercises the CreateNew path against a fresh target.</summary>
private async Task WipeSiteClosureAsync()
{
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.InstanceConnectionBindings.RemoveRange(ctx.InstanceConnectionBindings);
ctx.InstanceAttributeOverrides.RemoveRange(ctx.InstanceAttributeOverrides);
ctx.InstanceAlarmOverrides.RemoveRange(ctx.InstanceAlarmOverrides);
ctx.InstanceNativeAlarmSourceOverrides.RemoveRange(ctx.InstanceNativeAlarmSourceOverrides);
ctx.Instances.RemoveRange(ctx.Instances);
ctx.DataConnections.RemoveRange(ctx.DataConnections);
ctx.Areas.RemoveRange(ctx.Areas);
ctx.Sites.RemoveRange(ctx.Sites);
ctx.Templates.RemoveRange(ctx.Templates);
await ctx.SaveChangesAsync();
}
private async Task<ImportResult> ApplyAsync(
Guid sessionId, IReadOnlyList<ImportResolution> resolutions, BundleNameMap nameMap)
{
await using var scope = _provider.CreateAsyncScope();
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
return await importer.ApplyAsync(sessionId, resolutions, user: "bob", ct: CancellationToken.None, nameMap: nameMap);
}
/// <summary>
/// Hand-packs an arbitrary <see cref="BundleContentDto"/> into a real, loadable
/// plaintext bundle and opens a session. Lets negative-path tests carry an
/// instance whose TemplateName the export resolver would never emit (e.g. a
/// template absent from both bundle and target), so the instance pass' guard
/// can be exercised mid-transaction. Reuses the production manifest builder +
/// serializer for hash fidelity.
/// </summary>
private async Task<Guid> PackAndLoadAsync(BundleContentDto content)
{
await using var scope = _provider.CreateAsyncScope();
var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var summary = new BundleSummary(
Templates: content.Templates.Count,
TemplateFolders: content.TemplateFolders.Count,
SharedScripts: content.SharedScripts.Count,
ExternalSystems: content.ExternalSystems.Count,
DbConnections: content.DatabaseConnections.Count,
NotificationLists: content.NotificationLists.Count,
SmtpConfigs: content.SmtpConfigs.Count,
ApiMethods: content.ApiMethods.Count,
Sites: content.Sites.Count,
DataConnections: content.DataConnections.Count,
Instances: content.Instances.Count);
var manifest = manifestBuilder.Build(
sourceEnvironment: "dev",
exportedBy: "alice",
scadaBridgeVersion: "1.0.0",
encryption: null,
summary: summary,
contents: Array.Empty<ManifestContentEntry>(),
contentBytes: serializer.SerializeContentBytes(content));
await using var packed = serializer.Pack(content, manifest, passphrase: null, encryptor: null);
using var ms = new MemoryStream();
await packed.CopyToAsync(ms);
ms.Position = 0;
var session = await importer.LoadAsync(ms, passphrase: null);
return session.SessionId;
}
// ──────────────────────────────────────────────────────────────────────
// CreateNew into a fresh target
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task ApplyAsync_CreateNew_into_fresh_target_creates_site_connection_instance_with_rewired_FKs()
{
await SeedSiteClosureAsync();
var sessionId = await ExportAllSitesAndLoadAsync();
await WipeSiteClosureAsync();
// The template must exist for the instance to resolve its TemplateId.
// The wipe removed it, so re-create it (a real import that carries the
// template would Add it; here we isolate the site/instance path).
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
await ctx.SaveChangesAsync();
}
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null) });
var result = await ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Skip, null), // already present
new("Site", "plant-1", ResolutionAction.Add, null),
new("DataConnection", "OpcUaPrimary", ResolutionAction.Add, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
nameMap);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var site = await ctx.Sites.SingleAsync(s => s.SiteIdentifier == "plant-1");
Assert.Equal("Plant 1", site.Name);
// Full config carried (D3 "carry full config").
Assert.Equal("10.0.0.1:8083", site.GrpcNodeAAddress);
Assert.Equal("akka://site@10.0.0.2:2552", site.NodeBAddress);
var conn = await ctx.DataConnections.SingleAsync(c => c.Name == "OpcUaPrimary");
Assert.Equal(site.Id, conn.SiteId);
// Primary/Backup config restored from the bundle's secrets block.
Assert.Contains("opc.tcp://primary", conn.PrimaryConfiguration!);
Assert.Contains("opc.tcp://backup", conn.BackupConfiguration!);
Assert.Equal(5, conn.FailoverRetryCount);
var inst = await ctx.Instances
.Include(i => i.ConnectionBindings)
.Include(i => i.AttributeOverrides)
.Include(i => i.AlarmOverrides)
.Include(i => i.NativeAlarmSourceOverrides)
.SingleAsync(i => i.UniqueName == "Pump-01");
// Imported instances are design-time config — never carried as deployed.
Assert.Equal(InstanceState.NotDeployed, inst.State);
Assert.Equal(site.Id, inst.SiteId);
// FK rewire — binding points at the CREATED connection's surrogate id.
var binding = Assert.Single(inst.ConnectionBindings);
Assert.Equal(conn.Id, binding.DataConnectionId);
Assert.Equal("ns=3;s=Pump.Flow", binding.DataSourceReferenceOverride);
// Native-alarm override connection-name rewritten to the target name.
var native = Assert.Single(inst.NativeAlarmSourceOverrides);
Assert.Equal("OpcUaPrimary", native.ConnectionNameOverride);
// Override rows present.
Assert.Single(inst.AttributeOverrides);
Assert.Equal("42", inst.AttributeOverrides.Single().OverrideValue);
Assert.Single(inst.AlarmOverrides);
Assert.Equal(7, inst.AlarmOverrides.Single().PriorityLevelOverride);
}
// Counts: site + connection + instance added (template was Skipped).
Assert.Equal(3, result.Added);
Assert.Equal(1, result.Skipped);
// D2 has not run yet — StaleInstanceIds stays empty.
Assert.Empty(result.StaleInstanceIds);
}
// ──────────────────────────────────────────────────────────────────────
// MapToExisting into a populated target (FK remap to existing ids)
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task ApplyAsync_MapToExisting_remaps_binding_to_existing_target_connection_id()
{
// Source env: plant-1 / OpcUaPrimary. Build a bundle from it, then set up
// a DISTINCT target env that already has plant-1 + OpcUaPrimary with their
// OWN surrogate ids (achieved by wiping + reseeding so ids differ from the
// source's). The import must MapToExisting and rebind to the TARGET ids.
await SeedSiteClosureAsync();
var sessionId = await ExportAllSitesAndLoadAsync();
await WipeSiteClosureAsync();
// Re-seed the target with the SAME identifiers but fresh rows (no instance
// — the import brings it). Pad with throwaway rows first so the target's
// surrogate ids do not coincide with the source's by accident.
int targetConnId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// Throwaway site/connection to advance the id counters.
var pad = new Site("Pad", "pad-site");
ctx.Sites.Add(pad);
await ctx.SaveChangesAsync();
ctx.DataConnections.Add(new DataConnection("PadConn", "OpcUa", pad.Id));
await ctx.SaveChangesAsync();
ctx.Templates.Add(new Template("Pump") { Description = "target pump" });
var site = new Site("Plant 1 (target)", "plant-1");
ctx.Sites.Add(site);
await ctx.SaveChangesAsync();
var conn = new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
{
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://target-existing\"}",
};
ctx.DataConnections.Add(conn);
await ctx.SaveChangesAsync();
targetConnId = conn.Id;
}
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary") });
var result = await ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Skip, null),
new("Site", "plant-1", ResolutionAction.Skip, null), // leave target site untouched
new("DataConnection", "OpcUaPrimary", ResolutionAction.Skip, null), // leave target conn untouched
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
nameMap);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// Skip left the existing target connection's config untouched — NOT a
// new connection created from the bundle.
var conns = await ctx.DataConnections.Where(c => c.Name == "OpcUaPrimary").ToListAsync();
var conn = Assert.Single(conns);
Assert.Equal(targetConnId, conn.Id);
Assert.Contains("target-existing", conn.PrimaryConfiguration!);
var inst = await ctx.Instances
.Include(i => i.ConnectionBindings)
.Include(i => i.NativeAlarmSourceOverrides)
.SingleAsync(i => i.UniqueName == "Pump-01");
// FK remapped explicitly to the EXISTING target connection id.
var binding = Assert.Single(inst.ConnectionBindings);
Assert.Equal(targetConnId, binding.DataConnectionId);
Assert.Equal("OpcUaPrimary", inst.NativeAlarmSourceOverrides.Single().ConnectionNameOverride);
}
// Site + connection Skipped, instance Added.
Assert.Equal(1, result.Added);
Assert.Equal(3, result.Skipped); // template + site + connection
}
// ──────────────────────────────────────────────────────────────────────
// Rename an instance on import
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task ApplyAsync_renames_instance_unique_name_on_import()
{
await SeedSiteClosureAsync();
var sessionId = await ExportAllSitesAndLoadAsync();
await WipeSiteClosureAsync();
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
await ctx.SaveChangesAsync();
}
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null) });
var result = await ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Skip, null),
new("Instance", "Pump-01", ResolutionAction.Rename, "Pump-01-imported"),
},
nameMap);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.Equal(0, await ctx.Instances.CountAsync(i => i.UniqueName == "Pump-01"));
var renamed = await ctx.Instances
.Include(i => i.ConnectionBindings)
.SingleAsync(i => i.UniqueName == "Pump-01-imported");
// Renamed instance still gets its FKs rewired + state reset.
Assert.Equal(InstanceState.NotDeployed, renamed.State);
var conn = await ctx.DataConnections.SingleAsync(c => c.Name == "OpcUaPrimary");
Assert.Equal(conn.Id, renamed.ConnectionBindings.Single().DataConnectionId);
}
Assert.Equal(1, result.Renamed);
}
// ──────────────────────────────────────────────────────────────────────
// Cross-site rebind: source site maps to a DIFFERENTLY-named target site
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task ApplyAsync_maps_source_site_to_differently_named_target_site_and_rebinds_connections()
{
await SeedSiteClosureAsync(siteIdentifier: "plant-1", connectionName: "OpcUaPrimary");
var sessionId = await ExportAllSitesAndLoadAsync();
await WipeSiteClosureAsync();
// Target env has a DIFFERENTLY-identified site (plant-west) carrying a
// same-named connection. The operator maps plant-1 → plant-west.
int westSiteId;
int westConnId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
var west = new Site("Plant West", "plant-west");
ctx.Sites.Add(west);
await ctx.SaveChangesAsync();
westSiteId = west.Id;
var conn = new DataConnection("OpcUaPrimary", "OpcUa", west.Id)
{
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://west\"}",
};
ctx.DataConnections.Add(conn);
await ctx.SaveChangesAsync();
westConnId = conn.Id;
}
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-west") },
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary") });
var result = await ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Skip, null),
new("Site", "plant-1", ResolutionAction.Skip, null),
new("DataConnection", "OpcUaPrimary", ResolutionAction.Skip, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
nameMap);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// No plant-1 site was created — the bundle's site mapped onto plant-west.
Assert.Equal(0, await ctx.Sites.CountAsync(s => s.SiteIdentifier == "plant-1"));
var inst = await ctx.Instances
.Include(i => i.ConnectionBindings)
.SingleAsync(i => i.UniqueName == "Pump-01");
// Instance rebound to the TARGET site + its connection id.
Assert.Equal(westSiteId, inst.SiteId);
Assert.Equal(westConnId, inst.ConnectionBindings.Single().DataConnectionId);
}
Assert.Equal(1, result.Added);
}
// ──────────────────────────────────────────────────────────────────────
// Rollback: a mid-apply failure persists NOTHING
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task ApplyAsync_rolls_back_site_and_connection_when_instance_pass_throws()
{
// Hand-pack a bundle whose instance references template "GhostTemplate"
// that exists in NEITHER the bundle NOR the (empty) target. The reference
// is rejected in the pre-write validation phase (so the change tracker is
// still empty), the import aborts, and the transaction rolls back — no
// site, connection, or instance row may survive, and a BundleImportFailed
// audit row records the abort. The validation-phase rejection surfaces as
// a SemanticValidationException, the same all-or-nothing failure contract
// the script-reference and template-validation checks already use.
var content = new BundleContentDto(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: Array.Empty<TemplateDto>(),
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiMethods: Array.Empty<ApiMethodDto>())
{
Sites = new[]
{
new SiteDto("plant-1", "Plant 1", null, null, null, null, null),
},
DataConnections = new[]
{
new DataConnectionDto("plant-1", "OpcUaPrimary", "OpcUa", 3, null),
},
Instances = new[]
{
new InstanceDto(
UniqueName: "Pump-01",
TemplateName: "GhostTemplate",
SiteIdentifier: "plant-1",
AreaName: null,
State: InstanceState.Enabled,
AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
ConnectionBindings: Array.Empty<InstanceConnectionBindingDto>()),
},
};
var sessionId = await PackAndLoadAsync(content);
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null) });
await Assert.ThrowsAsync<SemanticValidationException>(() =>
importer.ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Site", "plant-1", ResolutionAction.Add, null),
new("DataConnection", "OpcUaPrimary", ResolutionAction.Add, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
user: "bob",
ct: CancellationToken.None,
nameMap: nameMap));
}
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.Equal(0, await ctx.Sites.CountAsync());
Assert.Equal(0, await ctx.DataConnections.CountAsync());
Assert.Equal(0, await ctx.Instances.CountAsync());
// A BundleImportFailed audit row records the aborted import.
Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "BundleImportFailed"));
}
}
// ──────────────────────────────────────────────────────────────────────
// Overwrite an existing instance: child rows replaced from the bundle
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task ApplyAsync_Overwrite_existing_instance_replaces_child_rows_and_remaps_binding()
{
await SeedSiteClosureAsync();
var sessionId = await ExportAllSitesAndLoadAsync();
// Mutate the target instance so its children diverge from the bundle:
// drop the override + binding, add a junk override. Overwrite must restore
// the bundle's shape and rebind to the (still-present) target connection.
int targetConnId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
targetConnId = (await ctx.DataConnections.SingleAsync(c => c.Name == "OpcUaPrimary")).Id;
var inst = await ctx.Instances
.Include(i => i.AttributeOverrides)
.Include(i => i.ConnectionBindings)
.SingleAsync(i => i.UniqueName == "Pump-01");
ctx.InstanceAttributeOverrides.RemoveRange(inst.AttributeOverrides);
ctx.InstanceConnectionBindings.RemoveRange(inst.ConnectionBindings);
inst.AttributeOverrides.Clear();
inst.ConnectionBindings.Clear();
inst.AttributeOverrides.Add(new InstanceAttributeOverride("Junk") { OverrideValue = "stale" });
await ctx.SaveChangesAsync();
}
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary") });
var result = await ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Skip, null),
new("Site", "plant-1", ResolutionAction.Skip, null),
new("DataConnection", "OpcUaPrimary", ResolutionAction.Skip, null),
new("Instance", "Pump-01", ResolutionAction.Overwrite, null),
},
nameMap);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var inst = await ctx.Instances
.Include(i => i.AttributeOverrides)
.Include(i => i.ConnectionBindings)
.Include(i => i.NativeAlarmSourceOverrides)
.SingleAsync(i => i.UniqueName == "Pump-01");
// Bundle's child shape restored — junk override gone, Flow override + binding back.
Assert.DoesNotContain(inst.AttributeOverrides, o => o.AttributeName == "Junk");
Assert.Contains(inst.AttributeOverrides, o => o.AttributeName == "Flow" && o.OverrideValue == "42");
var binding = Assert.Single(inst.ConnectionBindings);
Assert.Equal(targetConnId, binding.DataConnectionId);
Assert.Equal("OpcUaPrimary", inst.NativeAlarmSourceOverrides.Single().ConnectionNameOverride);
Assert.Equal(InstanceState.NotDeployed, inst.State);
}
Assert.Equal(1, result.Overwritten);
}
}