From 86a3d7418768b4358913778854bc2ec5a291eafa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 13 Aug 2026 12:13:55 +0200 Subject: [PATCH] Report how many documents the reindex actually queues The index-all trigger returned Boolean(jobId) while the client typed the field as a number, so the toast read "Indexing true document(s)". The route now answers with the count of live documents still lacking a content index, computed from the same live-scoped counts the usage gauge reads, and an honest 0 when no job was created. OpenAPI schema updated to the integer shape; the hook's type carries a contract note so the wire and the toast cannot drift apart again. Route test pins the number, a component test pins that the toast renders the count and never the word "true". Refs #776 --- docs/api/openapi.yaml | 7 +- .../inbound/reindex/__tests__/route.test.ts | 116 ++++++++++++++++ .../api/documents/inbound/reindex/route.ts | 28 +++- .../documents-view-reindex-toast.test.tsx | 131 ++++++++++++++++++ src/components/documents/use-content-index.ts | 11 +- src/lib/openapi/routes/documents.ts | 5 +- 6 files changed, 290 insertions(+), 8 deletions(-) create mode 100644 src/app/api/documents/inbound/reindex/__tests__/route.test.ts create mode 100644 src/components/documents/__tests__/documents-view-reindex-toast.test.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8562757f0..8a1a951f6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12792,7 +12792,8 @@ paths: immediately; the work runs off-request on the queue. responses: "200": - description: Whether a backfill job was enqueued. + description: How many live documents the enqueued backfill still has to index (0 when everything is indexed or no job + was created). content: application/json: schema: @@ -38680,7 +38681,9 @@ components: type: object properties: enqueued: - type: boolean + type: integer + minimum: 0 + maximum: 9007199254740991 required: - enqueued additionalProperties: false diff --git a/src/app/api/documents/inbound/reindex/__tests__/route.test.ts b/src/app/api/documents/inbound/reindex/__tests__/route.test.ts new file mode 100644 index 000000000..d26443e4b --- /dev/null +++ b/src/app/api/documents/inbound/reindex/__tests__/route.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * Refs #776 — the reindex trigger's wire honesty. The client types + * `{ enqueued: number }` and interpolates it into "Indexing {count} + * document(s)"; the route used to return `Boolean(jobId)`, so the toast said + * "Indexing true document(s)". Pins: `enqueued` is a NUMBER — the count of + * live documents still lacking a content index (the same figure the usage + * gauge derives) when a job was created, and an honest 0 when none was. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + inboundDocument: { count: vi.fn() }, + documentContentIndex: { count: vi.fn() }, + }, +})); +vi.mock("@/lib/jobs/document-content-index-backfill", () => ({ + enqueueContentIndexBackfill: vi.fn(), +})); +vi.mock("@/lib/documents/provider-order", () => ({ + resolveDocumentVisionProvider: vi.fn(), +})); +vi.mock("@/lib/ai/consent-guard", () => ({ + assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined), + ConsentRequiredError: class ConsentRequiredError extends Error {}, +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { enqueueContentIndexBackfill } from "@/lib/jobs/document-content-index-backfill"; +import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; + +const SESSION_OK = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +const PICK = { + chain: [{ providerType: "anthropic", instance: {} }], + pick: { + entry: { providerType: "anthropic", instance: {} }, + providerType: "anthropic", + pdfSupported: true, + }, +}; + +const req = () => + new NextRequest(new URL("http://localhost/api/documents/inbound/reindex"), { + method: "POST", + }); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); + vi.mocked(enqueueContentIndexBackfill).mockResolvedValue({ enqueued: true }); + // 7 live documents, 4 already indexed → 3 remain. + vi.mocked(prisma.inboundDocument.count).mockResolvedValue(7 as never); + vi.mocked(prisma.documentContentIndex.count).mockResolvedValue(4 as never); +}); + +describe("POST /api/documents/inbound/reindex", () => { + it("returns the remaining un-indexed count as a NUMBER, never a boolean", async () => { + const res = await POST(req() as never, {} as never); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body.data.enqueued).toBe("number"); + expect(body.data.enqueued).toBe(3); + }); + + it("returns an honest 0 when no job was created", async () => { + vi.mocked(enqueueContentIndexBackfill).mockResolvedValue({ + enqueued: false, + }); + const res = await POST(req() as never, {} as never); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.enqueued).toBe(0); + }); + + it("scopes both counts to the caller's LIVE documents (the gauge's own figures)", async () => { + await POST(req() as never, {} as never); + expect(prisma.inboundDocument.count).toHaveBeenCalledWith({ + where: { userId: "user-1", deletedAt: null }, + }); + expect(prisma.documentContentIndex.count).toHaveBeenCalledWith({ + where: { userId: "user-1", document: { deletedAt: null } }, + }); + }); +}); diff --git a/src/app/api/documents/inbound/reindex/route.ts b/src/app/api/documents/inbound/reindex/route.ts index c8f97cf9b..de2a13ff8 100644 --- a/src/app/api/documents/inbound/reindex/route.ts +++ b/src/app/api/documents/inbound/reindex/route.ts @@ -11,6 +11,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; import { enqueueContentIndexBackfill } from "@/lib/jobs/document-content-index-backfill"; import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; import { annotate } from "@/lib/logging/context"; @@ -58,16 +59,37 @@ export const POST = apiHandler(async (request) => { surface: "insights", }); - const { enqueued } = await enqueueContentIndexBackfill(user.id); + const { enqueued: jobCreated } = await enqueueContentIndexBackfill(user.id); + + // Wire honesty (#776): the client types `enqueued` as a NUMBER and + // interpolates it into "Indexing {count} document(s)" — a boolean here once + // rendered as "Indexing true document(s)". Report the count of live + // documents still lacking a content index (the exact figures the usage + // gauge already derives, same live-scoping: a soft-deleted document keeps + // its index row until hard purge, so both counts filter `deletedAt: null`). + // An honest 0 when no job was created — the client then says "everything is + // already indexed" instead of promising work nobody queued. + let enqueued = 0; + if (jobCreated) { + const [indexedCount, totalCount] = await Promise.all([ + prisma.documentContentIndex.count({ + where: { userId: user.id, document: { deletedAt: null } }, + }), + prisma.inboundDocument.count({ + where: { userId: user.id, deletedAt: null }, + }), + ]); + enqueued = Math.max(0, totalCount - indexedCount); + } await auditLog("documents.inbound.reindex", { userId: user.id, ipAddress: getClientIp(request), - details: { enqueued }, + details: { enqueued, jobCreated }, }); annotate({ action: { name: "documents.contentIndex.backfillEnqueue" }, - meta: { enqueued }, + meta: { enqueued, jobCreated }, }); return apiSuccess({ enqueued }); diff --git a/src/components/documents/__tests__/documents-view-reindex-toast.test.tsx b/src/components/documents/__tests__/documents-view-reindex-toast.test.tsx new file mode 100644 index 000000000..8b679a628 --- /dev/null +++ b/src/components/documents/__tests__/documents-view-reindex-toast.test.tsx @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/** + * Refs #776 — the "index all" toast's count honesty. + * + * The reindex route reports `enqueued` as a NUMBER (documents still to + * index); the toast interpolates it into "Indexing {count} document(s)". + * Before the route fix the wire carried `Boolean(jobId)` and the toast said + * "Indexing true document(s)". This pins the component end of that contract: + * fed the real wire shape, the success toast renders the count and NEVER the + * word "true"; a 0 says "everything is already indexed" instead of promising + * queued work. + * + * House convention: `renderToStaticMarkup` + handler exercised via a mocked + * child reference (the filter bar captures `onIndexAll`), no DOM events. + */ + +let mockSearch = ""; +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/documents", + useSearchParams: () => new URLSearchParams(mockSearch), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", modules: { inboundDocuments: true } }, + isLoading: false, + isAuthenticated: true, + }), +})); + +vi.mock("@/lib/api/api-fetch", async () => { + const actual = await vi.importActual( + "@/lib/api/api-fetch", + ); + return { + ...actual, + apiGet: () => new Promise(() => {}), + apiPatch: vi.fn(), + apiPost: vi.fn(), + apiDelete: vi.fn(), + }; +}); + +const toastSuccess = vi.fn(); +const toastError = vi.fn(); +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => toastSuccess(...args), + error: (...args: unknown[]) => toastError(...args), + }, +})); + +// Capture the view's index-all handler through the filter bar's prop — the +// static render cannot click, so the mocked child is the seam. +let capturedOnIndexAll: (() => void) | undefined; +vi.mock("../document-filter-bar", () => ({ + DocumentFilterBar: (props: { onIndexAll?: () => void }) => { + capturedOnIndexAll = props.onIndexAll; + return null; + }, +})); + +// The reindex mutation resolves synchronously with a configurable wire +// payload — exactly what `apiPost` would unwrap from the route envelope. +let wirePayload: { enqueued: number }; +vi.mock("../use-content-index", () => ({ + useIndexDocument: () => ({ mutate: vi.fn(), isPending: false }), + useReindexAll: () => ({ + mutate: ( + _vars: void, + opts?: { onSuccess?: (result: { enqueued: number }) => void }, + ) => { + opts?.onSuccess?.(wirePayload); + }, + isPending: false, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { DocumentsView } from "../documents-view"; + +function render() { + mockSearch = ""; + capturedOnIndexAll = undefined; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + renderToStaticMarkup( + + + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe(" index-all toast (#776)", () => { + it("renders the remaining count — never the word 'true'", () => { + wirePayload = { enqueued: 3 }; + render(); + expect(capturedOnIndexAll).toBeDefined(); + + capturedOnIndexAll!(); + + expect(toastSuccess).toHaveBeenCalledTimes(1); + const message = String(toastSuccess.mock.calls[0]![0]); + expect(message).toContain("3"); + expect(message).not.toContain("true"); + expect(toastError).not.toHaveBeenCalled(); + }); + + it("says everything is indexed on an honest 0, not 'indexing 0 documents'", () => { + wirePayload = { enqueued: 0 }; + render(); + + capturedOnIndexAll!(); + + expect(toastSuccess).toHaveBeenCalledTimes(1); + const message = String(toastSuccess.mock.calls[0]![0]); + expect(message).toContain("already indexed"); + expect(message).not.toContain("true"); + }); +}); diff --git a/src/components/documents/use-content-index.ts b/src/components/documents/use-content-index.ts index 3d9434cac..28f164ba8 100644 --- a/src/components/documents/use-content-index.ts +++ b/src/components/documents/use-content-index.ts @@ -41,7 +41,16 @@ export function useIndexDocument() { }); } -/** Fire the corpus backfill; resolves with how many docs were enqueued. */ +/** + * Fire the corpus backfill; resolves with how many docs were enqueued. + * + * Wire contract (#776): `enqueued` is the NUMBER of live documents the + * backfill still has to index — the reindex route computes it from the same + * live-scoped counts the usage gauge reads, and returns an honest 0 when no + * job was created. The toast interpolates it as a count, so this type is + * load-bearing: it must match the route's shape exactly (a boolean here once + * rendered as "Indexing true document(s)"). + */ export function useReindexAll() { const queryClient = useQueryClient(); return useMutation<{ enqueued: number }, Error, void>({ diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 5c52046f8..2014415a7 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -1028,12 +1028,13 @@ export const inboundDocumentPaths: NonNullable = { "Enqueues a background job that content-indexes the caller's not-yet-indexed documents (one provider transcription each, bounded + resumable). Gated on the module, a configured vision provider (422 `documents.inbound.providerUnsupported` otherwise), and the existing AI consent (403 otherwise). Returns immediately; the work runs off-request on the queue.", responses: { "200": { - description: "Whether a backfill job was enqueued.", + description: + "How many live documents the enqueued backfill still has to index (0 when everything is indexed or no job was created).", content: { "application/json": { schema: dataEnvelope( z - .object({ enqueued: z.boolean() }) + .object({ enqueued: z.number().int().nonnegative() }) .meta({ id: "DocumentReindexResponse" }), "DocumentReindexEnvelope", ),