feat(transport-ui): export wizard site/instance selection (M8 E1)

This commit is contained in:
Joseph Doherty
2026-06-18 06:26:36 -04:00
parent bdbf5cdab0
commit d0b38ad726
3 changed files with 314 additions and 5 deletions
@@ -7,8 +7,10 @@ using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
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.Transport;
@@ -49,6 +51,7 @@ public class TransportExportPageTests : BunitContext
private readonly IExternalSystemRepository _externalRepo = Substitute.For<IExternalSystemRepository>();
private readonly INotificationRepository _notificationRepo = Substitute.For<INotificationRepository>();
private readonly IInboundApiRepository _inboundApiRepo = Substitute.For<IInboundApiRepository>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IBundleExporter _exporter = Substitute.For<IBundleExporter>();
public TransportExportPageTests()
@@ -73,14 +76,24 @@ public class TransportExportPageTests : BunitContext
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration>()));
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>()));
// Empty site/instance defaults — the M8 site/instance picker calls these on load
// and the DependencyResolver fans out over sites on resolve.
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
_siteRepo.GetInstancesBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance>()));
_siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<DataConnection>>(new List<DataConnection>()));
Services.AddSingleton(_templateRepo);
Services.AddSingleton(_externalRepo);
Services.AddSingleton(_notificationRepo);
Services.AddSingleton(_inboundApiRepo);
Services.AddSingleton(_siteRepo);
Services.AddSingleton(_exporter);
// DependencyResolver is sealed but its only dependencies are the four
// repositories above — registering the concrete type is enough.
// DependencyResolver is sealed but its dependencies are the repositories above
// (template/external/notification/inbound + ISiteRepository for M8 site/instance
// export) — registering the concrete type is enough.
Services.AddSingleton<DependencyResolver>();
Services.AddSingleton<IOptions<TransportOptions>>(
Microsoft.Extensions.Options.Options.Create(new TransportOptions
@@ -312,6 +325,108 @@ public class TransportExportPageTests : BunitContext
Assert.False(result.Succeeded);
}
// ─────────────────────────────────────────────────────────────────────
// Test 5 (M8 E1): Step 1 renders the Sites & Instances group, listing each
// site and (when expanded) its instances.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void Renders_step1_sites_group_with_sites_and_instances()
{
var site = new Site("North Plant", "north") { Id = 5 };
var instance = new Instance("north/pump-01") { Id = 50, SiteId = 5 };
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site> { site }));
_siteRepo.GetInstancesBySiteIdAsync(5, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance> { instance }));
var cut = Render<TransportExportPage>();
cut.WaitForState(() => cut.Markup.Contains("North Plant"));
// The Sites & Instances group is present alongside the existing artifact groups.
Assert.NotNull(cut.Find("[data-testid='group-sites']"));
Assert.NotNull(cut.Find("[data-testid='sites-hint']"));
Assert.Contains("North Plant", cut.Markup);
Assert.Contains("north", cut.Markup);
// The site row carries a site checkbox; expanding reveals the instance checkbox.
Assert.NotNull(cut.Find("#chk-site-5"));
var expandToggle = cut.Find("[data-testid='site-row'] button");
expandToggle.Click();
cut.WaitForAssertion(() =>
{
Assert.NotNull(cut.Find("[data-testid='site-instances']"));
Assert.NotNull(cut.Find("#chk-instance-50"));
Assert.Contains("north/pump-01", cut.Markup);
});
}
// ─────────────────────────────────────────────────────────────────────
// Test 6 (M8 E1): Selecting a site flows its id into ExportSelection.SiteIds;
// selecting an individual instance flows its id into InstanceIds. Verified at
// the resolver/exporter boundary (the same contract DependencyResolver reads).
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Step4_export_carries_selected_site_and_instance_ids()
{
var siteA = new Site("North Plant", "north") { Id = 5 };
var siteB = new Site("South Plant", "south") { Id = 6 };
var instanceB = new Instance("south/pump-09") { Id = 90, SiteId = 6 };
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site> { siteA, siteB }));
_siteRepo.GetInstancesBySiteIdAsync(5, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance>()));
_siteRepo.GetInstancesBySiteIdAsync(6, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance> { instanceB }));
// Resolver fetches selected entities by id; return them so the closure is non-empty.
_siteRepo.GetSiteByIdAsync(5, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Site?>(siteA));
_exporter
.ExportAsync(
Arg.Any<ExportSelection>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult<Stream>(new MemoryStream(new byte[] { 0x50, 0x4b, 0x03, 0x04 })));
var cut = Render<TransportExportPage>();
cut.WaitForState(() => cut.Markup.Contains("North Plant"));
// Tick site 5 (North Plant) directly.
cut.Find("#chk-site-5").Change(true);
// Expand South Plant and tick its instance individually.
var southRow = cut.FindAll("[data-testid='site-row']")
.First(r => r.GetAttribute("data-site-id") == "6");
southRow.QuerySelector("button")!.Click();
cut.WaitForState(() => cut.FindAll("#chk-instance-90").Count > 0);
cut.Find("#chk-instance-90").Change(true);
// Step 1 → 2 → 3 → export.
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
cut.WaitForAssertion(() => Assert.Contains("Selected by you", cut.Markup));
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
cut.WaitForAssertion(() => Assert.Contains("Passphrase", cut.Markup));
cut.Find("#passphrase").Input("hunter2hunter2");
cut.Find("#passphrase-confirm").Input("hunter2hunter2");
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Export").ClickAsync(new());
cut.WaitForAssertion(() => Assert.Contains("Bundle ready", cut.Markup));
await _exporter.Received(1).ExportAsync(
Arg.Is<ExportSelection>(s =>
s.SiteIds.Contains(5)
&& s.InstanceIds.Contains(90)),
"alice",
"test-cluster",
"hunter2hunter2",
Arg.Any<CancellationToken>());
}
// ─────────────────────────────────────────────────────────────────────
// Static helpers — exercised directly so the file-naming + secret-count
// contract is unit-pinned independently of the rendering surface.