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; /// /// Coverage for the Central UI deployment-status page's server-side read path — /// and /// /// (residual R3). /// /// /// These replace a whole-table GetAllDeploymentRecords 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. /// /// /// /// Uses the shared SQLite in-memory fixture. It enforces the /// DeploymentRecord → Instance FK, which is exactly the relationship the /// new query's inner join relies on, so the seeds are real Site/Template/Instance /// rows. /// /// 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 SeedSiteAsync(string name) { var site = new Site(name, name); _context.Sites.Add(site); await _context.SaveChangesAsync(); return site.Id; } private async Task 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 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(); 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(DeploymentRecordSummary.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()), 1, 25); Assert.Equal(0, page.TotalCount); Assert.Empty(page.Rows); } // ── 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); } }