feat(transport): preview diff + required-mapping detection + blockers (M8 C2)

This commit is contained in:
Joseph Doherty
2026-06-18 06:28:47 -04:00
parent d0b38ad726
commit 50d77b07cf
3 changed files with 616 additions and 4 deletions
@@ -2,16 +2,20 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
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.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
@@ -78,6 +82,139 @@ public sealed class BundleImporterPreviewTests : IDisposable
return ms.ToArray();
}
/// <summary>Exports every seeded site (and its instance/connection closure) into a bundle.</summary>
private async Task<Stream> ExportAllSitesAsync()
{
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);
return await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
}
/// <summary>
/// Seeds a template + a site + a site-scoped data connection + an instance bound
/// to that connection (with one attribute override) so a full site closure can be
/// exported and previewed. Returns the seeded site identifier + connection name.
/// </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" };
ctx.Sites.Add(site);
await ctx.SaveChangesAsync();
var conn = new DataConnection(connectionName, "OpcUa", site.Id)
{
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}",
FailoverRetryCount = 3,
};
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" });
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>
/// Packs an arbitrary <see cref="BundleContentDto"/> into a real, loadable plaintext
/// bundle so blocker scenarios can reference entities that the export resolver would
/// never carry (e.g. an instance pointing at a template absent from both bundle and
/// target). Reuses the production manifest builder + serializer for hash fidelity.
/// </summary>
private async Task<byte[]> PackBundleAsync(BundleContentDto content)
{
await using var scope = _provider.CreateAsyncScope();
var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
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));
var packed = serializer.Pack(content, manifest, passphrase: null, encryptor: null);
return await StreamToBytes(packed);
}
private static BundleContentDto EmptyContent() => new(
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>());
private static InstanceDto SimpleInstanceDto(
string uniqueName,
string templateName,
string siteIdentifier,
IReadOnlyList<InstanceConnectionBindingDto>? bindings = null) => new(
UniqueName: uniqueName,
TemplateName: templateName,
SiteIdentifier: siteIdentifier,
AreaName: null,
State: InstanceState.Enabled,
AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
ConnectionBindings: bindings ?? Array.Empty<InstanceConnectionBindingDto>());
[Fact]
public async Task PreviewAsync_classifies_artifact_as_Identical_when_fields_match()
{
@@ -388,4 +525,206 @@ public sealed class BundleImporterPreviewTests : IDisposable
Assert.Equal(ConflictKind.Identical, valveItem.Kind);
Assert.Equal(ConflictKind.Identical, tankItem.Kind);
}
// ---- M8 C2: site/connection/instance preview + required-mapping detection + blockers ----
[Fact]
public async Task PreviewAsync_into_fresh_target_surfaces_required_mappings_with_null_auto_match()
{
// Arrange: seed a full site closure, export it, then wipe the target so the
// site + connection are absent. The preview must surface the site +
// connection as required mappings with no auto-match (create-new implied),
// and classify the site/connection/instance as New.
await SeedSiteClosureAsync();
var bundleStream = await ExportAllSitesAsync();
var bytes = await StreamToBytes(bundleStream);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Instances.RemoveRange(ctx.Instances);
ctx.DataConnections.RemoveRange(ctx.DataConnections);
ctx.Sites.RemoveRange(ctx.Sites);
ctx.Templates.RemoveRange(ctx.Templates);
await ctx.SaveChangesAsync();
}
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
preview = await importer.PreviewAsync(session.SessionId);
}
var siteMapping = Assert.Single(preview.RequiredSiteMappings, m => m.SourceSiteIdentifier == "plant-1");
Assert.Null(siteMapping.AutoMatchTargetIdentifier);
Assert.Equal("Plant 1", siteMapping.SourceSiteName);
var connMapping = Assert.Single(preview.RequiredConnectionMappings,
m => m.SourceSiteIdentifier == "plant-1" && m.SourceConnectionName == "OpcUaPrimary");
Assert.Null(connMapping.AutoMatchTargetName);
// The fresh target carries none of these, so each is classified New (and the
// missing template would block — but we wiped the template too, so the
// instance also blocks; assert the New site/connection at minimum).
Assert.Contains(preview.Items, i => i.EntityType == "Site" && i.Name == "plant-1" && i.Kind == ConflictKind.New);
Assert.Contains(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "OpcUaPrimary" && i.Kind == ConflictKind.New);
}
[Fact]
public async Task PreviewAsync_into_populated_target_auto_matches_site_and_connection()
{
// Arrange: seed a site closure, export it, leave the target unchanged. The
// bundle's site + connection identity-match the target, so both required
// mappings auto-match and the per-type diffs read Identical.
await SeedSiteClosureAsync();
var bundleStream = await ExportAllSitesAsync();
var bytes = await StreamToBytes(bundleStream);
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
preview = await importer.PreviewAsync(session.SessionId);
}
var siteMapping = Assert.Single(preview.RequiredSiteMappings, m => m.SourceSiteIdentifier == "plant-1");
Assert.Equal("plant-1", siteMapping.AutoMatchTargetIdentifier);
var connMapping = Assert.Single(preview.RequiredConnectionMappings,
m => m.SourceSiteIdentifier == "plant-1" && m.SourceConnectionName == "OpcUaPrimary");
Assert.Equal("OpcUaPrimary", connMapping.AutoMatchTargetName);
// The site + connection match the target exactly → Identical, not New.
var siteItem = Assert.Single(preview.Items, i => i.EntityType == "Site" && i.Name == "plant-1");
Assert.Equal(ConflictKind.Identical, siteItem.Kind);
var connItem = Assert.Single(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "OpcUaPrimary");
Assert.Equal(ConflictKind.Identical, connItem.Kind);
// No blocker — the template + connection both resolve in the target.
Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker && i.EntityType == "Instance");
}
[Fact]
public async Task PreviewAsync_modified_instance_against_hydrated_target_shows_child_diff_not_all_added()
{
// Arrange: seed a site closure (instance has a Flow=42 attribute override),
// export it, then mutate the TARGET instance's override value. The diff must
// surface a single modified child override — proving CompareInstance received
// a HYDRATED existing instance (I2). A non-hydrated entity would read the
// existing children as empty and report the bundle's override as an addition.
await SeedSiteClosureAsync();
var bundleStream = await ExportAllSitesAsync();
var bytes = await StreamToBytes(bundleStream);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var ovr = await ctx.InstanceAttributeOverrides.SingleAsync(o => o.AttributeName == "Flow");
ovr.OverrideValue = "99"; // bundle still carries 42
await ctx.SaveChangesAsync();
}
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
preview = await importer.PreviewAsync(session.SessionId);
}
var instItem = Assert.Single(preview.Items, i => i.EntityType == "Instance" && i.Name == "Pump-01");
Assert.Equal(ConflictKind.Modified, instItem.Kind);
Assert.NotNull(instItem.FieldDiffJson);
// The diff names the AttributeOverrides collection (a change, NOT an add of
// every existing child). The hydrated existing instance already had a Flow
// override, so the diff is a modification, not an addition.
Assert.Contains("AttributeOverrides", instItem.FieldDiffJson!, StringComparison.Ordinal);
// A non-hydrated existing would have reported AreaName/State unchanged but
// every override as Added — assert the diff did NOT explode into the other
// unchanged children (ConnectionBindings DataSourceReference matches verbatim).
Assert.DoesNotContain("\"ConnectionBindings\"", instItem.FieldDiffJson!, StringComparison.Ordinal);
}
[Fact]
public async Task PreviewAsync_instance_with_missing_template_emits_blocker()
{
// Arrange: hand-build a bundle whose instance references a template present in
// NEITHER the bundle nor the target. (The real exporter would never produce
// this — it always carries the instance's template — so we pack directly.)
var content = EmptyContent() with
{
Instances = new[]
{
SimpleInstanceDto("Ghost-01", templateName: "NoSuchTemplate", siteIdentifier: "plant-1"),
},
};
var bytes = await PackBundleAsync(content);
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
preview = await importer.PreviewAsync(session.SessionId);
}
Assert.Contains(preview.Items, i =>
i.Kind == ConflictKind.Blocker
&& i.EntityType == "Instance"
&& i.Name == "Ghost-01"
&& i.BlockerReason is not null
&& i.BlockerReason.Contains("NoSuchTemplate", StringComparison.Ordinal));
}
[Fact]
public async Task PreviewAsync_referenced_connection_absent_from_bundle_and_target_emits_blocker()
{
// Arrange: seed the template in the target so the instance's TEMPLATE resolves
// (isolating the connection blocker), then hand-build a bundle whose instance
// binds an attribute to a connection that is in neither the bundle nor the
// target.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "resolves" });
await ctx.SaveChangesAsync();
}
var content = EmptyContent() with
{
Instances = new[]
{
SimpleInstanceDto("Pump-99", templateName: "Pump", siteIdentifier: "plant-1",
bindings: new[]
{
new InstanceConnectionBindingDto("Flow", "PhantomConn", DataSourceReferenceOverride: null),
}),
},
};
var bytes = await PackBundleAsync(content);
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
preview = await importer.PreviewAsync(session.SessionId);
}
// The unresolved connection blocks…
Assert.Contains(preview.Items, i =>
i.Kind == ConflictKind.Blocker
&& i.Name == "PhantomConn"
&& i.BlockerReason is not null
&& i.BlockerReason.Contains("PhantomConn", StringComparison.Ordinal));
// …but the template resolves in the target, so the instance is NOT a
// missing-template blocker.
Assert.DoesNotContain(preview.Items, i =>
i.Kind == ConflictKind.Blocker
&& i.EntityType == "Instance"
&& i.Name == "Pump-99");
}
}
@@ -110,6 +110,7 @@ public sealed class BundleImporterLoadTests
externalRepo: Substitute.For<IExternalSystemRepository>(),
notificationRepo: Substitute.For<INotificationRepository>(),
inboundApiRepo: Substitute.For<IInboundApiRepository>(),
siteRepo: Substitute.For<ISiteRepository>(),
auditService: Substitute.For<IAuditService>(),
correlationContext: Substitute.For<IAuditCorrelationContext>(),
// LoadAsync never touches the DbContext — Preview/Apply do. Build