From ac9a78590ab1bd76942aac4a35db05c7813d2053 Mon Sep 17 00:00:00 2001 From: Amal Date: Mon, 3 Aug 2026 06:40:28 -0700 Subject: [PATCH] test(frontend): pay down the coverage regression from #274/#263/#280 with lib tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHY THIS MATTERS Main's "Frontend build and tests" job has been red for everyone: statements 52.14% vs the 54% floor and branches 62.57% vs 73%. A coverage ratchet only works if regressions are paid down with tests — if we lower the floor instead, the ratchet becomes a decoration and every future untested merge quietly erodes the suite. This commit restores green by testing the code that caused the drop, then re-arms the ratchet at the new level. WHAT HAPPENED The ratchet floors were measured in #255 (2266446) before three merges landed untested code inside the gated scope (src/app/lib/**): - #263 (db-pagination) + #274 (folder-grouped tabular reviews) grew mikeApi.ts's listTabularReviews/listTabularReviewIds into query-string builders with seven conditional params each, added the document_grouping field, the uploadReviewDocument orchestration, and the tabular chat/cell endpoints — nearly all unexercised. Lines 1044-1456 were the bulk of the uncovered report. - #280 (workflow slash triggers) leans on the workflow endpoints (listWorkflows feeds the slash menu), which were also untested. Because coverage is a global percentage over the gated files, adding untested statements/branches anywhere in scope dilutes the totals even though no tested line got worse. HOW THE FIX WORKS Extend the existing mikeApi.test.ts fetch/session mocking pattern to the regressed surface, asserting behavior (URLs, methods, exact payloads, error contracts), not just execution: - listTabularReviews/listTabularReviewIds: every pagination knob serialized under its snake_case name, scope="all" omitted (backend default), abort signals forwarded, and the ids query scoped identically to the list query — the invariant that keeps select-all-then-delete from deleting reviews the user cannot see. - createTabularReview/updateTabularReview: document_grouping (the #274 field) passes through unchanged; PATCH sends only the given fields. - uploadReviewDocument: project vs standalone upload routing, and that the follow-up PATCH appends to existing document_ids instead of replacing them (the review-shrinking failure mode). - Multipart uploads: FormData with auth header only (a manual JSON content type would break the boundary), optional filename field, and the plain-Error-with-response-text failure contract. - Tabular chats/cells, workflow list/hide/unhide, query and payload defaults (getDocumentUrl version param, createChat "{}" body, parent_folder_id null-vs-undefined, empty error bodies). - supabase.ts: importing without env vars fails loudly at module load — the desired crash-at-startup behavior for a misconfigured build. - deleteTabularReviewsWithConcurrency: empty input short-circuits; concurrency<=0 clamps to one worker instead of silently deleting nothing. - utils.diceCoefficient: sub-bigram inputs score 0. Coverage moves from 52.14/62.57/34.67/52.41 (stmts/branch/funcs/lines) to 81.18/98.24/55.64/79.18. Per the ratchet's own rule ("floors only go up: when you add tests, raise them in the same PR"), the floors move to 79/96/53/77 — about two points under the new measurement, so one small innocent addition doesn't instantly re-redden main, while a real drop still fails CI. Co-Authored-By: Claude Fable 5 --- ...eleteTabularReviewsWithConcurrency.test.ts | 30 + frontend/src/app/lib/mikeApi.test.ts | 512 ++++++++++++++++++ frontend/src/app/lib/supabase.test.ts | 38 ++ frontend/src/app/lib/utils.test.ts | 6 + frontend/vitest.config.mts | 27 +- 5 files changed, 600 insertions(+), 13 deletions(-) create mode 100644 frontend/src/app/lib/supabase.test.ts diff --git a/frontend/src/app/lib/deleteTabularReviewsWithConcurrency.test.ts b/frontend/src/app/lib/deleteTabularReviewsWithConcurrency.test.ts index 59b97e2f1..38d001c59 100644 --- a/frontend/src/app/lib/deleteTabularReviewsWithConcurrency.test.ts +++ b/frontend/src/app/lib/deleteTabularReviewsWithConcurrency.test.ts @@ -32,6 +32,36 @@ describe("deleteTabularReviewsWithConcurrency", () => { expect(result.failedIds).toEqual(["review-3", "review-7"]); }); + it("returns empty results for empty input without calling delete", async () => { + const deleteReview = vi.fn(); + + const result = await deleteTabularReviewsWithConcurrency( + [], + deleteReview, + ); + + expect(result).toEqual({ deletedIds: [], failedIds: [] }); + expect(deleteReview).not.toHaveBeenCalled(); + }); + + it("clamps a non-positive concurrency to a single worker and still deletes", async () => { + const order: string[] = []; + const deleteReview = vi.fn(async (id: string) => { + order.push(id); + }); + + const result = await deleteTabularReviewsWithConcurrency( + ["review-1", "review-2"], + deleteReview, + 0, + ); + + // Math.max(1, floor(0)) keeps one worker alive; a naive 0 would spawn + // no workers and silently delete nothing. + expect(order).toEqual(["review-1", "review-2"]); + expect(result.deletedIds).toEqual(["review-1", "review-2"]); + }); + it("deduplicates ids before deleting", async () => { const deleteReview = vi.fn().mockResolvedValue(undefined); diff --git a/frontend/src/app/lib/mikeApi.test.ts b/frontend/src/app/lib/mikeApi.test.ts index 1a7d67162..c11935afa 100644 --- a/frontend/src/app/lib/mikeApi.test.ts +++ b/frontend/src/app/lib/mikeApi.test.ts @@ -19,20 +19,46 @@ vi.mock("@/app/lib/supabase", () => ({ import { MikeApiError, + clearTabularCells, + createChat, + createLibraryFolder, + createProjectFolder, + createTabularReview, deleteAllChats, + deleteTabularChat, + deleteTabularReview, downloadDocumentsZip, exportAccountData, + generateTabularColumnPrompt, getChat, + getDocumentUrl, + getTabularChatMessages, + getTabularChats, getUserProfile, + hideWorkflow, isMfaRequiredError, listChats, + listHiddenWorkflows, listProjects, + listTabularReviewIds, + listTabularReviews, + listWorkflows, lookupUserByEmail, mapTRMessages, + regenerateTabularCell, + renameTabularChat, + replaceDocumentVersionFile, streamChat, streamProjectChat, streamTabularChat, streamTabularGeneration, + unhideWorkflow, + updateTabularReview, + uploadDocumentVersion, + uploadLibraryDocument, + uploadProjectDocument, + uploadReviewDocument, + uploadStandaloneDocument, } from "./mikeApi"; const fetchMock = vi.fn(); @@ -606,3 +632,489 @@ describe("streamTabularGeneration", () => { expect(init.headers).toEqual({ Authorization: "Bearer token-123" }); }); }); + +// --------------------------------------------------------------------------- +// Tabular review listing. This is the query-building half of the paginated +// review list (PR #263 db-pagination + PR #274 folder grouping): the backend +// scopes, sorts, and pages entirely off these params, so a silently dropped +// or misnamed param means the UI shows the wrong rows, not an error. +// --------------------------------------------------------------------------- + +describe("listTabularReviews", () => { + it("requests the bare collection when no filters are given", async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listTabularReviews(); + + const { url, init } = lastFetchCall(); + // No stray "?" — the backend treats /tabular-review and + // /tabular-review? the same, but the cache key would differ. + expect(url).toBe("http://localhost:3001/tabular-review"); + expect(init.signal).toBeUndefined(); + }); + + it("serializes every pagination knob and forwards the abort signal", async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + const controller = new AbortController(); + + await listTabularReviews("p1", { + limit: 25, + offset: 50, + search: "lease agreements", + sortKey: "updated_at", + sortDirection: "desc", + scope: "standalone", + signal: controller.signal, + }); + + const { url, init } = lastFetchCall(); + expect(url).toBe( + "http://localhost:3001/tabular-review" + + "?project_id=p1&limit=25&offset=50&search=lease+agreements" + + "&sort_key=updated_at&sort_direction=desc&scope=standalone", + ); + // The signal lets the list screen cancel a stale page when the user + // types a new search before the previous one resolves. + expect(init.signal).toBe(controller.signal); + }); + + it('omits the scope param for "all" — the backend default', async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listTabularReviews(undefined, { scope: "all", limit: 10 }); + + expect(lastFetchCall().url).toBe( + "http://localhost:3001/tabular-review?limit=10", + ); + }); +}); + +describe("listTabularReviewIds", () => { + it("requests the bare id list when no filters are given", async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listTabularReviewIds(); + + expect(lastFetchCall().url).toBe( + "http://localhost:3001/tabular-review/ids", + ); + }); + + it("scopes ids by project, search, and scope so select-all matches the visible filter", async () => { + fetchMock.mockResolvedValue(jsonResponse([{ id: "r1", user_id: "u1" }])); + const controller = new AbortController(); + + const ids = await listTabularReviewIds("p1", { + search: "nda", + scope: "in-project", + signal: controller.signal, + }); + + expect(ids).toEqual([{ id: "r1", user_id: "u1" }]); + const { url, init } = lastFetchCall(); + // Select-all-then-delete deletes whatever this returns; if the query + // here is broader than the list query, users delete unseen reviews. + expect(url).toBe( + "http://localhost:3001/tabular-review/ids?project_id=p1&search=nda&scope=in-project", + ); + expect(init.signal).toBe(controller.signal); + }); + + it('omits the scope param for "all"', async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listTabularReviewIds(undefined, { scope: "all" }); + + expect(lastFetchCall().url).toBe( + "http://localhost:3001/tabular-review/ids", + ); + }); +}); + +describe("tabular review CRUD", () => { + it("createTabularReview posts the folder grouping mode through unchanged", async () => { + fetchMock.mockResolvedValue(jsonResponse({ id: "r1" })); + + await createTabularReview({ + title: "Leases", + document_ids: ["d1", "d2"], + columns_config: [{ index: 0, name: "Term", prompt: "Find term" }], + project_id: "p1", + document_grouping: "folder", + }); + + const { url, init } = lastFetchCall(); + expect(url).toBe("http://localhost:3001/tabular-review"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ + title: "Leases", + document_ids: ["d1", "d2"], + columns_config: [{ index: 0, name: "Term", prompt: "Find term" }], + project_id: "p1", + document_grouping: "folder", + }); + }); + + it("updateTabularReview PATCHes partial payloads without inventing fields", async () => { + fetchMock.mockResolvedValue(jsonResponse({ id: "r1" })); + + await updateTabularReview("r1", { document_grouping: "document" }); + + const { url, init } = lastFetchCall(); + expect(url).toBe("http://localhost:3001/tabular-review/r1"); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body as string)).toEqual({ + document_grouping: "document", + }); + }); + + it("deleteTabularReview issues DELETE on the review resource", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + + await deleteTabularReview("r1"); + + const { url, init } = lastFetchCall(); + expect(url).toBe("http://localhost:3001/tabular-review/r1"); + expect(init.method).toBe("DELETE"); + }); + + it("generateTabularColumnPrompt forwards title and optional hints", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ prompt: "p", source: "preset" }), + ); + + const result = await generateTabularColumnPrompt("Termination", { + format: "date", + documentName: "lease.pdf", + tags: ["real-estate"], + }); + + expect(result.source).toBe("preset"); + const { url, init } = lastFetchCall(); + expect(url).toBe("http://localhost:3001/tabular-review/prompt"); + expect(JSON.parse(init.body as string)).toEqual({ + title: "Termination", + format: "date", + documentName: "lease.pdf", + tags: ["real-estate"], + }); + }); +}); + +describe("uploadReviewDocument", () => { + it("uploads into the project then appends the new id to the review", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ id: "new-doc" })) + .mockResolvedValueOnce(jsonResponse({ id: "r1" })); + const file = new File(["x"], "a.pdf"); + + const uploaded = await uploadReviewDocument("r1", file, { + projectId: "p1", + documentIds: ["d1"], + columnsConfig: [{ index: 0, name: "Term", prompt: "p" }], + }); + + expect(uploaded).toEqual({ id: "new-doc" }); + const [uploadCall, patchCall] = fetchMock.mock.calls; + expect(uploadCall[0]).toBe("http://localhost:3001/projects/p1/documents"); + expect((uploadCall[1] as RequestInit).body).toBeInstanceOf(FormData); + expect(patchCall[0]).toBe("http://localhost:3001/tabular-review/r1"); + // Existing ids must be preserved — the review would otherwise shrink + // to just the newly uploaded document. + expect(JSON.parse((patchCall[1] as RequestInit).body as string)).toEqual({ + columns_config: [{ index: 0, name: "Term", prompt: "p" }], + document_ids: ["d1", "new-doc"], + }); + }); + + it("falls back to a standalone upload when the review has no project", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ id: "new-doc" })) + .mockResolvedValueOnce(jsonResponse({ id: "r1" })); + + await uploadReviewDocument("r1", new File(["x"], "a.pdf")); + + const [uploadCall, patchCall] = fetchMock.mock.calls; + expect(uploadCall[0]).toBe("http://localhost:3001/single-documents"); + // With no prior ids the review ends up with exactly the new document. + expect(JSON.parse((patchCall[1] as RequestInit).body as string)).toEqual( + { document_ids: ["new-doc"] }, + ); + }); +}); + +describe("tabular review chats", () => { + it("lists chats and fetches messages from the nested routes", async () => { + fetchMock.mockImplementation(() => Promise.resolve(jsonResponse([]))); + + await getTabularChats("r1"); + expect(lastFetchCall().url).toBe( + "http://localhost:3001/tabular-review/r1/chats", + ); + + await getTabularChatMessages("r1", "c1"); + expect(lastFetchCall().url).toBe( + "http://localhost:3001/tabular-review/r1/chats/c1/messages", + ); + }); + + it("renames via PATCH and deletes via DELETE on the chat resource", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + + await renameTabularChat("r1", "c1", "New title"); + let { url, init } = lastFetchCall(); + expect(url).toBe("http://localhost:3001/tabular-review/r1/chats/c1"); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body as string)).toEqual({ title: "New title" }); + + await deleteTabularChat("r1", "c1"); + ({ url, init } = lastFetchCall()); + expect(url).toBe("http://localhost:3001/tabular-review/r1/chats/c1"); + expect(init.method).toBe("DELETE"); + }); + + it("includes chat_id but omits absent context in streamTabularChat", async () => { + fetchMock.mockResolvedValue(streamResponse([])); + + await streamTabularChat("r1", [{ role: "user", content: "q" }], "c9"); + + expect(JSON.parse(lastFetchCall().init.body as string)).toEqual({ + messages: [{ role: "user", content: "q" }], + chat_id: "c9", + }); + }); +}); + +describe("tabular cell operations", () => { + it("regenerateTabularCell posts the row/column address with snake_case keys", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ summary: "s", flag: "green", reasoning: "r" }), + ); + + const cell = await regenerateTabularCell("r1", "row-1", 2); + + expect(cell.flag).toBe("green"); + const { url, init } = lastFetchCall(); + expect(url).toBe( + "http://localhost:3001/tabular-review/r1/regenerate-cell", + ); + expect(JSON.parse(init.body as string)).toEqual({ + row_id: "row-1", + column_index: 2, + }); + }); + + it("clearTabularCells posts the row ids to clear", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + + await clearTabularCells("r1", ["row-1", "row-2"]); + + const { url, init } = lastFetchCall(); + expect(url).toBe("http://localhost:3001/tabular-review/r1/clear-cells"); + expect(JSON.parse(init.body as string)).toEqual({ + row_ids: ["row-1", "row-2"], + }); + }); +}); + +// --------------------------------------------------------------------------- +// Multipart uploads. These bypass apiRequest (FormData must not get a JSON +// content type) and therefore have their own, weaker error contract: a plain +// Error carrying the raw response text instead of MikeApiError. +// --------------------------------------------------------------------------- + +describe("multipart upload endpoints", () => { + const file = new File(["pdf-bytes"], "a.pdf", { type: "application/pdf" }); + + it("uploadLibraryDocument posts FormData with auth and no JSON content type", async () => { + fetchMock.mockResolvedValue(jsonResponse({ id: "d1" })); + + const doc = await uploadLibraryDocument("templates", file); + + expect(doc).toEqual({ id: "d1" }); + const { url, init } = lastFetchCall(); + expect(url).toBe("http://localhost:3001/library/templates/documents"); + expect(init.method).toBe("POST"); + expect(init.body).toBeInstanceOf(FormData); + expect((init.body as FormData).get("file")).toBeInstanceOf(File); + // Setting Content-Type manually would break the multipart boundary. + expect(init.headers).toEqual({ Authorization: "Bearer token-123" }); + }); + + it("upload failures throw a plain Error with the response text, not MikeApiError", async () => { + fetchMock.mockImplementation(() => + Promise.resolve(new Response("file too large", { status: 413 })), + ); + + const error = await uploadProjectDocument("p1", file).catch( + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(MikeApiError); + expect((error as Error).message).toBe("file too large"); + + await expect(uploadStandaloneDocument(file)).rejects.toThrow( + "file too large", + ); + await expect(uploadLibraryDocument("files", file)).rejects.toThrow( + "file too large", + ); + }); + + it("uploadDocumentVersion appends the filename field only when given", async () => { + fetchMock.mockImplementation(() => + Promise.resolve(jsonResponse({ id: "v1" })), + ); + + await uploadDocumentVersion("d1", file, "renamed.pdf"); + let body = lastFetchCall().init.body as FormData; + expect(lastFetchCall().url).toBe( + "http://localhost:3001/single-documents/d1/versions", + ); + expect(body.get("filename")).toBe("renamed.pdf"); + + await uploadDocumentVersion("d1", file); + body = lastFetchCall().init.body as FormData; + expect(body.get("filename")).toBeNull(); + }); + + it("replaceDocumentVersionFile PUTs to the version file route and surfaces errors", async () => { + fetchMock.mockResolvedValue(jsonResponse({ id: "v1" })); + + await replaceDocumentVersionFile("d1", "v1", file, "renamed.pdf"); + + const { url, init } = lastFetchCall(); + expect(url).toBe( + "http://localhost:3001/single-documents/d1/versions/v1/file", + ); + expect(init.method).toBe("PUT"); + expect((init.body as FormData).get("filename")).toBe("renamed.pdf"); + + fetchMock.mockResolvedValue(new Response("nope", { status: 409 })); + await expect( + replaceDocumentVersionFile("d1", "v1", file), + ).rejects.toThrow("nope"); + }); +}); + +describe("query and payload defaults", () => { + it("getDocumentUrl appends version_id only when a version is requested", async () => { + fetchMock.mockImplementation(() => + Promise.resolve( + jsonResponse({ url: "u", filename: "f", version_id: null }), + ), + ); + + await getDocumentUrl("d1"); + expect(lastFetchCall().url).toBe( + "http://localhost:3001/single-documents/d1/url", + ); + + await getDocumentUrl("d1", "v 1"); + expect(lastFetchCall().url).toBe( + "http://localhost:3001/single-documents/d1/url?version_id=v%201", + ); + }); + + it("createChat defaults to an empty JSON object body", async () => { + fetchMock.mockResolvedValue(jsonResponse({ id: "c1" })); + + await createChat(); + expect(lastFetchCall().init.body).toBe("{}"); + fetchMock.mockResolvedValue(jsonResponse({ id: "c2" })); + + await createChat({ project_id: "p1" }); + expect(JSON.parse(lastFetchCall().init.body as string)).toEqual({ + project_id: "p1", + }); + }); + + it("listChats without options hits the bare /chat route", async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listChats(); + + expect(lastFetchCall().url).toBe("http://localhost:3001/chat"); + }); + + it("folder creation defaults parent_folder_id to null, not undefined", async () => { + fetchMock.mockImplementation(() => + Promise.resolve(jsonResponse({ id: "f1" })), + ); + + await createProjectFolder("p1", "Discovery"); + // null must survive JSON.stringify (undefined would drop the key and + // the backend would reject the payload). + expect(JSON.parse(lastFetchCall().init.body as string)).toEqual({ + name: "Discovery", + parent_folder_id: null, + }); + + await createLibraryFolder("files", "Precedents", "parent-1"); + expect(JSON.parse(lastFetchCall().init.body as string)).toEqual({ + name: "Precedents", + parent_folder_id: "parent-1", + }); + }); + + it("downloadDocumentsZip synthesizes a message when the error body is empty", async () => { + fetchMock.mockResolvedValue(new Response("", { status: 500 })); + + await expect(downloadDocumentsZip(["d1"])).rejects.toThrow( + "API error: 500", + ); + }); + + it("mapTRMessages degrades a null user body to an empty string", () => { + const mapped = mapTRMessages([ + { + id: "m1", + chat_id: "c1", + role: "user", + content: null, + created_at: "2026-01-01", + }, + ]); + expect(mapped).toEqual([{ role: "user", content: "" }]); + }); +}); + +// --------------------------------------------------------------------------- +// Workflows. The slash-command menu (PR #280) is fed by listWorkflows, and +// hide/unhide controls which ones it offers — a wrong route here silently +// empties the menu rather than erroring. +// --------------------------------------------------------------------------- + +describe("workflow endpoints", () => { + it("listWorkflows filters by type via the query string", async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listWorkflows("assistant"); + + expect(lastFetchCall().url).toBe( + "http://localhost:3001/workflows?type=assistant", + ); + }); + + it("hide/unhide/list use the hidden-workflows routes with matching methods", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + + await hideWorkflow("w1"); + let { url, init } = lastFetchCall(); + expect(url).toBe("http://localhost:3001/workflows/hidden"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ workflow_id: "w1" }); + + await unhideWorkflow("w1"); + ({ url, init } = lastFetchCall()); + expect(url).toBe("http://localhost:3001/workflows/hidden/w1"); + expect(init.method).toBe("DELETE"); + + fetchMock.mockResolvedValue(jsonResponse(["w2"])); + await expect(listHiddenWorkflows()).resolves.toEqual(["w2"]); + expect(lastFetchCall().url).toBe( + "http://localhost:3001/workflows/hidden", + ); + }); +}); diff --git a/frontend/src/app/lib/supabase.test.ts b/frontend/src/app/lib/supabase.test.ts new file mode 100644 index 000000000..3b97d526d --- /dev/null +++ b/frontend/src/app/lib/supabase.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// supabase.ts creates its client at module load, so each test re-imports the +// module fresh after adjusting the environment. vitest.config.mts supplies +// valid dummy env values; these tests override them per-case. + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("supabase client bootstrap", () => { + it("exports a working client when both env vars are present", async () => { + vi.resetModules(); + + const { supabase } = await import("./supabase"); + + expect(supabase.auth).toBeDefined(); + expect(typeof supabase.auth.getSession).toBe("function"); + }); + + it("fails loudly at import when the Supabase URL is missing", async () => { + // The `|| ""` fallback hands createClient an empty URL, which throws. + // That is the desired behavior: a misconfigured build must crash at + // startup, not mint a client that fails on every auth call later. + vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", ""); + vi.resetModules(); + + await expect(import("./supabase")).rejects.toThrow(/supabaseUrl/i); + }); + + it("fails loudly at import when the publishable key is missing", async () => { + vi.stubEnv("NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY", ""); + vi.resetModules(); + + await expect(import("./supabase")).rejects.toThrow(/key/i); + }); +}); diff --git a/frontend/src/app/lib/utils.test.ts b/frontend/src/app/lib/utils.test.ts index 31dddcf5f..238dbdcae 100644 --- a/frontend/src/app/lib/utils.test.ts +++ b/frontend/src/app/lib/utils.test.ts @@ -21,6 +21,12 @@ describe("diceCoefficient", () => { expect(diceCoefficient("anything", "")).toBe(0); }); + it("returns 0 when a normalized side is too short to form a bigram", () => { + // "a!" normalizes to "a" — one character, no bigrams to compare. + expect(diceCoefficient("a!", "abc")).toBe(0); + expect(diceCoefficient("abc", "b")).toBe(0); + }); + it("returns a partial score for partially overlapping strings", () => { const score = diceCoefficient("night", "nacht"); expect(score).toBeGreaterThan(0); diff --git a/frontend/vitest.config.mts b/frontend/vitest.config.mts index 8d4973423..3661d81a5 100644 --- a/frontend/vitest.config.mts +++ b/frontend/vitest.config.mts @@ -53,21 +53,22 @@ export default defineConfig({ include: ["src/app/lib/**"], exclude: ["src/app/lib/**/*.test.*"], // No-regression RATCHET floor, not a target. The global number is - // dominated by mikeApi.ts: the request/error/stream plumbing and - // message mapping are tested, but most of its ~100 thin endpoint - // wrappers are not, so mikeApi sits around 40% while the small - // pure libs (documentUploadValidation, modelAvailability, utils) - // are at ~100%. Measured on this tree: 54.02% statements, 73.94% - // branches, 32.20% functions, 52.74% lines. These floors sit just - // below that (rounded down to whole percents) so CI fails on a - // *drop*. Floors only go up: when you add tests, raise them in - // the same PR. Backlog + per-area status: + // dominated by mikeApi.ts: the request/error/stream plumbing, + // message mapping, the paginated tabular-review queries, and the + // multipart upload error paths are tested; the remaining gap is + // thin endpoint wrappers (MCP connectors, workflow shares) that + // add functions faster than tests. Measured on this tree: 81.18% + // statements, 98.24% branches, 55.64% functions, 79.18% lines. + // These floors sit ~2 points below that so an innocently small + // untested addition doesn't instantly red-flag main, while a real + // drop still fails CI. Floors only go up: when you add tests, + // raise them in the same PR. Backlog + per-area status: // docs/frontend-testing.md. thresholds: { - statements: 54, - branches: 73, - functions: 32, - lines: 52, + statements: 79, + branches: 96, + functions: 53, + lines: 77, }, }, },