731 lines
34 KiB
C#
731 lines
34 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Integration tests for <see cref="ZB.MOM.WW.ScadaBridge.Transport.Import.BundleImporter.PreviewAsync"/>.
|
|
/// Reuses the same in-memory host pattern as the exporter tests: real
|
|
/// repositories, real EF in-memory provider, real Transport pipeline. Each test
|
|
/// seeds the target DB, exports a bundle, then loads + previews it via the
|
|
/// importer.
|
|
/// </summary>
|
|
public sealed class BundleImporterPreviewTests : IDisposable
|
|
{
|
|
private readonly ServiceProvider _provider;
|
|
|
|
public BundleImporterPreviewTests()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton<IConfiguration>(
|
|
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
|
|
|
var dbName = $"BundleImporterPreviewTests_{Guid.NewGuid()}";
|
|
services.AddDbContext<ScadaBridgeDbContext>(opts => opts.UseInMemoryDatabase(dbName));
|
|
|
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
|
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
|
// M8: DependencyResolver now injects ISiteRepository to walk the
|
|
// site/data-connection/instance closure; register it or activation fails.
|
|
services.AddScoped<ISiteRepository, SiteRepository>();
|
|
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
|
services.AddScoped<IAuditService, AuditService>();
|
|
services.AddTransport();
|
|
|
|
_provider = services.BuildServiceProvider();
|
|
}
|
|
|
|
public void Dispose() => _provider.Dispose();
|
|
|
|
private async Task<Stream> ExportTemplatesAsync()
|
|
{
|
|
await using var scope = _provider.CreateAsyncScope();
|
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var ids = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
|
var selection = new ExportSelection(
|
|
TemplateIds: ids,
|
|
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: false);
|
|
|
|
return await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
|
passphrase: null, cancellationToken: CancellationToken.None);
|
|
}
|
|
|
|
private static async Task<byte[]> StreamToBytes(Stream s)
|
|
{
|
|
using var ms = new MemoryStream();
|
|
await s.CopyToAsync(ms);
|
|
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()
|
|
{
|
|
// Arrange: seed a template, export it, leave target unchanged. The
|
|
// bundle's DTO is the literal projection of the target, so the diff
|
|
// should classify it as Identical.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.Templates.Add(new Template("Pump") { Description = "stable" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var bundleStream = await ExportTemplatesAsync();
|
|
var bytes = await StreamToBytes(bundleStream);
|
|
|
|
// Act
|
|
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
|
|
var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
|
|
Assert.Equal(ConflictKind.Identical, pumpItem.Kind);
|
|
Assert.Null(pumpItem.FieldDiffJson);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_classifies_artifact_as_Modified_with_field_diff()
|
|
{
|
|
// Arrange: seed a template with Description="new", export it, then
|
|
// overwrite the target template's Description with "old". The bundle's
|
|
// version differs from the target, so the diff should flag the
|
|
// Description field.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.Templates.Add(new Template("Pump") { Description = "new" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var bundleStream = await ExportTemplatesAsync();
|
|
var bytes = await StreamToBytes(bundleStream);
|
|
|
|
// Mutate the target between export and preview so the diff has
|
|
// something to report. The bundle still carries Description="new".
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
|
|
t.Description = "old";
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
// Act
|
|
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
|
|
var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
|
|
Assert.Equal(ConflictKind.Modified, pumpItem.Kind);
|
|
Assert.NotNull(pumpItem.FieldDiffJson);
|
|
// The diff should mention the Description field by name.
|
|
Assert.Contains("Description", pumpItem.FieldDiffJson!, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_classifies_artifact_as_New_when_absent_from_target()
|
|
{
|
|
// Arrange: seed a template, export it, then delete it from the target
|
|
// database. The bundle still contains the template, so the diff should
|
|
// classify it as New (target is now empty).
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.Templates.Add(new Template("Pump") { Description = "to-be-deleted" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var bundleStream = await ExportTemplatesAsync();
|
|
var bytes = await StreamToBytes(bundleStream);
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = await ctx.Templates.SingleAsync();
|
|
ctx.Templates.Remove(t);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
// Act
|
|
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
|
|
var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
|
|
Assert.Equal(ConflictKind.New, pumpItem.Kind);
|
|
Assert.Null(pumpItem.FieldDiffJson);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_emits_Blocker_when_required_dependency_missing()
|
|
{
|
|
// Arrange: seed a template whose script body calls MissingHelper(), and
|
|
// an unrelated HelperFn() shared script that *is* defined but isn't the
|
|
// referenced one. We then export WITHOUT IncludeDependencies and use a
|
|
// selection that only pulls the template — the bundle won't carry
|
|
// MissingHelper (it doesn't exist anywhere) so the preview must flag it.
|
|
//
|
|
// To get MissingHelper into the bundle script body without the export
|
|
// resolver pulling it in (it can't — it doesn't exist), we just seed
|
|
// the template with a script that mentions it; the resolver scan only
|
|
// matters for entity discovery, the body text is preserved verbatim.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
|
|
ctx.ExternalSystemDefinitions.Add(new ExternalSystemDefinition("ErpSystem", "https://erp.example", "ApiKey"));
|
|
|
|
var t = new Template("Pump") { Description = "broken" };
|
|
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
|
|
ctx.Templates.Add(t);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var bundleStream = await ExportTemplatesAsync();
|
|
var bytes = await StreamToBytes(bundleStream);
|
|
|
|
// Wipe the SharedScripts table so MissingHelper has no chance of being
|
|
// resolved in the target either. (HelperFn is intentionally seeded so
|
|
// we can verify the blocker check is specific — it should NOT flag
|
|
// HelperFn since it's in the target.)
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
// Keep HelperFn + ErpSystem so they're in the target's resolved set.
|
|
// Just confirm via assertion that MissingHelper is the blocker name.
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
// Act
|
|
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: there's at least one Blocker, and the MissingHelper one is in there.
|
|
Assert.Contains(preview.Items, i => i.Kind == ConflictKind.Blocker);
|
|
Assert.Contains(preview.Items, i =>
|
|
i.Kind == ConflictKind.Blocker
|
|
&& i.Name == "MissingHelper"
|
|
&& i.BlockerReason is not null
|
|
&& i.BlockerReason.Contains("MissingHelper", StringComparison.Ordinal));
|
|
// Conversely, HelperFn must NOT be a blocker — it's seeded in the target.
|
|
Assert.DoesNotContain(preview.Items, i =>
|
|
i.Kind == ConflictKind.Blocker && i.Name == "HelperFn");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_does_not_flag_opcua_tag_paths_in_DataSourceReference_as_blockers()
|
|
{
|
|
// Arrange: a template with an attribute whose DataSourceReference is an
|
|
// OPC UA node-address path -- e.g. "ns=3;s=Tank.Level". The segment
|
|
// before the dot ("Tank") used to be parsed by the blocker heuristic as
|
|
// a potential SharedScript reference, even though tag paths live in the
|
|
// device's address space and are not script-callable.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = new Template("Pump") { Description = "tag-path-check" };
|
|
t.Attributes.Add(new TemplateAttribute("Level")
|
|
{
|
|
Value = "0",
|
|
DataSourceReference = "ns=3;s=Tank.Level",
|
|
});
|
|
ctx.Templates.Add(t);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var bundleStream = await ExportTemplatesAsync();
|
|
var bytes = await StreamToBytes(bundleStream);
|
|
|
|
// Act
|
|
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: "Tank" (the device-owned tag-path root segment) must not be
|
|
// flagged as a missing SharedScript or ExternalSystem reference.
|
|
Assert.DoesNotContain(preview.Items, i =>
|
|
i.Kind == ConflictKind.Blocker && i.Name == "Tank");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_does_not_flag_stdlib_or_runtime_member_accesses_as_blockers()
|
|
{
|
|
// Arrange: a template script that uses a representative mix of stdlib
|
|
// calls, runtime-API roots, and member-access patterns. None of these
|
|
// are user-defined SharedScripts or ExternalSystems and the previous
|
|
// heuristic was flagging every one of them.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = new Template("Pump") { Description = "noise-check" };
|
|
t.Scripts.Add(new TemplateScript("init", """
|
|
var now = DateTimeOffset.UtcNow;
|
|
var s = Convert.ToString(123);
|
|
await Notify.Send("alerts", "msg");
|
|
var x = await Database.ExecuteScalarAsync("SELECT COUNT(*) FROM t");
|
|
var y = await ExternalSystem.Call("erp", "ping");
|
|
obj.Dispose();
|
|
"""));
|
|
ctx.Templates.Add(t);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var bundleStream = await ExportTemplatesAsync();
|
|
var bytes = await StreamToBytes(bundleStream);
|
|
|
|
// Act
|
|
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: none of the well-known names produce blocker rows.
|
|
string[] noiseNames =
|
|
{
|
|
"DateTimeOffset", "UtcNow", "Convert", "ToString", "Notify", "Send",
|
|
"Database", "ExecuteScalarAsync", "COUNT", "ExternalSystem", "Call",
|
|
"Dispose",
|
|
};
|
|
foreach (var name in noiseNames)
|
|
{
|
|
Assert.DoesNotContain(preview.Items, i =>
|
|
i.Kind == ConflictKind.Blocker && i.Name == name);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewAsync_multiple_templates_with_children_diffs_each_correctly()
|
|
{
|
|
// Transport-008 regression: PreviewAsync previously fetched each matching
|
|
// template's children via a per-name GetTemplateWithChildrenAsync call
|
|
// (N+1). The bulk variant returns every match in a single query — this
|
|
// test seeds three templates with distinct child collections and asserts
|
|
// the preview hydrates each one so the per-child diff sees the right
|
|
// attribute / alarm / script counts (i.e. the bulk fetch did not lose
|
|
// any child rows compared to the per-name fetch).
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var pump = new Template("Pump") { Description = "p1" };
|
|
pump.Attributes.Add(new TemplateAttribute("Flow"));
|
|
pump.Scripts.Add(new TemplateScript("init", "return 1;"));
|
|
var valve = new Template("Valve") { Description = "v1" };
|
|
valve.Alarms.Add(new TemplateAlarm("HighPressure"));
|
|
var tank = new Template("Tank") { Description = "t1" };
|
|
tank.Attributes.Add(new TemplateAttribute("Level"));
|
|
tank.Attributes.Add(new TemplateAttribute("Temperature"));
|
|
ctx.Templates.AddRange(pump, valve, tank);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var bundleStream = await ExportTemplatesAsync();
|
|
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);
|
|
}
|
|
|
|
// Each template should be diff-classified (Identical, since the bundle
|
|
// is the literal projection of the target). Critically, the diff must
|
|
// succeed for ALL three — a bulk-fetch bug that silently drops rows
|
|
// would surface here as a missing item or a wrong (New) classification.
|
|
var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
|
|
var valveItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Valve");
|
|
var tankItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Tank");
|
|
Assert.Equal(ConflictKind.Identical, pumpItem.Kind);
|
|
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");
|
|
}
|
|
}
|