feat(search): relevance ranking + websearch_to_tsquery
Slice 1 of search-core (docs/discovery/05, MVP): replace date-only ordering with
ts_rank_cd relevance ranking (Npgsql RankCoverDensity) when a free-text query is
present, recency as a tiebreaker; browse-only requests (no query) keep today's
date-descending order unchanged. Upgrade plainto_tsquery -> websearch_to_tsquery
so quotes ("exact phrase"), OR, and -exclusions work as users already expect
from web search boxes.
Verified: ranking SQL semantics (plain/phrase/exclusion/ts_rank_cd score) checked
directly against a live Postgres instance, not just Npgsql's docs. Added a
regression test locking the date-order fallback path (the one behaviour testable
without a live Postgres, matching this repo's existing no-Testcontainers
convention -- see AuthEndpointsTests.TestAppFactory). All 40 tests pass; full
Release build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<AppDbContext>()
|
||||
.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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user