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
96 lines
4.5 KiB
C#
96 lines
4.5 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
[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<float[]> EmbedAsync(string text, CancellationToken ct = default)
|
|
=> Task.FromResult(Vec(text));
|
|
public Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default)
|
|
=> Task.FromResult<IReadOnlyList<float[]>>(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<AppDbContext>().UseNpgsql(Conn!, o => o.UseVector()).Options;
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddScoped<ICurrentUser, FakeCurrentUser>();
|
|
services.AddScoped(_ => new AppDbContext(opts, new FakeCurrentUser()));
|
|
services.AddScoped<IEmbeddingProvider, FakeEmbeddings>();
|
|
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<IServiceScopeFactory>(), NullLogger<EmbeddingBackfillWorker>.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();
|
|
}
|
|
}
|
|
}
|