using System.Net.Http.Json; using ItWorx.KnowledgeHub.Contracts.Auth; using ItWorx.KnowledgeHub.Contracts.Infrastructure; using ItWorx.KnowledgeHub.Contracts.Projects; using ItWorx.KnowledgeHub.Infrastructure.Persistence; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Npgsql; namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge; /// /// V2-R5 (docs/adr/ADR-029-v2-r5-final-hardening.md, brief Phase 4): `GET /// /infrastructure/storage` was a fully unbounded, unpaginated listing, and /// `GET /infrastructure/services` had no server-side project filter — the /// Project workspace fetched up to 200 services and filtered by project client-side. /// Proves the fix: real server-side cursor pagination (no duplicates, no gaps, stable /// across pages), a server-side text search, and a server-side project filter. /// [Collection(PostgresTestGroup.Name)] public sealed class InfrastructurePaginationTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres) { private const int VolumeCount = 130; [Fact] public async Task Storage_pages_through_every_volume_exactly_once_with_no_duplicates_or_gaps() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); await SeedVolumesDirectlyAsync(); var seenIds = new HashSet(); string? cursor = null; var pageCount = 0; do { var url = "/api/v1/infrastructure/storage?limit=20" + (cursor is null ? "" : $"&cursor={Uri.EscapeDataString(cursor)}"); var page = await client.GetFromJsonAsync(url); Assert.NotNull(page); Assert.True(page.Items.Count <= 20); foreach (var item in page.Items) { Assert.True(seenIds.Add(item.Id), $"Volume {item.Id} appeared on more than one page."); } cursor = page.NextCursor; pageCount++; Assert.True(pageCount < 50, "Too many pages — likely a pagination bug (cursor never advances / never terminates)."); } while (cursor is not null); Assert.Equal(VolumeCount, seenIds.Count); Assert.True(pageCount > 1, "Expected more than one page at this seeded volume count."); } [Fact] public async Task Storage_search_filters_server_side_not_only_within_the_first_loaded_page() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); await SeedVolumesDirectlyAsync(); // "schaal-service-00099" (a service near the end of the seeded range, unlikely // to appear on a single default-sized first page) must still be found — proving // the search runs server-side across the whole table, not client-side over // whatever page happened to load first. var result = await client.GetFromJsonAsync( "/api/v1/infrastructure/storage?search=schaal-service-00099&limit=20"); Assert.NotNull(result); Assert.Single(result.Items); Assert.Contains("schaal-service-00099", result.Items[0].ServiceName); Assert.Null(result.NextCursor); } [Fact] public async Task Services_search_filters_server_side_not_only_within_the_first_loaded_page() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); await SeedVolumesDirectlyAsync(); var result = await client.GetFromJsonAsync( "/api/v1/infrastructure/services?includeHidden=true&search=schaal-service-00099&limit=20"); Assert.NotNull(result); Assert.Single(result.Items); Assert.Contains("schaal-service-00099", result.Items[0].Name); Assert.Null(result.NextCursor); } [Fact] public async Task Services_can_be_filtered_by_project_entirely_server_side() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var (workspaceId, allServiceIds) = await SeedVolumesDirectlyAsync(); var session = await client.GetFromJsonAsync("/api/v1/auth/session"); Assert.NotNull(session?.UserId); var projectResponse = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/projects", new CreateProjectRequest( $"Paginatie-project {Guid.NewGuid():N}", "Testproject voor paginatiedekking.", "Medium", "# Status", session.UserId.Value)); Assert.Equal(System.Net.HttpStatusCode.OK, projectResponse.StatusCode); var project = await projectResponse.Content.ReadFromJsonAsync(); Assert.NotNull(project); var projectId = project.Id; // Link exactly 3 of the 130 seeded services to this project through the real // editorial-profile endpoint — proving the filter is a real server-side WHERE // against genuine domain state, not a client-side slice of an unfiltered page. var linkedIds = allServiceIds.Take(3).ToArray(); foreach (var serviceId in linkedIds) { var response = await SetupHelper.PutWithCsrfAsync( client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", new { isFavorite = false, isHidden = false, isArchived = false, projectId }, "0"); Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); } _ = workspaceId; var filtered = await client.GetFromJsonAsync( $"/api/v1/infrastructure/services?includeHidden=true&projectId={projectId}&limit=200"); Assert.NotNull(filtered); Assert.Equal(3, filtered.Items.Count); Assert.Equal(linkedIds.ToHashSet(), filtered.Items.Select(item => item.Id).ToHashSet()); } [Fact] public async Task Services_status_filter_is_applied_before_cursor_pagination() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var (_, serviceIds) = await SeedVolumesDirectlyAsync(); using (var scope = Factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.ExecuteSqlInterpolatedAsync( $"UPDATE infrastructure_services SET health = {(short)ItWorx.KnowledgeHub.Domain.Infrastructure.HealthState.Unhealthy} WHERE id = {serviceIds[^1]}"); } // The unhealthy service is deliberately the final seeded row. A server-side // WHERE is required: a local filter over a first page could never find it. var result = await client.GetFromJsonAsync( "/api/v1/infrastructure/services?includeHidden=true&status=attention&limit=20"); Assert.NotNull(result); Assert.Single(result.Items); Assert.Equal(serviceIds[^1], result.Items[0].Id); } [Fact] public async Task Services_report_the_filtered_total_not_the_size_of_the_returned_page() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var (_, serviceIds) = await SeedVolumesDirectlyAsync(); using (var scope = Factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.ExecuteSqlInterpolatedAsync( $"UPDATE infrastructure_services SET health = {(short)ItWorx.KnowledgeHub.Domain.Infrastructure.HealthState.Unhealthy} WHERE id = {serviceIds[^1]}"); } // A deliberately small page over a deliberately large catalog: the operator // must be told how many services exist behind the filter, not how many fitted // on the first page. var firstPage = await client.GetFromJsonAsync( "/api/v1/infrastructure/services?includeHidden=true&limit=10"); Assert.NotNull(firstPage); Assert.Equal(10, firstPage.Items.Count); Assert.NotNull(firstPage.NextCursor); Assert.Equal(serviceIds.Count, firstPage.TotalCount); // The total tracks the filter, and stays stable while paging through it. var attention = await client.GetFromJsonAsync( "/api/v1/infrastructure/services?includeHidden=true&status=attention&limit=10"); Assert.NotNull(attention); Assert.Equal(1, attention.TotalCount); var secondPage = await client.GetFromJsonAsync( $"/api/v1/infrastructure/services?includeHidden=true&limit=10&cursor={Uri.EscapeDataString(firstPage.NextCursor!)}"); Assert.NotNull(secondPage); Assert.Equal(serviceIds.Count, secondPage.TotalCount); } private async Task<(Guid WorkspaceId, IReadOnlyList ServiceIds)> SeedVolumesDirectlyAsync() { using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var workspace = await db.Workspaces.AsNoTracking().FirstAsync(); var workspaceId = workspace.Id; var connectionOptions = scope.ServiceProvider.GetRequiredService(); await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString); await connection.OpenAsync(); var snapshotId = Guid.NewGuid(); const string servicesSql = """ INSERT INTO infrastructure_services ( id, workspace_id, host_id, source_key, detected_name, runtime_kind, runtime_status, health, image_reference, image_digest, runtime_user, read_only_root_filesystem, no_new_privileges, privileged, presence, confidence, last_snapshot_id, first_seen_at, last_seen_at) SELECT gen_random_uuid(), @workspaceId, NULL, 'docker:schaal-service-' || n, 'schaal-service-' || lpad(n::text, 5, '0'), 'container', 'running', 1, 'itworx/schaal:' || n, '', '', false, false, false, 1, 2, @snapshotId, now(), now() FROM generate_series(1, @count) AS n RETURNING id """; var serviceIds = new List(); await using (var command = new NpgsqlCommand(servicesSql, connection) { CommandTimeout = 60 }) { command.Parameters.AddWithValue("workspaceId", workspaceId); command.Parameters.AddWithValue("snapshotId", snapshotId); command.Parameters.AddWithValue("count", VolumeCount); await using var reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) { serviceIds.Add(reader.GetGuid(0)); } } const string volumesSql = """ INSERT INTO infrastructure_volumes ( id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at, name, mount_path, storage_class, read_only, size_bytes) SELECT gen_random_uuid(), @workspaceId, s.id, 'volume:main', 2, @snapshotId, now(), 'data', '/data', '', false, NULL FROM infrastructure_services s WHERE s.workspace_id = @workspaceId """; await using (var command = new NpgsqlCommand(volumesSql, connection) { CommandTimeout = 60 }) { command.Parameters.AddWithValue("workspaceId", workspaceId); command.Parameters.AddWithValue("snapshotId", snapshotId); await command.ExecuteNonQueryAsync(); } return (workspaceId, serviceIds); } }