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
This commit is contained in:
Marc-André Bombeck committed 2026-08-13 12:13:55 +02:00
1 parent 4543fa7192
commit 86a3d74187
6 files changed
+290 -8

No files matched your search

+5 -2
View File
@@ -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
@@ -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 } },
});
});
});
+25 -3
View File
@@ -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 });
@@ -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<typeof import("@/lib/api/api-fetch")>(
"@/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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<DocumentsView />
</I18nProvider>
</QueryClientProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("<DocumentsView> 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");
});
});
+10 -1
View File
@@ -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>({
+3 -2
View File
@@ -1028,12 +1028,13 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"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",
),