feat(transport): export site/instance selection end-to-end via CLI + ManagementActor (M8 B4)

This commit is contained in:
Joseph Doherty
2026-06-18 06:14:39 -04:00
parent d7dae24355
commit bdbf5cdab0
16 changed files with 450 additions and 12 deletions
@@ -1386,6 +1386,15 @@ public class ManagementActorTests : TestKit, IDisposable
.Returns(new List<Template>());
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Scripts.SharedScript>());
// M8 (B4): export now also fans out to instances (template repo) and sites
// (site repo) for the site/instance-scoped selection.
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>());
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.Site>());
_services.AddScoped(_ => siteRepo);
var externalRepo = Substitute.For<IExternalSystemRepository>();
externalRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
@@ -1494,6 +1503,95 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Contains("DoesNotExist", response.Error);
}
[Fact]
public void ExportBundleCommand_WithSiteAndInstanceNames_ResolvesToIds()
{
// M8 (B4): --sites accepts SiteIdentifier (preferred) or Name; --instances
// accepts UniqueName. The handler resolves both to surrogate ids and passes
// them on the ExportSelection.
var (exporter, _) = AddBundleSubstitutes();
var siteRepo = (ISiteRepository)_services.BuildServiceProvider()
.GetRequiredService<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.Site>
{
new("North Plant", "NORTH-01") { Id = 11 },
new("East Plant", "EAST-02") { Id = 22 },
});
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>
{
new("NORTH-01.Pump1") { Id = 101, SiteId = 11, TemplateId = 1 },
new("NORTH-01.Pump2") { Id = 102, SiteId = 11, TemplateId = 1 },
});
Commons.Types.Transport.ExportSelection? captured = null;
exporter.ExportAsync(
Arg.Do<Commons.Types.Transport.ExportSelection>(s => captured = s),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult<Stream>(new MemoryStream(new byte[] { 1, 2, 3 })));
var cmd = new ExportBundleCommand(
All: false,
TemplateNames: null, SharedScriptNames: null,
ExternalSystemNames: null, DatabaseConnectionNames: null,
NotificationListNames: null, SmtpConfigurationNames: null,
ApiMethodNames: null,
IncludeDependencies: false, Passphrase: null,
SourceEnvironment: "test-env",
// mix the SiteIdentifier and the friendly Name to prove the dual-key match.
SiteNames: new[] { "NORTH-01", "East Plant" },
InstanceNames: new[] { "NORTH-01.Pump1" });
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(captured);
Assert.Equal(new[] { 11, 22 }, captured!.SiteIds.OrderBy(x => x));
Assert.Equal(new[] { 101 }, captured.InstanceIds);
}
[Fact]
public void ExportBundleCommand_WithAll_IncludesEverySiteAndInstance()
{
// M8 (B4): All=true ignores per-type name lists and includes every site +
// instance, mirroring how All already includes every template/etc.
var (exporter, _) = AddBundleSubstitutes();
var siteRepo = (ISiteRepository)_services.BuildServiceProvider()
.GetRequiredService<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.Site>
{
new("North Plant", "NORTH-01") { Id = 11 },
new("East Plant", "EAST-02") { Id = 22 },
});
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>
{
new("NORTH-01.Pump1") { Id = 101, SiteId = 11, TemplateId = 1 },
new("EAST-02.Pump9") { Id = 909, SiteId = 22, TemplateId = 1 },
});
Commons.Types.Transport.ExportSelection? captured = null;
exporter.ExportAsync(
Arg.Do<Commons.Types.Transport.ExportSelection>(s => captured = s),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult<Stream>(new MemoryStream(new byte[] { 1 })));
var actor = CreateActor();
actor.Tell(Envelope(AllExportCommand(), "Designer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(captured);
Assert.Equal(new[] { 11, 22 }, captured!.SiteIds.OrderBy(x => x));
Assert.Equal(new[] { 101, 909 }, captured.InstanceIds.OrderBy(x => x));
}
[Fact]
public void ImportBundleCommand_WithBlockerRow_AbortsBeforeApply()
{