9f67147e7b
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.
407 lines
17 KiB
C#
407 lines
17 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
|
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.Types.Deployment;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
|
|
|
/// <summary>
|
|
/// Coverage for the Central UI deployment-status page's server-side read path —
|
|
/// <see cref="DeploymentManagerRepository.QueryDeploymentListPageAsync"/> and
|
|
/// <see cref="DeploymentManagerRepository.GetDeploymentStatusCountsAsync"/>
|
|
/// (residual R3).
|
|
///
|
|
/// <para>
|
|
/// These replace a whole-table <c>GetAllDeploymentRecords</c> read whose only
|
|
/// caller then site-scoped, sorted, counted the status tiles and sliced a page in
|
|
/// the Blazor circuit's memory. Every one of those jobs is asserted here to happen
|
|
/// in SQL instead: the filter dimensions, the total count, the deterministic
|
|
/// ordering that makes offset paging safe, and the grouped tile aggregation.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Uses the shared SQLite in-memory fixture. It enforces the
|
|
/// <c>DeploymentRecord → Instance</c> FK, which is exactly the relationship the
|
|
/// new query's inner join relies on, so the seeds are real Site/Template/Instance
|
|
/// rows.
|
|
/// </para>
|
|
/// </summary>
|
|
public class DeploymentListQueryRepositoryTests : IDisposable
|
|
{
|
|
private readonly ScadaBridgeDbContext _context;
|
|
private readonly DeploymentManagerRepository _repository;
|
|
private readonly DateTimeOffset _base = new(2026, 8, 15, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
public DeploymentListQueryRepositoryTests()
|
|
{
|
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
|
_repository = new DeploymentManagerRepository(_context);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_context.Database.CloseConnection();
|
|
_context.Dispose();
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
private async Task<int> SeedSiteAsync(string name)
|
|
{
|
|
var site = new Site(name, name);
|
|
_context.Sites.Add(site);
|
|
await _context.SaveChangesAsync();
|
|
return site.Id;
|
|
}
|
|
|
|
private async Task<int> SeedInstanceAsync(string uniqueName, int siteId)
|
|
{
|
|
var template = new Template($"T-{uniqueName}");
|
|
_context.Templates.Add(template);
|
|
await _context.SaveChangesAsync();
|
|
|
|
var instance = new Instance(uniqueName) { SiteId = siteId, TemplateId = template.Id };
|
|
_context.Instances.Add(instance);
|
|
await _context.SaveChangesAsync();
|
|
return instance.Id;
|
|
}
|
|
|
|
private async Task<DeploymentRecord> SeedRecordAsync(
|
|
string deploymentId,
|
|
int instanceId,
|
|
DeploymentStatus status,
|
|
DateTimeOffset deployedAt,
|
|
string deployedBy = "alice",
|
|
string? revisionHash = null)
|
|
{
|
|
var record = new DeploymentRecord(deploymentId, deployedBy)
|
|
{
|
|
InstanceId = instanceId,
|
|
Status = status,
|
|
DeployedAt = deployedAt,
|
|
RevisionHash = revisionHash
|
|
};
|
|
_context.DeploymentRecords.Add(record);
|
|
await _context.SaveChangesAsync();
|
|
return record;
|
|
}
|
|
|
|
// ── Paging ────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task QueryPage_ReturnsRequestedSlice_AndTheTotalCountOfTheWholeSet()
|
|
{
|
|
var siteId = await SeedSiteAsync("S1");
|
|
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
|
for (var i = 0; i < 25; i++)
|
|
{
|
|
await SeedRecordAsync($"dep-{i:D3}", instanceId, DeploymentStatus.Success, _base.AddMinutes(i));
|
|
}
|
|
|
|
var page2 = await _repository.QueryDeploymentListPageAsync(
|
|
new DeploymentListFilter(), pageNumber: 2, pageSize: 10);
|
|
|
|
Assert.Equal(25, page2.TotalCount);
|
|
Assert.Equal(10, page2.Rows.Count);
|
|
Assert.Equal(3, page2.PageCount(10));
|
|
|
|
// Newest first: page 2 of 10 starts at the 11th newest, dep-014.
|
|
Assert.Equal("dep-014", page2.Rows[0].DeploymentId);
|
|
Assert.Equal("dep-005", page2.Rows[^1].DeploymentId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QueryPage_PagesDoNotOverlapOrDropRows_EvenWhenDeployedAtTies()
|
|
{
|
|
// Rapid redeploys write several records on the same clock tick. Without the
|
|
// Id tie-break the sort key is unstable and offset paging repeats or drops
|
|
// rows between pages — the failure mode the ThenByDescending(Id) prevents.
|
|
var siteId = await SeedSiteAsync("S1");
|
|
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
|
for (var i = 0; i < 9; i++)
|
|
{
|
|
await SeedRecordAsync($"dep-{i:D3}", instanceId, DeploymentStatus.Success, _base);
|
|
}
|
|
|
|
var seen = new List<string>();
|
|
for (var page = 1; page <= 3; page++)
|
|
{
|
|
var result = await _repository.QueryDeploymentListPageAsync(
|
|
new DeploymentListFilter(), pageNumber: page, pageSize: 3);
|
|
seen.AddRange(result.Rows.Select(r => r.DeploymentId));
|
|
}
|
|
|
|
Assert.Equal(9, seen.Count);
|
|
Assert.Equal(9, seen.Distinct().Count());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QueryPage_NonPositivePageNumber_IsFlooredAtPageOne()
|
|
{
|
|
var siteId = await SeedSiteAsync("S1");
|
|
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
|
await SeedRecordAsync("dep-000", instanceId, DeploymentStatus.Success, _base);
|
|
|
|
var page = await _repository.QueryDeploymentListPageAsync(
|
|
new DeploymentListFilter(), pageNumber: 0, pageSize: 10);
|
|
|
|
Assert.Single(page.Rows);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QueryPage_OversizedPageSize_IsClampedToTheMaximum()
|
|
{
|
|
var siteId = await SeedSiteAsync("S1");
|
|
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
|
await SeedRecordAsync("dep-000", instanceId, DeploymentStatus.Success, _base);
|
|
|
|
// A caller asking for int.MaxValue rows must not be able to turn the paged
|
|
// read back into the whole-table read it replaced.
|
|
var page = await _repository.QueryDeploymentListPageAsync(
|
|
new DeploymentListFilter(), pageNumber: 1, pageSize: int.MaxValue);
|
|
|
|
Assert.Single(page.Rows);
|
|
Assert.True(DeploymentListPage.MaxPageSize > 0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QueryPage_PastTheEnd_ReturnsNoRowsButStillReportsTheTotal()
|
|
{
|
|
var siteId = await SeedSiteAsync("S1");
|
|
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
|
await SeedRecordAsync("dep-000", instanceId, DeploymentStatus.Success, _base);
|
|
|
|
var page = await _repository.QueryDeploymentListPageAsync(
|
|
new DeploymentListFilter(), pageNumber: 9, pageSize: 25);
|
|
|
|
Assert.Empty(page.Rows);
|
|
Assert.Equal(1, page.TotalCount);
|
|
}
|
|
|
|
// ── Joined projection ─────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task QueryPage_JoinsTheInstance_SoNameAndSiteTravelWithTheRow()
|
|
{
|
|
var siteId = await SeedSiteAsync("S1");
|
|
var instanceId = await SeedInstanceAsync("Line3.Filler", siteId);
|
|
await SeedRecordAsync("dep-000", instanceId, DeploymentStatus.Failed, _base,
|
|
deployedBy: "bob", revisionHash: "abc123");
|
|
|
|
var row = Assert.Single(
|
|
(await _repository.QueryDeploymentListPageAsync(new DeploymentListFilter(), 1, 25)).Rows);
|
|
|
|
Assert.Equal("Line3.Filler", row.InstanceUniqueName);
|
|
Assert.Equal(siteId, row.SiteId);
|
|
Assert.Equal(instanceId, row.InstanceId);
|
|
Assert.Equal("dep-000", row.DeploymentId);
|
|
Assert.Equal(DeploymentStatus.Failed, row.Status);
|
|
Assert.Equal("bob", row.DeployedBy);
|
|
Assert.Equal("abc123", row.RevisionHash);
|
|
}
|
|
|
|
// ── Filters ───────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task QueryPage_StatusFilter_IsAppliedInTheDatabase()
|
|
{
|
|
var siteId = await SeedSiteAsync("S1");
|
|
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
|
await SeedRecordAsync("dep-ok", instanceId, DeploymentStatus.Success, _base);
|
|
await SeedRecordAsync("dep-bad", instanceId, DeploymentStatus.Failed, _base.AddMinutes(1));
|
|
|
|
var page = await _repository.QueryDeploymentListPageAsync(
|
|
new DeploymentListFilter(Status: DeploymentStatus.Failed), 1, 25);
|
|
|
|
Assert.Equal(1, page.TotalCount);
|
|
Assert.Equal("dep-bad", Assert.Single(page.Rows).DeploymentId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QueryPage_InstanceFilter_RestrictsToOneInstanceHistory()
|
|
{
|
|
var siteId = await SeedSiteAsync("S1");
|
|
var a = await SeedInstanceAsync("Inst-A", siteId);
|
|
var b = await SeedInstanceAsync("Inst-B", siteId);
|
|
await SeedRecordAsync("dep-a", a, DeploymentStatus.Success, _base);
|
|
await SeedRecordAsync("dep-b", b, DeploymentStatus.Success, _base);
|
|
|
|
var page = await _repository.QueryDeploymentListPageAsync(
|
|
new DeploymentListFilter(InstanceId: b), 1, 25);
|
|
|
|
Assert.Equal("dep-b", Assert.Single(page.Rows).DeploymentId);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Filler", "dep-a")] // instance unique name
|
|
[InlineData("dep-b", "dep-b")] // deployment id
|
|
[InlineData("carol", "dep-b")] // initiating user
|
|
[InlineData("rev-a", "dep-a")] // revision hash
|
|
public async Task QueryPage_Search_MatchesAcrossTheFindableIdentifiers(string term, string expected)
|
|
{
|
|
var siteId = await SeedSiteAsync("S1");
|
|
var a = await SeedInstanceAsync("Line3.Filler", siteId);
|
|
var b = await SeedInstanceAsync("Line4.Capper", siteId);
|
|
await SeedRecordAsync("dep-a", a, DeploymentStatus.Success, _base, deployedBy: "alice", revisionHash: "rev-a1");
|
|
await SeedRecordAsync("dep-b", b, DeploymentStatus.Success, _base.AddMinutes(1), deployedBy: "carol", revisionHash: "rev-b1");
|
|
|
|
var page = await _repository.QueryDeploymentListPageAsync(
|
|
new DeploymentListFilter(Search: term), 1, 25);
|
|
|
|
Assert.Equal(expected, Assert.Single(page.Rows).DeploymentId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QueryPage_WhitespaceSearch_IsTreatedAsUnconstrained()
|
|
{
|
|
var siteId = await SeedSiteAsync("S1");
|
|
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
|
await SeedRecordAsync("dep-000", instanceId, DeploymentStatus.Success, _base);
|
|
|
|
var page = await _repository.QueryDeploymentListPageAsync(
|
|
new DeploymentListFilter(Search: " "), 1, 25);
|
|
|
|
Assert.Equal(1, page.TotalCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QueryPage_SiteScope_RestrictsToTheGrantedSites()
|
|
{
|
|
var s1 = await SeedSiteAsync("S1");
|
|
var s2 = await SeedSiteAsync("S2");
|
|
var a = await SeedInstanceAsync("Inst-A", s1);
|
|
var b = await SeedInstanceAsync("Inst-B", s2);
|
|
await SeedRecordAsync("dep-a", a, DeploymentStatus.Success, _base);
|
|
await SeedRecordAsync("dep-b", b, DeploymentStatus.Success, _base.AddMinutes(1));
|
|
|
|
var page = await _repository.QueryDeploymentListPageAsync(
|
|
new DeploymentListFilter(SiteIdScope: new[] { s2 }), 1, 25);
|
|
|
|
Assert.Equal(1, page.TotalCount);
|
|
Assert.Equal("dep-b", Assert.Single(page.Rows).DeploymentId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QueryPage_EmptySiteScope_IsARealFilterThatMatchesNothing()
|
|
{
|
|
// A scoped user granted no sites must see nothing — an empty scope must NOT
|
|
// degrade into "unconstrained", which would leak every site's deployments.
|
|
var siteId = await SeedSiteAsync("S1");
|
|
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
|
await SeedRecordAsync("dep-000", instanceId, DeploymentStatus.Success, _base);
|
|
|
|
var page = await _repository.QueryDeploymentListPageAsync(
|
|
new DeploymentListFilter(SiteIdScope: Array.Empty<int>()), 1, 25);
|
|
|
|
Assert.Equal(0, page.TotalCount);
|
|
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]
|
|
public async Task StatusCounts_GroupsEveryStatus_WithAbsentOnesReportedAsZero()
|
|
{
|
|
var siteId = await SeedSiteAsync("S1");
|
|
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
|
await SeedRecordAsync("dep-1", instanceId, DeploymentStatus.Success, _base);
|
|
await SeedRecordAsync("dep-2", instanceId, DeploymentStatus.Success, _base.AddMinutes(1));
|
|
await SeedRecordAsync("dep-3", instanceId, DeploymentStatus.Failed, _base.AddMinutes(2));
|
|
await SeedRecordAsync("dep-4", instanceId, DeploymentStatus.InProgress, _base.AddMinutes(3));
|
|
|
|
var counts = await _repository.GetDeploymentStatusCountsAsync(new DeploymentListFilter());
|
|
|
|
Assert.Equal(0, counts.Pending);
|
|
Assert.Equal(1, counts.InProgress);
|
|
Assert.Equal(2, counts.Success);
|
|
Assert.Equal(1, counts.Failed);
|
|
Assert.Equal(4, counts.Total);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StatusCounts_IgnoreTheStatusFilter_SoEachTileKeepsItsOwnTotal()
|
|
{
|
|
// The tiles are the status BREAKDOWN of the filtered set. Honouring the
|
|
// active status filter here would zero the other three tiles the moment an
|
|
// operator clicked one.
|
|
var siteId = await SeedSiteAsync("S1");
|
|
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
|
await SeedRecordAsync("dep-1", instanceId, DeploymentStatus.Success, _base);
|
|
await SeedRecordAsync("dep-2", instanceId, DeploymentStatus.Failed, _base.AddMinutes(1));
|
|
|
|
var counts = await _repository.GetDeploymentStatusCountsAsync(
|
|
new DeploymentListFilter(Status: DeploymentStatus.Failed));
|
|
|
|
Assert.Equal(1, counts.Success);
|
|
Assert.Equal(1, counts.Failed);
|
|
Assert.Equal(2, counts.Total);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StatusCounts_HonourEveryOtherFilterDimension()
|
|
{
|
|
var s1 = await SeedSiteAsync("S1");
|
|
var s2 = await SeedSiteAsync("S2");
|
|
var a = await SeedInstanceAsync("Inst-A", s1);
|
|
var b = await SeedInstanceAsync("Inst-B", s2);
|
|
await SeedRecordAsync("dep-a1", a, DeploymentStatus.Success, _base);
|
|
await SeedRecordAsync("dep-a2", a, DeploymentStatus.Failed, _base.AddMinutes(1));
|
|
await SeedRecordAsync("dep-b1", b, DeploymentStatus.Success, _base.AddMinutes(2));
|
|
|
|
var scoped = await _repository.GetDeploymentStatusCountsAsync(
|
|
new DeploymentListFilter(SiteIdScope: new[] { s1 }));
|
|
Assert.Equal(2, scoped.Total);
|
|
|
|
var searched = await _repository.GetDeploymentStatusCountsAsync(
|
|
new DeploymentListFilter(Search: "Inst-B"));
|
|
Assert.Equal(1, searched.Total);
|
|
Assert.Equal(1, searched.Success);
|
|
|
|
var byInstance = await _repository.GetDeploymentStatusCountsAsync(
|
|
new DeploymentListFilter(InstanceId: a));
|
|
Assert.Equal(2, byInstance.Total);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StatusCounts_EmptySet_IsAllZeros()
|
|
{
|
|
var counts = await _repository.GetDeploymentStatusCountsAsync(new DeploymentListFilter());
|
|
|
|
Assert.Equal(DeploymentStatusCounts.Empty, counts);
|
|
}
|
|
}
|