mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
4543fa7192
commit
86a3d74187
6 files changed
+290
-8
No files matched your search
@@ -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 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>({
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user