fix(transport): export instance AreaName — Area-by-name reconciliation was half-shipped (exporter hardcoded null)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||||
@@ -72,6 +73,24 @@ public sealed class BundleExporter : IBundleExporter
|
|||||||
// folder chain, transitive shared-scripts/external-systems if requested).
|
// folder chain, transitive shared-scripts/external-systems if requested).
|
||||||
var resolved = await _resolver.ResolveAsync(selection, cancellationToken).ConfigureAwait(false);
|
var resolved = await _resolver.ResolveAsync(selection, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// 1b. Areas travel by NAME (the importer resolves-or-creates them under the
|
||||||
|
// target site), so we only need an id→name lookup for the AreaIds the
|
||||||
|
// exported instances actually reference — a single query over Areas keyed
|
||||||
|
// to those FKs. Without this the serializer would emit AreaName: null and
|
||||||
|
// the imported instance would lose its area assignment.
|
||||||
|
var referencedAreaIds = resolved.Instances
|
||||||
|
.Select(i => i.AreaId)
|
||||||
|
.Where(id => id is not null)
|
||||||
|
.Select(id => id!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
var areaNameById = referencedAreaIds.Count == 0
|
||||||
|
? new Dictionary<int, string>()
|
||||||
|
: await _dbContext.Areas
|
||||||
|
.Where(a => referencedAreaIds.Contains(a.Id))
|
||||||
|
.ToDictionaryAsync(a => a.Id, a => a.Name, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
// 2. Convert to the wire-shaped DTO (strips EF identity, refs-by-name).
|
// 2. Convert to the wire-shaped DTO (strips EF identity, refs-by-name).
|
||||||
// The resolver's site/dataConnection/instance closure is wired
|
// The resolver's site/dataConnection/instance closure is wired
|
||||||
// through via object-initializer — without this the aggregate would
|
// through via object-initializer — without this the aggregate would
|
||||||
@@ -97,6 +116,9 @@ public sealed class BundleExporter : IBundleExporter
|
|||||||
// bundle ships an empty smsConfigs array even when SMS configs were
|
// bundle ships an empty smsConfigs array even when SMS configs were
|
||||||
// selected — mirrors the site/instance fix (review item I3).
|
// selected — mirrors the site/instance fix (review item I3).
|
||||||
SmsConfigurations = resolved.SmsConfigs,
|
SmsConfigurations = resolved.SmsConfigs,
|
||||||
|
// Area id→name lookup for the exported instances — feeds the serializer's
|
||||||
|
// AreaName projection so each instance's area travels by name.
|
||||||
|
AreaNameById = areaNameById,
|
||||||
};
|
};
|
||||||
var contentDto = _entitySerializer.ToBundleContent(aggregate);
|
var contentDto = _entitySerializer.ToBundleContent(aggregate);
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ public sealed record EntityAggregate(
|
|||||||
// and never sees null; producers that resolve SMS config opt in via
|
// and never sees null; producers that resolve SMS config opt in via
|
||||||
// object-initializer.
|
// object-initializer.
|
||||||
public IReadOnlyList<SmsConfiguration> SmsConfigurations { get; init; } = Array.Empty<SmsConfiguration>();
|
public IReadOnlyList<SmsConfiguration> SmsConfigurations { get; init; } = Array.Empty<SmsConfiguration>();
|
||||||
|
|
||||||
|
// Area id → name lookup for the exported instances. Areas are not carried as a
|
||||||
|
// first-class bundle collection (they reconcile by NAME under the target site),
|
||||||
|
// so the aggregate only needs enough to resolve each instance's AreaId FK back to
|
||||||
|
// a portable name at serialization time. Init-only with an empty default: producers
|
||||||
|
// that export instances populate it; every other caller keeps compiling and the
|
||||||
|
// serializer emits AreaName: null when an instance's AreaId isn't in the map.
|
||||||
|
public IReadOnlyDictionary<int, string> AreaNameById { get; init; } =
|
||||||
|
new Dictionary<int, string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -248,10 +248,13 @@ public sealed class EntitySerializer
|
|||||||
UniqueName: inst.UniqueName,
|
UniqueName: inst.UniqueName,
|
||||||
TemplateName: templateNameById.TryGetValue(inst.TemplateId, out var tn) ? tn : string.Empty,
|
TemplateName: templateNameById.TryGetValue(inst.TemplateId, out var tn) ? tn : string.Empty,
|
||||||
SiteIdentifier: siteIdentifierBySiteId.TryGetValue(inst.SiteId, out var isid) ? isid : string.Empty,
|
SiteIdentifier: siteIdentifierBySiteId.TryGetValue(inst.SiteId, out var isid) ? isid : string.Empty,
|
||||||
// TODO: Areas don't travel in the bundle yet — EntityAggregate
|
// Area travels by NAME — the importer resolves-or-creates the
|
||||||
// carries no Areas collection, so there's no id→name lookup. Emit
|
// equivalent area under the target site (ResolveOrCreateAreaIdAsync).
|
||||||
// null until area transport is added; the importer leaves AreaId null.
|
// Resolve the instance's AreaId FK against the aggregate's id→name
|
||||||
AreaName: null,
|
// map; if the FK is null (no area) or unresolved (orphan/absent from
|
||||||
|
// the map), the name comes through as null and the importer leaves
|
||||||
|
// AreaId null on the imported instance.
|
||||||
|
AreaName: inst.AreaId is { } aid && aggregate.AreaNameById.TryGetValue(aid, out var an) ? an : null,
|
||||||
State: inst.State,
|
State: inst.State,
|
||||||
AttributeOverrides: inst.AttributeOverrides.Select(o => new InstanceAttributeOverrideDto(
|
AttributeOverrides: inst.AttributeOverrides.Select(o => new InstanceAttributeOverrideDto(
|
||||||
AttributeName: o.AttributeName,
|
AttributeName: o.AttributeName,
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
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 Area-by-name transport (Task 7): a source instance
|
||||||
|
/// assigned to an <see cref="Area"/> under its site must carry that area's NAME
|
||||||
|
/// through the exported bundle so the importer's <c>ResolveOrCreateAreaIdAsync</c>
|
||||||
|
/// machinery can resolve-or-create the equivalent area under the target site. This
|
||||||
|
/// closes the half-shipped Area transport — the importer's GetOrCreate path was
|
||||||
|
/// already built + tested, but the exporter hardcoded <c>AreaName: null</c>.
|
||||||
|
/// <para>
|
||||||
|
/// Reuses the in-memory host pattern from <c>SiteInstanceImportTests</c>: real
|
||||||
|
/// repositories, real EF in-memory provider, real Transport pipeline.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AreaTransportTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ServiceProvider _provider;
|
||||||
|
|
||||||
|
public AreaTransportTests()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddSingleton<IConfiguration>(
|
||||||
|
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||||
|
|
||||||
|
var dbName = $"AreaTransportTests_{Guid.NewGuid()}";
|
||||||
|
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();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Instance_Area_travels_by_name_and_resolves_under_target_site()
|
||||||
|
{
|
||||||
|
// ---- Seed a site + instance assigned to an Area "Line-1" ----
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
|
||||||
|
var template = new Template("Pump") { Description = "pump tpl" };
|
||||||
|
ctx.Templates.Add(template);
|
||||||
|
|
||||||
|
var site = new Site("Plant 1", "plant-1");
|
||||||
|
ctx.Sites.Add(site);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var area = new Area("Line-1") { SiteId = site.Id };
|
||||||
|
ctx.Areas.Add(area);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var instance = new Instance("Pump-01")
|
||||||
|
{
|
||||||
|
TemplateId = template.Id,
|
||||||
|
SiteId = site.Id,
|
||||||
|
AreaId = area.Id,
|
||||||
|
State = InstanceState.Enabled,
|
||||||
|
};
|
||||||
|
ctx.Instances.Add(instance);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Export the whole site closure ----
|
||||||
|
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;
|
||||||
|
|
||||||
|
// ---- Wipe the source rows so the import creates a fresh target ----
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
ctx.Instances.RemoveRange(ctx.Instances);
|
||||||
|
ctx.Areas.RemoveRange(ctx.Areas);
|
||||||
|
ctx.Sites.RemoveRange(ctx.Sites);
|
||||||
|
ctx.Templates.RemoveRange(ctx.Templates);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
// The template must exist for the instance to resolve its TemplateId.
|
||||||
|
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Import with a CreateNew site mapping ----
|
||||||
|
Guid sessionId;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
var session = await importer.LoadAsync(ms, passphrase: null);
|
||||||
|
sessionId = session.SessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameMap = new BundleNameMap(
|
||||||
|
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
|
||||||
|
Connections: Array.Empty<ConnectionMapping>());
|
||||||
|
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
await importer.ApplyAsync(
|
||||||
|
sessionId,
|
||||||
|
new List<ImportResolution>
|
||||||
|
{
|
||||||
|
new("Template", "Pump", ResolutionAction.Skip, null),
|
||||||
|
new("Site", "plant-1", ResolutionAction.Add, null),
|
||||||
|
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
||||||
|
},
|
||||||
|
user: "bob",
|
||||||
|
ct: CancellationToken.None,
|
||||||
|
nameMap: nameMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Assert: the imported instance's AreaId resolves to an Area named
|
||||||
|
// "Line-1" under the target site (NOT null). ----
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var site = await ctx.Sites.SingleAsync(s => s.SiteIdentifier == "plant-1");
|
||||||
|
var inst = await ctx.Instances.SingleAsync(i => i.UniqueName == "Pump-01");
|
||||||
|
|
||||||
|
Assert.NotNull(inst.AreaId);
|
||||||
|
var area = await ctx.Areas.SingleAsync(a => a.Id == inst.AreaId);
|
||||||
|
Assert.Equal("Line-1", area.Name);
|
||||||
|
Assert.Equal(site.Id, area.SiteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user