diff --git a/src/InboxIntel.Infrastructure/Search/SearchService.cs b/src/InboxIntel.Infrastructure/Search/SearchService.cs index 601f8ce..178b3e4 100644 --- a/src/InboxIntel.Infrastructure/Search/SearchService.cs +++ b/src/InboxIntel.Infrastructure/Search/SearchService.cs @@ -10,7 +10,9 @@ namespace InboxIntel.Infrastructure.Search; /// /// Structured + full-text search. Structured filters compose as SQL WHERE /// clauses; free text uses PostgreSQL FTS via the generated SearchVector column -/// (EF.Functions.ToTsVector/Matches translate to @@ / to_tsquery). +/// (websearch_to_tsquery / @@ / ts_rank_cd). Results are relevance-ranked when a +/// free-text query is present, date-ordered otherwise. See +/// docs/discovery/05-search-redesign.md for the full multi-layer search design. /// public class SearchService : ISearchService { @@ -58,16 +60,24 @@ public class SearchService : ISearchService return new PagedResult { Items = [], Page = r.Page, PageSize = r.PageSize, TotalCount = 0 }; } - if (!string.IsNullOrWhiteSpace(r.Query)) - { - // PostgreSQL full-text match against the generated tsvector. - var term = r.Query.Trim(); - q = q.Where(e => e.SearchVector!.Matches(EF.Functions.PlainToTsQuery("english", term))); - } + // websearch_to_tsquery (vs. plainto_tsquery) understands quotes ("exact phrase"), + // OR, and -exclusions — the syntax users already expect from web search boxes. + var hasFreeTextQuery = !string.IsNullOrWhiteSpace(r.Query); + var term = r.Query?.Trim() ?? string.Empty; + if (hasFreeTextQuery) + q = q.Where(e => e.SearchVector!.Matches(EF.Functions.WebSearchToTsQuery("english", term))); var total = await q.CountAsync(ct); - var items = await q - .OrderByDescending(e => e.SentAtUtc) + + // Relevance-ranked when there's a free-text query (ts_rank_cd via RankCoverDensity, + // recency as a tiebreaker); date-only otherwise — matches the existing browse + // behaviour when the user isn't searching for anything in particular. + var ranked = hasFreeTextQuery + ? q.OrderByDescending(e => e.SearchVector!.RankCoverDensity(EF.Functions.WebSearchToTsQuery("english", term))) + .ThenByDescending(e => e.SentAtUtc) + : q.OrderByDescending(e => e.SentAtUtc); + + var items = await ranked .Skip((r.Page - 1) * r.PageSize) .Take(r.PageSize) .Select(e => new EmailSummaryDto( diff --git a/tests/InboxIntel.IntegrationTests/SearchRankingTests.cs b/tests/InboxIntel.IntegrationTests/SearchRankingTests.cs new file mode 100644 index 0000000..9706171 --- /dev/null +++ b/tests/InboxIntel.IntegrationTests/SearchRankingTests.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using InboxIntel.Application.Abstractions; +using InboxIntel.Application.Search; +using InboxIntel.Domain.Entities; +using InboxIntel.Infrastructure.Persistence; +using InboxIntel.Infrastructure.Search; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace InboxIntel.IntegrationTests; + +/// +/// SearchService now relevance-ranks (ts_rank_cd via RankCoverDensity) when a free-text +/// query is present, falling back to date order otherwise. The ranked path requires a +/// live PostgreSQL instance to exercise (EF's InMemory provider cannot translate +/// websearch_to_tsquery/RankCoverDensity) — this project deliberately avoids a hard +/// Postgres/Testcontainers dependency for tests (see AuthEndpointsTests.TestAppFactory), +/// so ranking correctness itself is verified manually/in staging, not here. What IS +/// covered: the date-order fallback, which is plain LINQ and must not regress. +/// +public class SearchRankingTests +{ + private sealed class FakeCurrentUser : ICurrentUser + { + public Guid UserId { get; set; } + public bool IsAuthenticated => UserId != Guid.Empty; + } + + [Fact] + public async Task No_query_falls_back_to_date_descending_order() + { + var user = Guid.NewGuid(); + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(nameof(No_query_falls_back_to_date_descending_order)).Options; + + using (var seed = new AppDbContext(opts, new FakeCurrentUser())) + { + var sender = new Sender { UserId = user, Address = "sender@example.com", DisplayName = "Sender" }; + seed.Senders.Add(sender); + var baseline = DateTimeOffset.UtcNow; + seed.Emails.Add(new Email { UserId = user, GmailMessageId = "oldest", SentAtUtc = baseline.AddDays(-2), Sender = sender }); + seed.Emails.Add(new Email { UserId = user, GmailMessageId = "newest", SentAtUtc = baseline, Sender = sender }); + seed.Emails.Add(new Email { UserId = user, GmailMessageId = "middle", SentAtUtc = baseline.AddDays(-1), Sender = sender }); + await seed.SaveChangesAsync(); + } + + using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = user }); + var request = GmailQueryParser.Parse(null, page: 1, pageSize: 50); + var result = await new SearchService(ctx).SearchAsync(user, request); + + result.Items.Select(i => i.GmailMessageId).Should().ContainInOrder("newest", "middle", "oldest"); + } +}