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 Pgvector; using Pgvector.EntityFrameworkCore; using Xunit; namespace InboxIntel.IntegrationTests; /// /// AUDIT M-7: live-PostgreSQL regression tests for the search paths the InMemory provider /// cannot translate (websearch_to_tsquery, ts_rank_cd, ts_headline, pg_trgm word_similarity, /// pgvector cosine). These previously existed only as throwaway manual verifications. /// /// They run when LIVEDB_CONNECTION points at a pgvector-enabled Postgres (the CI `db-tests` /// job provides one as a service container) and no-op otherwise, so the default local / /// InMemory test run is unaffected. /// [Trait("Category", "LiveDb")] [Collection("LiveDb")] // serialise LiveDb classes: concurrent MigrateAsync on a fresh DB races public class LiveDbSearchTests { private static string? Conn => Environment.GetEnvironmentVariable("LIVEDB_CONNECTION"); private sealed class FakeCurrentUser : ICurrentUser { public Guid UserId { get; set; } public bool IsAuthenticated => UserId != Guid.Empty; } private static DbContextOptions Options() => new DbContextOptionsBuilder().UseNpgsql(Conn!, o => o.UseVector()).Options; private static Vector Vec(float x, float y) { var arr = new float[768]; arr[0] = x; arr[1] = y; return new Vector(arr); } private static async Task SeedAsync(DbContextOptions opts) { var uid = Guid.NewGuid(); using var db = new AppDbContext(opts, new FakeCurrentUser()); await db.Database.MigrateAsync(); db.Users.Add(new User { Id = uid, GoogleSubjectId = "g" + uid, Email = uid + "@t.t" }); var dom = new MailDomain { UserId = uid, Name = "t.t" }; var snd = new Sender { UserId = uid, Address = "a@t.t", DisplayName = "A", Domain = dom }; var thr = new MailThread { UserId = uid, GmailThreadId = "th" + uid }; db.Domains.Add(dom); db.Senders.Add(snd); db.Threads.Add(thr); db.Emails.AddRange( new Email { UserId = uid, GmailMessageId = "subj" + uid, Subject = "Invoice March", BodyText = "hello there", Sender = snd, Thread = thr, Embedding = Vec(1, 0), SentAtUtc = DateTimeOffset.UtcNow.AddDays(-2) }, new Email { UserId = uid, GmailMessageId = "body" + uid, Subject = "Weekly notes", BodyText = "we received your invoice today", Sender = snd, Thread = thr, Embedding = Vec(0, 1), SentAtUtc = DateTimeOffset.UtcNow.AddDays(-1) }); await db.SaveChangesAsync(); return uid; } private static async Task CleanupAsync(DbContextOptions opts, Guid uid) { using var db = new AppDbContext(opts, new FakeCurrentUser()); await db.Emails.Where(e => e.UserId == uid).ExecuteDeleteAsync(); await db.Threads.Where(t => t.UserId == uid).ExecuteDeleteAsync(); await db.Senders.Where(s => s.UserId == uid).ExecuteDeleteAsync(); await db.Domains.Where(d => d.UserId == uid).ExecuteDeleteAsync(); await db.Users.Where(u => u.Id == uid).ExecuteDeleteAsync(); } [Fact] public async Task Ranked_search_weights_subject_hits_first_and_explains_body_hits() { if (Conn is null) return; // soft-skip outside the live-db CI job var opts = Options(); var uid = await SeedAsync(opts); try { using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = uid }); var res = await new SearchService(ctx).SearchAsync(uid, GmailQueryParser.Parse("invoice", 1, 10)); res.TotalCount.Should().Be(2); res.Items[0].Subject.Should().Be("Invoice March"); // subject weight A wins var bodyHit = res.Items.First(i => i.Subject == "Weekly notes"); bodyHit.MatchHighlight.Should().Contain("invoice"); // ts_headline present bodyHit.MatchHighlight.Should().Contain(((char)0xE000).ToString()); // sentinel wrapping } finally { await CleanupAsync(opts, uid); } } [Fact] public async Task Typo_falls_back_to_trigram_word_similarity() { if (Conn is null) return; var opts = Options(); var uid = await SeedAsync(opts); try { using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = uid }); var res = await new SearchService(ctx).SearchAsync(uid, GmailQueryParser.Parse("invoce", 1, 10)); res.TotalCount.Should().BeGreaterThan(0, "the typo should fuzzy-match via pg_trgm"); } finally { await CleanupAsync(opts, uid); } } [Fact] public async Task Pgvector_cosine_orders_nearest_embedding_first() { if (Conn is null) return; var opts = Options(); var uid = await SeedAsync(opts); try { using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = uid }); var query = Vec(1, 0); var ordered = await ctx.Emails .Where(e => e.Embedding != null) .OrderBy(e => e.Embedding!.CosineDistance(query)) .Select(e => e.Subject) .ToListAsync(); ordered.Should().Equal("Invoice March", "Weekly notes"); } finally { await CleanupAsync(opts, uid); } } }