using System.Net; using System.Net.Http.Json; using ItWorx.KnowledgeHub.Contracts.Knowledge; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge; /// PostgreSQL full-text + trigram search: title/body matching, tag/type /// filters, typo tolerance, trash exclusion and authentication /// (docs/27-acceptance-matrix.md, M3-A04..A06). [Collection(PostgresTestGroup.Name)] public sealed class SearchTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres) { [Fact] public async Task Search_finds_a_page_by_title_and_by_body_text() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "PostgreSQL back-uprunbook", "Runbook", "Stappen om een back-up te herstellen.", null)); await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "Onderwerp zonder verband", "WikiPage", "Volledig andere inhoud.", null)); var byTitle = await client.GetFromJsonAsync("/api/v1/search?q=back-uprunbook"); Assert.NotNull(byTitle); Assert.Contains(byTitle.Items, item => item.Title == "PostgreSQL back-uprunbook"); var byBody = await client.GetFromJsonAsync("/api/v1/search?q=herstellen"); Assert.NotNull(byBody); Assert.Contains(byBody.Items, item => item.Title == "PostgreSQL back-uprunbook"); Assert.DoesNotContain(byBody.Items, item => item.Title == "Onderwerp zonder verband"); } [Fact] public async Task Search_is_typo_tolerant_for_titles_via_trigram_similarity() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "Troubleshooting netwerkverbinding", "Troubleshooting", "Inhoud.", null)); // Deliberate typo: "Trubleshootng" instead of "Troubleshooting". var response = await client.GetFromJsonAsync("/api/v1/search?q=Trubleshootng"); Assert.NotNull(response); Assert.Contains(response.Items, item => item.Title == "Troubleshooting netwerkverbinding"); } [Fact] public async Task Search_can_be_filtered_by_tag_and_by_document_type() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "Gearchiveerd project", "ProjectDocument", "Inhoud.", null, ["archief", "netwerk"])); await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "Actief project", "ProjectDocument", "Inhoud.", null, ["actief"])); var byTag = await client.GetFromJsonAsync("/api/v1/search?q=project&tag=archief"); Assert.NotNull(byTag); Assert.Single(byTag.Items); Assert.Equal("Gearchiveerd project", byTag.Items[0].Title); var byType = await client.GetFromJsonAsync("/api/v1/search?q=project&type=ProjectDocument"); Assert.NotNull(byType); Assert.Equal(2, byType.Items.Count); } [Fact] public async Task Search_never_returns_a_trashed_page() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var createResponse = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "Binnenkort verwijderde pagina", "WikiPage", "Inhoud.", null)); var page = await createResponse.Content.ReadFromJsonAsync(); Assert.NotNull(page); var beforeTrash = await client.GetFromJsonAsync("/api/v1/search?q=verwijderde"); Assert.NotNull(beforeTrash); Assert.Contains(beforeTrash.Items, item => item.Id == page.Id); await SetupHelper.PostWithCsrfAndIfMatchAsync(client, $"/api/v1/pages/{page.Id}/trash", (object?)null, page.ETag); var afterTrash = await client.GetFromJsonAsync("/api/v1/search?q=verwijderde"); Assert.NotNull(afterTrash); Assert.DoesNotContain(afterTrash.Items, item => item.Id == page.Id); } [Fact] public async Task Search_requires_authentication() { using var client = Factory.CreateClient(); var response = await client.GetAsync("/api/v1/search?q=iets"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task Search_cursor_pagination_is_deterministic_and_covers_every_result_without_duplicates() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); for (var i = 0; i < 12; i++) { await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, $"Cursortest onderwerp {i:D2}", "WikiPage", "Gedeelde zoekterm cursorproef.", null)); } var seen = new HashSet(); string? cursor = null; var pages = 0; do { var url = cursor is null ? "/api/v1/search?q=cursorproef&limit=5" : $"/api/v1/search?q=cursorproef&limit=5&cursor={Uri.EscapeDataString(cursor)}"; var response = await client.GetFromJsonAsync(url); Assert.NotNull(response); foreach (var item in response.Items) { Assert.True(seen.Add(item.Id), $"Item {item.Id} was returned on more than one page."); } cursor = response.NextCursor; pages++; Assert.True(pages < 10, "Pagination did not terminate within a reasonable number of pages."); } while (cursor is not null); Assert.Equal(12, seen.Count); } }