using FluentAssertions; using InboxIntel.Application.Abstractions; using InboxIntel.Domain.Entities; using InboxIntel.Infrastructure.Ai; using InboxIntel.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Pgvector.EntityFrameworkCore; using Xunit; namespace InboxIntel.IntegrationTests; /// /// Semantic-search backfill: the worker embeds emails lacking an Embedding and persists the /// vectors. Runs against live Postgres (pgvector) since the Embedding column is ignored under /// the InMemory provider; the CI db-tests job provides the database. Uses a deterministic fake /// embedding provider — real-Ollama integration is verified separately (endpoint contract: /// /api/embeddings, 768 dims). /// [Trait("Category", "LiveDb")] [Collection("LiveDb")] // serialise LiveDb classes: concurrent MigrateAsync on a fresh DB races public class EmbeddingBackfillTests { private static string? Conn => Environment.GetEnvironmentVariable("LIVEDB_CONNECTION"); private sealed class FakeCurrentUser : ICurrentUser { public Guid UserId => Guid.Empty; // worker scope sees all rows public bool IsAuthenticated => false; } private sealed class FakeEmbeddings : IEmbeddingProvider { public bool IsAvailable => true; public Task EmbedAsync(string text, CancellationToken ct = default) => Task.FromResult(Vec(text)); public Task> EmbedBatchAsync(IReadOnlyList texts, CancellationToken ct = default) => Task.FromResult>(texts.Select(Vec).ToList()); private static float[] Vec(string text) { var v = new float[768]; v[0] = text.Length; // deterministic, content-dependent return v; } } [Fact] public async Task Worker_embeds_pending_emails_and_persists_vectors() { if (Conn is null) return; // soft-skip outside the live-db CI job var uid = Guid.NewGuid(); var opts = new DbContextOptionsBuilder().UseNpgsql(Conn!, o => o.UseVector()).Options; var services = new ServiceCollection(); services.AddScoped(); services.AddScoped(_ => new AppDbContext(opts, new FakeCurrentUser())); services.AddScoped(); using var sp = services.BuildServiceProvider(); using (var seed = new AppDbContext(opts, new FakeCurrentUser())) { await seed.Database.MigrateAsync(); seed.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", Domain = dom }; var thr = new MailThread { UserId = uid, GmailThreadId = "th" + uid }; seed.AddRange(dom, snd, thr); seed.Emails.Add(new Email { UserId = uid, GmailMessageId = "e1" + uid, Subject = "hello world", Sender = snd, Thread = thr, SentAtUtc = DateTimeOffset.UtcNow }); seed.Emails.Add(new Email { UserId = uid, GmailMessageId = "e2" + uid, Subject = "quarterly invoice", Sender = snd, Thread = thr, SentAtUtc = DateTimeOffset.UtcNow }); await seed.SaveChangesAsync(); } try { var worker = new EmbeddingBackfillWorker( sp.GetRequiredService(), NullLogger.Instance); var processed = await worker.ProcessBatchAsync(CancellationToken.None); processed.Should().BeGreaterThanOrEqualTo(2); using var check = new AppDbContext(opts, new FakeCurrentUser()); var mine = await check.Emails.Where(e => e.UserId == uid).ToListAsync(); mine.Should().OnlyContain(e => e.Embedding != null); mine.First().Embedding!.ToArray().Length.Should().Be(768); } finally { using var c = new AppDbContext(opts, new FakeCurrentUser()); await c.Emails.Where(e => e.UserId == uid).ExecuteDeleteAsync(); await c.Threads.Where(t => t.UserId == uid).ExecuteDeleteAsync(); await c.Senders.Where(s => s.UserId == uid).ExecuteDeleteAsync(); await c.Domains.Where(d => d.UserId == uid).ExecuteDeleteAsync(); await c.Users.Where(u => u.Id == uid).ExecuteDeleteAsync(); } } }