From 360cfe98c9ed3ee3995c8e9a6130aeeb03c997bf Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 2 Jul 2026 03:35:07 +0200 Subject: [PATCH] =?UTF-8?q?ci:=20audit=20batch=20D=20=E2=80=94=20live-Post?= =?UTF-8?q?gres=20test=20job=20+=20server-side=20format=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements AUDIT_REPORT.md M-7 and L-10: - M-7: new CI job 'db-tests' runs Category=LiveDb tests against a pgvector/pg16 service container — permanent regression coverage for the search paths the InMemory provider can't translate (ts_rank_cd weighting, ts_headline sentinels, pg_trgm typo fallback, pgvector cosine ordering). Previously these were only verified manually. Tests soft-skip when LIVEDB_CONNECTION is unset, so local and existing CI runs are unaffected. Verified green against a real pgvector container. - L-10: 'format' CI job (dotnet format --verify-no-changes) so --no-verify pushes can't bypass the formatting gate. - frontend job: node 20 -> 22 (aligns with the vite 8 upgrade). Full suite: 54/54 (51 + 3 LiveDb when live). Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/ci.yml | 44 +++++- .../LiveDbSearchTests.cs | 125 ++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 tests/InboxIntel.IntegrationTests/LiveDbSearchTests.cs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 94caccd..af752cb 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -31,8 +31,50 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Install run: npm ci - name: 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 diff --git a/tests/InboxIntel.IntegrationTests/LiveDbSearchTests.cs b/tests/InboxIntel.IntegrationTests/LiveDbSearchTests.cs new file mode 100644 index 0000000..5c7fc9d --- /dev/null +++ b/tests/InboxIntel.IntegrationTests/LiveDbSearchTests.cs @@ -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; + +/// +/// 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")] +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); } + } +}