refactor(deployments): one paged read of DeploymentRecords + close its three drifts

Deferred-work register rows 37, 86, 87, 88 — the structural duplication and the
three behavioral divergences it had accumulated, fixed together.

Row 37 (structural). `IDeploymentManagerRepository.QueryDeploymentSummariesAsync`
is DELETED. It and `QueryDeploymentListPageAsync` were two paged reads of the same
insert-only table with different contracts — raw offset vs page number, no total vs
total, no instance join vs joined, instance-id-list scoping vs site-id scoping. The
CLI/HTTP `QueryDeployments` handler now goes through `QueryDeploymentListPageAsync`
with a `DeploymentListFilter`, exactly like the Central UI page. The
`DeploymentRecordSummary` projection went with it (`DeploymentListRow` is the single
row shape); its `MaxPageSize` const moved to `DeploymentListPage`, which also gains
`DefaultPageSize`.

Row 86 (Medium, authorization — the headline). `SiteScopeService.ResolveAsync` was
claims-only, so an Administrator carrying `SiteId` claims saw the whole fleet through
the CLI (`ManagementActor.EnforceSiteScope` has always exempted Administrator) and
only their granted sites on the Deployments page. One user, one dataset, two answers.
Administrator = system-wide is the intended semantic on both sides; the UI was the
defect. The role claim is read explicitly with an OrdinalIgnoreCase comparison rather
than via `IsInRole`, which compares claim VALUES ordinally and only resolves when the
identity carries the matching roleType — the two sides must not disagree on a
differently-cased role name. An Administrator's site claims are not collected at all,
so the invariant "system-wide => empty permitted-id set" holds for every caller.
Designer is NOT exempt: the class doc claimed "any Admin/Design user" is system-wide,
which was true of neither side, so the doc was corrected rather than the bypass
widened.

Row 87 (Low). The scoped branch used to resolve the site grant into an instance-id
list by loading EVERY instance in the system, non-transactionally, on every scoped
query; the grant now rides as `SiteIdScope` and is resolved by the query's own join.
An unparseable permitted-site id is DROPPED, never ignored — dropping can only narrow
a grant, discarding it would widen it to fleet-wide. And `PageSize <= 0` no longer
means UNLIMITED down at the repository (`--page-size 0` materialized the whole
retention window): it means "unspecified" and takes the CLI's own default of 50,
capped at 1000. Bounding without telling would be silent truncation, so the handler
now returns a `DeploymentQueryResult` envelope carrying `totalCount` plus the page
coordinates actually applied — with a CLI table projection (mirroring row 57's
`AuditConfigTableProjection`) that unwraps it and appends a "showing N of M" note.

Row 88 (Low, UX). An unrecognised `--status` fell back to "no filter", so a typo like
`--status Sucess` returned every status — a complete-looking answer to a question the
caller never asked. It is now a `ManagementError` naming the valid values. Parsing
stays case-insensitive; a numeric string is rejected too, since `Enum.TryParse`
accepts "7" and yields an undefined value (guarded with `Enum.IsDefined`). Loud
failure over silent full results, per row 46.

Tests: 7 new `SiteScopeServiceTests` pinning Administrator = system-wide (verified
failing 6/7 against the pre-fix service, the Designer pin passing both ways); the
ManagementActor deployment suite migrated onto the consolidated query with new pins
for the status error, case-insensitive parse, numeric rejection, page-size default/
cap/floor, empty-scope-on-garbage-grant, and "reads no instances at all"; a repository
test for site-scope+status composing over a bounded page; and 6 CLI tests for the new
table projection.
This commit is contained in:
Joseph Doherty
2026-08-17 05:15:18 -04:00
parent 15e5887b52
commit 9f67147e7b
21 changed files with 760 additions and 256 deletions
@@ -0,0 +1,89 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Tests for the <c>deploy status</c> table projection (deferred-work register rows
/// 37/87). The handler now returns a <see cref="DeploymentQueryResult"/> OBJECT where
/// it used to return a bare row array; without a projector the generic renderer takes
/// its Property/Value branch and prints the whole <c>deployments</c> array as raw JSON
/// inside one cell. The projection unwraps the envelope and keeps the total visible as
/// a trailing note — which matters more here than for the audit query, because the
/// page size is bounded server-side and a full page is no longer proof there is
/// nothing more.
/// </summary>
public class DeploymentStatusTableProjectionTests
{
/// <summary>
/// The real server shape, serialised from the REAL <see cref="DeploymentQueryResult"/>
/// / <see cref="DeploymentListRow"/> types rather than hand-written JSON, so renaming
/// one of their properties fails this test instead of silently blanking a column.
/// </summary>
private static string RealPayload(int rowCount, int totalCount) =>
JsonSerializer.Serialize(
new DeploymentQueryResult(
Enumerable.Range(1, rowCount).Select(i => new DeploymentListRow(
i, $"dep-{i:D3}", i, $"Line3.Pump{i}", 2, DeploymentStatus.Success,
$"rev-{i}", "alice",
new DateTimeOffset(2026, 8, 15, 12, 0, 0, TimeSpan.Zero), null, null)).ToList(),
totalCount,
Page: 1,
PageSize: rowCount),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
[Fact]
public void Projection_UnwrapsTheEnvelopeToARowArray()
{
var projected = DeploymentStatusTableProjection.ProjectRows(RealPayload(2, 2));
using var doc = JsonDocument.Parse(projected);
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(2, doc.RootElement.GetArrayLength());
Assert.Equal("dep-001", doc.RootElement[0].GetProperty("deploymentId").GetString());
Assert.Equal("Line3.Pump1", doc.RootElement[0].GetProperty("instanceUniqueName").GetString());
}
[Fact]
public void Projection_WhenPageIsAStrictSubset_AppendsATotalNote()
{
var projected = DeploymentStatusTableProjection.ProjectRows(RealPayload(2, 57));
using var doc = JsonDocument.Parse(projected);
Assert.Equal(3, doc.RootElement.GetArrayLength());
var note = doc.RootElement[2].GetProperty("note").GetString();
Assert.Contains("showing 2 of 57", note);
Assert.Contains("--page-size", note);
}
[Fact]
public void Projection_WhenPageIsTheWholeResult_EmitsNoNoteColumn()
{
var projected = DeploymentStatusTableProjection.ProjectRows(RealPayload(2, 2));
using var doc = JsonDocument.Parse(projected);
Assert.All(doc.RootElement.EnumerateArray(),
e => Assert.False(e.TryGetProperty("note", out _)));
}
[Fact]
public void Projection_AcceptsABareRowArray_FromAPreEnvelopeServer()
{
var projected = DeploymentStatusTableProjection.ProjectRows("""[{"deploymentId":"dep-001"}]""");
using var doc = JsonDocument.Parse(projected);
Assert.Equal("dep-001", Assert.Single(doc.RootElement.EnumerateArray())
.GetProperty("deploymentId").GetString());
}
[Theory]
[InlineData("not json at all")]
[InlineData("""{"error":"boom"}""")]
public void Projection_LeavesUnrecognisedBodiesUntouched(string body)
{
Assert.Equal(body, DeploymentStatusTableProjection.ProjectRows(body));
}
}
@@ -90,4 +90,75 @@ public class SiteScopeServiceTests
Assert.True(await svc.IsSiteAllowedAsync(1));
Assert.True(await svc.IsSiteAllowedAsync(999));
}
// ── Administrator is system-wide regardless of SiteId claims (register 86) ──
//
// ManagementActor has always exempted Administrator from site scoping
// (EnforceSiteScope and every handler that calls it). This service was
// claims-only, so an Administrator who ALSO carried a scoped Deployer grant
// could act fleet-wide through the CLI/HTTP surface while the UI hid the same
// sites. The UI was the side that disagreed with the documented rule.
[Fact]
public async Task AdministratorWithSiteClaims_IsSystemWide()
{
var svc = ForUser(Role("Administrator"), Role("Deployer"), SiteClaim(1), SiteClaim(3));
Assert.True(await svc.IsSystemWideAsync());
}
[Fact]
public async Task AdministratorWithSiteClaims_FilterSites_ReturnsAllSites()
{
var svc = ForUser(Role("Administrator"), Role("Deployer"), SiteClaim(1));
var filtered = await svc.FilterSitesAsync(Sites(1, 2, 3));
Assert.Equal(new[] { 1, 2, 3 }, filtered.Select(s => s.Id));
}
[Fact]
public async Task AdministratorWithSiteClaims_IsSiteAllowed_ForSitesOutsideTheClaims()
{
var svc = ForUser(Role("Administrator"), SiteClaim(1));
Assert.True(await svc.IsSiteAllowedAsync(2));
}
[Fact]
public async Task AdministratorWithSiteClaims_ReportsNoPermittedIds()
{
// The invariant "system-wide => empty id set" must hold for every caller, so
// a consumer that reads the ids without consulting IsSystemWide cannot
// silently narrow an Administrator to a subset of sites.
var svc = ForUser(Role("Administrator"), SiteClaim(1), SiteClaim(3));
Assert.Empty(await svc.PermittedSiteIdsAsync());
}
[Theory]
[InlineData("administrator")]
[InlineData("ADMINISTRATOR")]
public async Task AdministratorRoleMatch_IsCaseInsensitive(string role)
{
// ManagementActor compares roles with OrdinalIgnoreCase. ClaimsPrincipal
// .IsInRole compares claim VALUES ordinally, so matching the actor requires
// reading the role claims explicitly — otherwise the two sides would
// disagree on a differently-cased role name.
var svc = ForUser(Role(role), SiteClaim(1));
Assert.True(await svc.IsSystemWideAsync());
}
[Fact]
public async Task DesignerWithSiteClaims_IsStillScoped()
{
// The class doc used to claim "any Admin/Design user" is system-wide. Only
// Administrator is — on either side of the rule — and the doc was corrected
// rather than the bypass widened.
var svc = ForUser(Role("Designer"), SiteClaim(1));
Assert.False(await svc.IsSystemWideAsync());
Assert.False(await svc.IsSiteAllowedAsync(2));
}
}
@@ -164,7 +164,7 @@ public class DeploymentListQueryRepositoryTests : IDisposable
new DeploymentListFilter(), pageNumber: 1, pageSize: int.MaxValue);
Assert.Single(page.Rows);
Assert.True(DeploymentRecordSummary.MaxPageSize > 0);
Assert.True(DeploymentListPage.MaxPageSize > 0);
}
[Fact]
@@ -300,6 +300,37 @@ public class DeploymentListQueryRepositoryTests : IDisposable
Assert.Empty(page.Rows);
}
[Fact]
public async Task QueryPage_SiteScopeAndStatus_Compose_ForTheCliQueryPath()
{
// Register row 37: this is now the ONLY paged read of DeploymentRecords —
// the CLI/HTTP QueryDeployments command reaches it through the same filter
// the UI page uses, where it used to have a second repository method with
// instance-id-list scoping and no total. Its combination (site scope AND a
// status filter, over more rows than one page holds) is pinned here.
var s1 = await SeedSiteAsync("S1");
var s2 = await SeedSiteAsync("S2");
var a = await SeedInstanceAsync("Inst-A", s1);
var b = await SeedInstanceAsync("Inst-B", s2);
for (var i = 0; i < 4; i++)
{
await SeedRecordAsync($"dep-a{i}", a, DeploymentStatus.Failed, _base.AddMinutes(i));
await SeedRecordAsync($"dep-b{i}", b, DeploymentStatus.Failed, _base.AddMinutes(i));
}
await SeedRecordAsync("dep-a-ok", a, DeploymentStatus.Success, _base.AddHours(1));
var page = await _repository.QueryDeploymentListPageAsync(
new DeploymentListFilter(Status: DeploymentStatus.Failed, SiteIdScope: new[] { s1 }),
pageNumber: 1, pageSize: 2);
// Four in-scope failures exist; the page is bounded to two and the total
// reports the rest, so a bounded page is never mistaken for a complete one.
Assert.Equal(4, page.TotalCount);
Assert.Equal(2, page.Rows.Count);
Assert.All(page.Rows, r => Assert.Equal(s1, r.SiteId));
Assert.All(page.Rows, r => Assert.Equal(DeploymentStatus.Failed, r.Status));
}
// ── Status counts ─────────────────────────────────────────────────────────
[Fact]
@@ -1,3 +1,4 @@
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
@@ -1222,51 +1223,63 @@ public class ManagementActorTests : TestKit, IDisposable
new("deploy-" + instanceId, "operator") { Id = instanceId, InstanceId = instanceId };
/// <summary>
/// Row-summary equivalent of <see cref="DeploymentRecordFor"/>. QueryDeployments
/// now returns DB-side projections instead of whole tracked entities.
/// Row-projection equivalent of <see cref="DeploymentRecordFor"/>, on the site
/// given. QueryDeployments returns DB-side projections joined to their instance,
/// not whole tracked entities — and the join is what carries the
/// <c>SiteId</c> the scope filter is evaluated against.
/// </summary>
private static DeploymentRecordSummary DeploymentSummaryFor(int instanceId) =>
new(instanceId, "deploy-" + instanceId, instanceId, DeploymentStatus.Success,
null, "operator", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null);
private static DeploymentListRow DeploymentRowFor(int instanceId, int siteId) =>
new(instanceId, "deploy-" + instanceId, instanceId, "Pump" + instanceId, siteId,
DeploymentStatus.Success, null, "operator", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null);
/// <summary>
/// Stubs <c>QueryDeploymentSummariesAsync</c> so it applies the same
/// instance / status / scope / paging predicates the database would.
/// Stubs the single paged deployment read so it applies the same
/// instance / status / site-scope / paging predicates the database would.
///
/// <para>
/// The filtering moved OUT of the handler and INTO the query, so a stub that
/// ignored the arguments would make these tests pass no matter what the handler
/// passed down. Honouring them here is what keeps the scope-enforcement
/// assertions meaningful.
/// The filtering lives entirely in the query now (register row 37 folded the
/// handler's own second repository method away), so a stub that ignored the
/// filter would make these tests pass no matter what the handler passed down.
/// Honouring it here is what keeps the scope-enforcement assertions meaningful.
/// </para>
/// </summary>
private static void StubDeploymentSummaryQuery(
/// <param name="repo">The substituted repository to stub.</param>
/// <param name="rows">The seed rows, as (instance id, site id) pairs.</param>
private static void StubDeploymentPageQuery(
IDeploymentManagerRepository repo,
IEnumerable<int> instanceIds)
params (int InstanceId, int SiteId)[] rows)
{
var rows = instanceIds.Select(DeploymentSummaryFor).ToList();
repo.QueryDeploymentSummariesAsync(
Arg.Any<int?>(), Arg.Any<DeploymentStatus?>(), Arg.Any<IReadOnlyCollection<int>?>(),
Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
var all = rows.Select(r => DeploymentRowFor(r.InstanceId, r.SiteId)).ToList();
repo.QueryDeploymentListPageAsync(
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
var instanceId = ci.ArgAt<int?>(0);
var scope = ci.ArgAt<IReadOnlyCollection<int>?>(2);
var skip = ci.ArgAt<int>(3);
var take = ci.ArgAt<int?>(4);
var filter = ci.ArgAt<DeploymentListFilter>(0);
var page = Math.Max(1, ci.ArgAt<int>(1));
var size = Math.Max(1, ci.ArgAt<int>(2));
IEnumerable<DeploymentRecordSummary> q = rows;
if (instanceId.HasValue)
q = q.Where(r => r.InstanceId == instanceId.Value);
if (scope != null)
q = q.Where(r => scope.Contains(r.InstanceId));
q = q.Skip(Math.Max(0, skip));
if (take is > 0)
q = q.Take(take.Value);
return (IReadOnlyList<DeploymentRecordSummary>)q.ToList();
IEnumerable<DeploymentListRow> q = all;
if (filter.InstanceId.HasValue)
q = q.Where(r => r.InstanceId == filter.InstanceId.Value);
if (filter.Status.HasValue)
q = q.Where(r => r.Status == filter.Status.Value);
if (filter.SiteIdScope != null)
q = q.Where(r => filter.SiteIdScope.Contains(r.SiteId));
var matched = q.ToList();
var slice = matched.Skip((page - 1) * size).Take(size).ToList();
return new DeploymentListPage(slice, matched.Count);
});
}
/// <summary>Captures the filter the handler pushed into the paged read.</summary>
private static DeploymentListFilter CapturedDeploymentFilter(IDeploymentManagerRepository repo)
{
var call = repo.ReceivedCalls()
.Single(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.QueryDeploymentListPageAsync));
return (DeploymentListFilter)call.GetArguments()[0]!;
}
/// <summary>
/// Stubs <c>GetTemplateSummariesAsync</c> over <paramref name="count"/> synthetic
/// templates, applying the requested Skip/Take exactly as the database would.
@@ -1305,7 +1318,7 @@ public class ManagementActorTests : TestKit, IDisposable
public void QueryDeployments_UnfilteredWithDeploymentRole_ReturnsAllRecords()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentSummaryQuery(deployRepo, [1, 2]);
StubDeploymentPageQuery(deployRepo, (1, 1), (2, 2));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
@@ -1322,7 +1335,7 @@ public class ManagementActorTests : TestKit, IDisposable
public void QueryDeployments_FilteredByInstanceId_ReturnsInstanceRecords()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentSummaryQuery(deployRepo, [5]);
StubDeploymentPageQuery(deployRepo, (5, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
@@ -1351,8 +1364,7 @@ public class ManagementActorTests : TestKit, IDisposable
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
// The out-of-scope instance's deployment history must not be queried.
deployRepo.DidNotReceiveWithAnyArgs().QueryDeploymentSummariesAsync(
default, default, default, default, default);
deployRepo.DidNotReceiveWithAnyArgs().QueryDeploymentListPageAsync(default!, default, default);
}
[Fact]
@@ -1361,7 +1373,7 @@ public class ManagementActorTests : TestKit, IDisposable
_templateRepo.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>())
.Returns(new Instance("Pump5") { Id = 5, SiteId = 1 });
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentSummaryQuery(deployRepo, [5]);
StubDeploymentPageQuery(deployRepo, (5, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
@@ -1376,17 +1388,8 @@ public class ManagementActorTests : TestKit, IDisposable
[Fact]
public void QueryDeployments_UnfilteredForSiteScopedUser_DropsOutOfScopeRecords()
{
// ManagementService-023: the unfiltered branch now bulk-loads instances
// once via GetAllInstancesAsync (instead of N+1 GetInstanceByIdAsync per
// distinct InstanceId). Mock the bulk path accordingly.
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>
{
new("Pump1") { Id = 1, SiteId = 1 },
new("Pump2") { Id = 2, SiteId = 2 },
});
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentSummaryQuery(deployRepo, [1, 2]);
StubDeploymentPageQuery(deployRepo, (1, 1), (2, 2));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
@@ -1397,28 +1400,19 @@ public class ManagementActorTests : TestKit, IDisposable
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("deploy-1", response.JsonData);
Assert.DoesNotContain("deploy-2", response.JsonData);
// The per-instance lookup must NOT have been used for the unfiltered
// branch — that was the N+1 the bulk load replaced.
_templateRepo.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default);
Assert.Equal(new[] { 1 }, CapturedDeploymentFilter(deployRepo).SiteIdScope);
}
[Fact]
public void QueryDeployments_UnfilteredForSiteScopedUser_UsesBulkInstanceLoad_NotPerRecordLookup()
public void QueryDeployments_UnfilteredForSiteScopedUser_ReadsNoInstancesAtAll()
{
// ManagementService-023 regression pin: the unfiltered branch must issue
// GetAllInstancesAsync ONCE and never call GetInstanceByIdAsync, regardless
// of how many DeploymentRecords reference distinct InstanceIds. Before the
// fix, three distinct instance ids would have produced three per-instance
// lookups (textbook N+1).
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>
{
new("Pump1") { Id = 1, SiteId = 1 },
new("Pump2") { Id = 2, SiteId = 2 },
new("Pump3") { Id = 3, SiteId = 1 },
});
// Register row 87. The scoped branch used to resolve the site grant into an
// instance-id LIST, which meant loading EVERY instance in the system on
// every scoped query (a bulk load that had itself replaced an N+1 of
// per-record lookups). The grant now rides as a site-id filter resolved by
// the query's own join, so neither instance read may happen.
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentSummaryQuery(deployRepo, [1, 2, 3]);
StubDeploymentPageQuery(deployRepo, (1, 1), (2, 2), (3, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
@@ -1427,17 +1421,41 @@ public class ManagementActorTests : TestKit, IDisposable
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_templateRepo.Received(1).GetAllInstancesAsync(Arg.Any<CancellationToken>());
_templateRepo.DidNotReceiveWithAnyArgs().GetAllInstancesAsync();
_templateRepo.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default);
}
[Fact]
public void QueryDeployments_SiteScopedUserWithUnparseableGrant_SeesNothing()
{
// A permitted-site id that is not an integer is dropped, never ignored:
// ignoring it would silently widen the grant to fleet-wide. A grant of
// nothing but garbage therefore yields an EMPTY scope — a real "sees
// nothing" filter, not "no filter".
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1), (2, 2));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "not-an-id" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.DoesNotContain("deploy-1", response.JsonData);
Assert.DoesNotContain("deploy-2", response.JsonData);
Assert.Empty(CapturedDeploymentFilter(deployRepo).SiteIdScope!);
}
[Fact]
public void QueryDeployments_UnfilteredForAdminUser_ReturnsAllRecords()
{
// Admin role bypasses site scoping even with PermittedSiteIds set.
// (The user also holds Deployment so it passes the role gate.)
// The UI's SiteScopeService now agrees — see SiteScopeServiceTests
// (register row 86).
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentSummaryQuery(deployRepo, [1, 2]);
StubDeploymentPageQuery(deployRepo, (1, 1), (2, 2));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
@@ -1448,6 +1466,119 @@ public class ManagementActorTests : TestKit, IDisposable
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("deploy-1", response.JsonData);
Assert.Contains("deploy-2", response.JsonData);
Assert.Null(CapturedDeploymentFilter(deployRepo).SiteIdScope);
}
// ── Loud status parsing + bounded page size (register rows 87 / 88) ────────
[Fact]
public void QueryDeployments_UnknownStatus_FailsLoudlyNamingTheValidValues()
{
// Register row 88: `--status Sucess` used to fall back to "no filter" and
// return EVERY status — a plausible, complete-looking answer to a question
// the caller did not ask.
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(Status: "Sucess"), "Deployer"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("Sucess", response.Error);
Assert.Contains("Success", response.Error);
Assert.Contains("Failed", response.Error);
deployRepo.DidNotReceiveWithAnyArgs().QueryDeploymentListPageAsync(default!, default, default);
}
[Theory]
[InlineData("success")]
[InlineData("SUCCESS")]
public void QueryDeployments_KnownStatus_IsCaseInsensitive(string status)
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(Status: status), "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(DeploymentStatus.Success, CapturedDeploymentFilter(deployRepo).Status);
}
[Fact]
public void QueryDeployments_NumericStatus_IsRejected()
{
// Enum.TryParse happily accepts "7" and yields an undefined enum value, so
// the parse needs the IsDefined guard as well as the TryParse.
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(Status: "7"), "Deployer"));
ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void QueryDeployments_NonPositivePageSize_TakesTheDefault_NotUnlimited(int requested)
{
// Register row 87: a non-positive page size used to mean "unlimited" all the
// way down to the repository, materializing the whole retention window.
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, Enumerable.Range(1, 200).Select(i => (i, 1)).ToArray());
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(PageSize: requested), "Deployer"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var call = deployRepo.ReceivedCalls()
.Single(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.QueryDeploymentListPageAsync));
Assert.Equal(DeploymentListPage.DefaultPageSize, (int)call.GetArguments()[2]!);
// The total travels with the bounded page, so a caller can tell a truncated
// page from a complete one.
var result = JsonSerializer.Deserialize<JsonElement>(response.JsonData);
Assert.Equal(200, result.GetProperty("totalCount").GetInt32());
Assert.Equal(DeploymentListPage.DefaultPageSize, result.GetProperty("deployments").GetArrayLength());
Assert.Equal(DeploymentListPage.DefaultPageSize, result.GetProperty("pageSize").GetInt32());
}
[Fact]
public void QueryDeployments_OversizedPageSize_IsCappedAtTheMaximum()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(PageSize: int.MaxValue), "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var call = deployRepo.ReceivedCalls()
.Single(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.QueryDeploymentListPageAsync));
Assert.Equal(DeploymentListPage.MaxPageSize, (int)call.GetArguments()[2]!);
}
[Fact]
public void QueryDeployments_PageIsFlooredAtOne()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(Page: 0), "Deployer"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("deploy-1", response.JsonData);
var result = JsonSerializer.Deserialize<JsonElement>(response.JsonData);
Assert.Equal(1, result.GetProperty("page").GetInt32());
}
// ========================================================================