Files
HealthLog/src/lib/jobs/__tests__/document-summary-catchup.test.ts
T
Marc-André Bombeck 764794496c feat(i18n): register Brazilian Portuguese as a locale
Add pt-BR to the locale list and everything that hangs off it: the
client loader, Intl and date-fns mapping, the reply-language footers of
the Coach and insights prompts (English body, pt-BR footer, the same
path Korean and Dutch take), the citation-coverage normative words, the
no-key fallback copy, the AUDIT-C standard-drink line, country labels,
period search, CSV yes/no cells and the Telegram bare-word aliases.

The locale enums in the extract, managed-profile and record-settings
schemas now derive from the locale list instead of repeating it, and
the five notification and login-alert routes share one isLocale guard
instead of hand-rolled chains, so a later locale is one edit there.
A language tag is matched exactly and case-insensitively before its
primary subtag, so pt-BR and pt-PT browsers land on the Portuguese
bundle rather than English, and AUDIT-C no longer cuts a region tag to
two letters.

PHQ-9, GAD-7 and WHO-5 are not marked validated for pt-BR: the wording
has not been read against the published Brazilian forms, so readers get
the English items plus the existing note.
2026-10-22 14:50:00 +02:00

287 lines
9.4 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* The auto-read catch-up pass.
*
* Documents are summarised by a job enqueued at UPLOAD time that no-ops while
* the `documentsAutoAiRead` opt-in is OFF, so a vault filled before the flip was
* never read and the toggle appeared to do nothing. These tests pin the catch-up
* that closes that hole, and the three properties it must not lose: it stays
* bounded, it stays idempotent, and it grants no consent or budget of its own.
*/
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: { inboundDocument: { findMany: vi.fn() } },
}));
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
vi.mock("@/lib/documents/document-settings", () => ({
documentAutoReadEnabled: vi.fn(),
}));
vi.mock("@/lib/jobs/document-summary", () => ({
enqueueDocumentSummary: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForJob: vi.fn(),
aiCapabilityForRecord: vi.fn(),
}));
import {
enqueueSummaryCatchUp,
runSummaryCatchUpForUser,
MAX_ENQUEUES_PER_RUN,
DOCUMENT_SUMMARY_CATCHUP_QUEUE,
} from "../document-summary-catchup";
import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { documentAutoReadEnabled } from "@/lib/documents/document-settings";
import { enqueueDocumentSummary } from "@/lib/jobs/document-summary";
import { annotate } from "@/lib/logging/context";
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
const findMany = vi.mocked(prisma.inboundDocument.findMany);
const mockEnqueueSummary = vi.mocked(enqueueDocumentSummary);
const mockAutoRead = vi.mocked(documentAutoReadEnabled);
const mockCapability = vi.mocked(aiCapabilityForJob);
/** Serve `total` document ids across the job's id-cursor paged walk. */
function serveDocuments(total: number) {
const ids = Array.from({ length: total }, (_, i) => ({
id: `doc-${String(i).padStart(5, "0")}`,
}));
findMany.mockImplementation((async (args: {
take: number;
cursor?: { id: string };
}) => {
const start = args.cursor
? ids.findIndex((d) => d.id === args.cursor!.id) + 1
: 0;
return ids.slice(start, start + args.take);
}) as unknown as typeof findMany);
}
beforeEach(() => {
vi.clearAllMocks();
mockAutoRead.mockResolvedValue(true);
mockCapability.mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
mockEnqueueSummary.mockResolvedValue({ enqueued: true });
});
describe("runSummaryCatchUpForUser", () => {
it("enqueues a summary for every already-stored un-summarised document", async () => {
serveDocuments(3);
const result = await runSummaryCatchUpForUser("user-1");
expect(result).toEqual({ enqueued: 3, capped: false });
expect(mockEnqueueSummary).toHaveBeenCalledTimes(3);
expect(mockEnqueueSummary).toHaveBeenCalledWith("user-1", "doc-00000");
expect(mockEnqueueSummary).toHaveBeenCalledWith("user-1", "doc-00002");
});
it("only considers documents that have no summary yet", async () => {
// Idempotency floor: a re-run cannot redo finished work because a
// summarised document is not in the candidate set at all.
serveDocuments(1);
await runSummaryCatchUpForUser("user-1");
const where = findMany.mock.calls[0]![0]!.where;
expect(where).toMatchObject({
userId: "user-1",
deletedAt: null,
summaryEncrypted: null,
});
});
it("leaves imports held back from AI reading alone (#1038)", async () => {
serveDocuments(1);
await runSummaryCatchUpForUser("user-1");
expect(findMany.mock.calls[0]![0]!.where).toMatchObject({
aiReadDeferred: false,
});
});
it("stops at the documented cap instead of queueing a whole vault", async () => {
serveDocuments(MAX_ENQUEUES_PER_RUN + 50);
const result = await runSummaryCatchUpForUser("user-1");
expect(result.enqueued).toBe(MAX_ENQUEUES_PER_RUN);
expect(result.capped).toBe(true);
expect(mockEnqueueSummary).toHaveBeenCalledTimes(MAX_ENQUEUES_PER_RUN);
});
it("re-reads the opt-in and does nothing when it was flipped back OFF", async () => {
// Consent race: the PATCH that scheduled the pass is not authority enough.
serveDocuments(5);
mockAutoRead.mockResolvedValue(false);
const result = await runSummaryCatchUpForUser("user-1");
expect(result).toEqual({ enqueued: 0, capped: false });
expect(mockEnqueueSummary).not.toHaveBeenCalled();
expect(findMany).not.toHaveBeenCalled();
});
it.each(["operator_disabled", "module_disabled", "user_disabled"] as const)(
"does nothing when the documentAi capability is unavailable (%s)",
async (reason) => {
serveDocuments(5);
mockCapability.mockResolvedValue({
available: false,
reason,
onDeviceAllowed: false,
});
const result = await runSummaryCatchUpForUser("user-1");
expect(mockCapability).toHaveBeenCalledWith("user-1", "documentAi");
expect(result).toEqual({ enqueued: 0, capped: false });
// Refused before a single document is listed.
expect(findMany).not.toHaveBeenCalled();
expect(mockEnqueueSummary).not.toHaveBeenCalled();
expect(annotate).toHaveBeenCalledWith({
action: { name: "documents.autoRead.catchUpSkipped" },
meta: { reason },
});
},
);
it.each(["no_provider", "consent_required"] as const)(
"leaves %s to each job's pick: still walks and enqueues",
async (reason) => {
// Whether there is a provider and whether sending to it needs a receipt
// depends on the document-order pick, which the per-document job
// re-checks at the wire. The pass-level presence answer does not decide.
serveDocuments(2);
mockCapability.mockResolvedValue({
available: false,
reason,
onDeviceAllowed: true,
});
const result = await runSummaryCatchUpForUser("user-1");
expect(result).toEqual({ enqueued: 2, capped: false });
expect(mockEnqueueSummary).toHaveBeenCalledTimes(2);
expect(annotate).not.toHaveBeenCalledWith(
expect.objectContaining({
action: { name: "documents.autoRead.catchUpSkipped" },
}),
);
},
);
it("routes work through the ordinary summary job, granting nothing itself", async () => {
// The per-document job is what re-asserts egress consent and reserves the
// daily budget. The catch-up must not have its own provider or budget path.
serveDocuments(2);
await runSummaryCatchUpForUser("user-1");
expect(mockEnqueueSummary).toHaveBeenCalledTimes(2);
for (const call of mockEnqueueSummary.mock.calls) {
expect(call[0]).toBe("user-1");
}
});
it("emits the catch-up wide event", async () => {
serveDocuments(2);
await runSummaryCatchUpForUser("user-1");
expect(annotate).toHaveBeenCalledWith({
action: { name: "documents.autoRead.catchUp" },
meta: { enqueued: 2, capped: false },
});
});
});
describe("enqueueSummaryCatchUp", () => {
it("coalesces a double toggle onto one per-user singleton key", async () => {
const send = vi.fn().mockResolvedValue("job-1");
vi.mocked(getGlobalBoss).mockReturnValue({
send,
} as unknown as ReturnType<typeof getGlobalBoss>);
await enqueueSummaryCatchUp("user-1");
await enqueueSummaryCatchUp("user-1");
expect(send).toHaveBeenCalledTimes(2);
for (const call of send.mock.calls) {
expect(call[0]).toBe(DOCUMENT_SUMMARY_CATCHUP_QUEUE);
expect(call[2]).toMatchObject({
singletonKey: "document-summary-catchup|user-1",
});
}
});
it("is a no-op without a boss and never throws on a send failure", async () => {
vi.mocked(getGlobalBoss).mockReturnValue(
null as unknown as ReturnType<typeof getGlobalBoss>,
);
await expect(enqueueSummaryCatchUp("user-1")).resolves.toEqual({
enqueued: false,
});
vi.mocked(getGlobalBoss).mockReturnValue({
send: vi.fn().mockRejectedValue(new Error("down")),
} as unknown as ReturnType<typeof getGlobalBoss>);
await expect(enqueueSummaryCatchUp("user-1")).resolves.toEqual({
enqueued: false,
});
});
});
describe("the toggle copy discloses the catch-up", () => {
const LOCALES = [
"de",
"en",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
function autoReadCopy(locale: string): { subLabel: string; honesty: string } {
const bundle = JSON.parse(
readFileSync(
join(__dirname, `../../../../messages/${locale}.json`),
"utf8",
),
) as {
settings: {
ai: { autoRead: { subLabel: string; honesty: string } };
};
};
return bundle.settings.ai.autoRead;
}
it.each(LOCALES)(
"%s does not promise the switch only applies to future uploads",
(locale) => {
// The flip enqueues a pass over documents ALREADY stored, so copy that
// scopes the switch to newly uploaded documents understates its reach.
const { subLabel } = autoReadCopy(locale);
expect(subLabel).not.toMatch(
/newly|neu hochgeladene|recién subidos|nouvellement téléversés|appena caricati|nowo przesłane/i,
);
},
);
it.each(LOCALES)("%s names the retroactive pass and its cap", (locale) => {
const { honesty } = autoReadCopy(locale);
expect(honesty).toContain(String(MAX_ENQUEUES_PER_RUN));
});
});