merge(agent-a): rows 37/86/87/88 — deployment reads consolidated on QueryDeploymentListPageAsync (QueryDeploymentSummariesAsync deleted); SiteScopeService honors the Administrator system-wide rule (claims no longer scope admins — the documented ManagementActor semantic now holds on both surfaces); site scope rides the query join (whole-table instance read gone), PageSize bounded (default 50, cap 1000); unparseable --status fails loudly naming valid values

This commit is contained in:
Joseph Doherty
2026-08-17 05:28:51 -04:00
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());
}
// ========================================================================