using System.Diagnostics; using System.Globalization; using System.Net.Http.Json; using System.Text; using ItWorx.KnowledgeHub.Contracts.UnifiedSearch; using ItWorx.KnowledgeHub.Domain.Knowledge; using ItWorx.KnowledgeHub.Domain.Projects; using ItWorx.KnowledgeHub.Infrastructure.Persistence; using ItWorx.KnowledgeHub.Infrastructure.UnifiedSearch; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using SortKey = ItWorx.KnowledgeHub.Domain.Knowledge.SortKey; namespace ItWorx.KnowledgeHub.IntegrationTests.UnifiedSearch; [Collection(PostgresTestGroup.Name)] [Trait("Category", "ReleaseScale")] public sealed class UnifiedSearchScaleTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres) { [Fact] public async Task Unified_search_counts_and_cursor_pages_stay_bounded_with_10000_pages_and_2000_projects() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var workspaceId = await db.Workspaces.Select(item => item.Id).SingleAsync(); var actorId = await db.Users.Select(item => item.Id).SingleAsync(); var now = DateTimeOffset.UtcNow; var sortKey = SortKey.First(); for (var offset = 0; offset < 10_000; offset += 500) { for (var index = offset; index < offset + 500; index++) { var title = index == 0 ? "Measure uniqueneedle page" : $"Measure page {index:D5}"; var page = Page.Create(workspaceId, null, sortKey, PageTitle.Create(title), DocumentType.WikiPage, "Measure synthetic documentation with a bounded result snippet.", actorId, now); page.ReplaceTags(["platform-scale"]); db.Pages.Add(page); sortKey = SortKey.Next(sortKey); } await db.SaveChangesAsync(); db.ChangeTracker.Clear(); } for (var offset = 0; offset < 2_000; offset += 500) { for (var index = offset; index < offset + 500; index++) { var title = index == 0 ? "Measure uniqueneedle project" : $"Measure project {index:D5}"; db.Projects.Add(Project.Create(workspaceId, title, "Measure synthetic project summary.", ProjectPriority.Medium, "Measure project state.", actorId, actorId, now)); } await db.SaveChangesAsync(); db.ChangeTracker.Clear(); } await db.Database.ExecuteSqlRawAsync("ANALYZE pages; ANALYZE projects;"); var repository = new UnifiedSearchRepository(db); var first = await repository.SearchAsync(workspaceId, "measure", null, null, null, 50, CancellationToken.None); Assert.Equal(10_000, first.Counts.Knowledge); Assert.Equal(2_000, first.Counts.Projects); Assert.Equal(50, first.Items.Count); Assert.NotNull(first.NextCursor); var next = await repository.SearchAsync(workspaceId, "measure", null, null, first.NextCursor, 50, CancellationToken.None); Assert.Equal(first.Counts, next.Counts); Assert.Equal(50, next.Items.Count); Assert.Empty(first.Items.Select(item => item.Id).Intersect(next.Items.Select(item => item.Id))); var report = new StringBuilder(); report.AppendLine(CultureInfo.InvariantCulture, $"Unified search scale — {DateTimeOffset.UtcNow:O}; 10000 pages, 2000 projects; 30 warm samples per query."); foreach (var query in new[] { "measure", "uniqueneedle" }) { var samples = new List(); for (var sample = 0; sample < 30; sample++) { var watch = Stopwatch.StartNew(); var result = await repository.SearchAsync(workspaceId, query, null, null, null, 50, CancellationToken.None); watch.Stop(); Assert.Equal(query == "measure" ? 10_000 : 1, result.Counts.Knowledge); Assert.Equal(query == "measure" ? 2_000 : 1, result.Counts.Projects); Assert.InRange(result.Items.Count, 1, 50); samples.Add(watch.Elapsed.TotalMilliseconds); } samples.Sort(); var p95 = samples[28]; report.AppendLine(CultureInfo.InvariantCulture, $"{query}: p50={samples[14]:F2}ms p95={p95:F2}ms max={samples[^1]:F2}ms"); // Same broad correctness/latency budget as existing scale gates; this // exercises the new grouped-count query rather than the legacy search. Assert.True(p95 < 3_000, $"Unified search p95 for '{query}' was {p95:F2}ms (budget 3000ms)."); } using var response = await client.GetAsync("/api/v1/unified-search?q=measure&limit=50"); response.EnsureSuccessStatusCode(); var bytes = await response.Content.ReadAsByteArrayAsync(); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.Equal(50, body.Items.Count); Assert.Equal(12_000, body.Counts.Total); Assert.True(bytes.Length < 100_000, $"One 50-item search page contained {bytes.Length} bytes."); report.AppendLine(CultureInfo.InvariantCulture, $"HTTP 50-item response: {bytes.Length} bytes; total=12000; no duplicate IDs across cursor pages."); Directory.CreateDirectory("artifacts/performance"); await File.WriteAllTextAsync("artifacts/performance/platform-unified-search-scale.txt", report.ToString()); } }