ci: audit batch D — live-db tests + format gate #23
+43
-1
@@ -31,8 +31,50 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '22'
|
||||||
- name: Install
|
- name: Install
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
# AUDIT L-10: the pre-commit hook enforces formatting locally, but --no-verify or web edits
|
||||||
|
# can bypass it — this makes the same check a server-side gate.
|
||||||
|
format:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
- name: dotnet format (verify only)
|
||||||
|
run: dotnet format InboxIntel.sln --verify-no-changes
|
||||||
|
|
||||||
|
# AUDIT M-7: the search paths the InMemory provider can't translate (FTS ranking,
|
||||||
|
# ts_headline, pg_trgm, pgvector) previously had only manual verification. This job runs
|
||||||
|
# the Category=LiveDb tests against a real pgvector Postgres service container.
|
||||||
|
db-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: test
|
||||||
|
POSTGRES_PASSWORD: test
|
||||||
|
POSTGRES_DB: test
|
||||||
|
env:
|
||||||
|
LIVEDB_CONNECTION: "Host=postgres;Port=5432;Database=test;Username=test;Password=test"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
- name: Wait for Postgres
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
(echo > /dev/tcp/postgres/5432) 2>/dev/null && exit 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "Postgres service did not become reachable" >&2
|
||||||
|
exit 1
|
||||||
|
- name: Live-DB tests
|
||||||
|
run: dotnet test InboxIntel.sln --filter "Category=LiveDb" --verbosity normal
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
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")]
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user