Files
Inboxintel/tests/InboxIntel.IntegrationTests/LiveDbSearchTests.cs
T
cesnimda 5191dd010f
CI / backend (push) Successful in 55s
CI / frontend (push) Successful in 12s
CI / format (push) Successful in 48s
CI / db-tests (push) Successful in 55s
Deploy Staging / deploy (push) Successful in 42s
CI / backend (pull_request) Successful in 54s
CI / frontend (pull_request) Successful in 13s
CI / format (pull_request) Successful in 49s
CI / db-tests (pull_request) Successful in 56s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 58s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 55s
feat(ai): semantic-search backfill + Ollama profile (#30)
2026-07-02 16:59:30 +02:00

127 lines
5.5 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
[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<AppDbContext> Options() =>
new DbContextOptionsBuilder<AppDbContext>().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<Guid> SeedAsync(DbContextOptions<AppDbContext> 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<AppDbContext> 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); }
}
}