From b73d675714c7c679f588b4d3a888f526771d5adf Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:30:35 +0800 Subject: [PATCH 1/4] test(document-viewer): shell-state coverage incl. the private-access gate (TCD-03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DocumentViewer resolves a four-way shell state (loading / ready / auth-required / error) that decides whether a source document is shown at all, and it had no render coverage. The state is prop-drivable via initialDetail / initialError, so these tests pin it without a network: - auth-required: a private-access failure resolves to the "Sign in required" shell with its reason — not the generic failure shell (which would read as "broken" rather than "sign in"). - error: a generic load failure resolves to "Source unavailable" with the reason, and never to the sign-in shell. - ready: a supplied detail payload resolves to the document identity and to neither failure shell. Only useRouter and useAuthSession are external (the rest of the component's hooks are local state); the next/dynamic PDF preview is mocked defensively so a late resolve cannot pull a real canvas into jsdom. Verified: 3/3 pass; typecheck, lint, format:check clean. Co-Authored-By: Claude Opus 4.8 --- tests/document-viewer-shell.dom.test.tsx | 104 +++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 tests/document-viewer-shell.dom.test.tsx diff --git a/tests/document-viewer-shell.dom.test.tsx b/tests/document-viewer-shell.dom.test.tsx new file mode 100644 index 00000000..ef21676f --- /dev/null +++ b/tests/document-viewer-shell.dom.test.tsx @@ -0,0 +1,104 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// DocumentViewer resolves a four-way shell state (loading / ready / auth-required +// / error) that decides whether a source document is shown at all. The +// auth-required branch is the private-document gate: an unauthenticated reader +// must get the sign-in shell, never document content. The state is prop-drivable +// via initialDetail / initialError, so these tests pin it without a network. + +const push = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push, replace: vi.fn(), refresh: vi.fn(), back: vi.fn(), forward: vi.fn(), prefetch: vi.fn() }), + useSearchParams: () => new URLSearchParams(), + usePathname: () => "/documents/doc-1", +})); + +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => ({ + status: "signed_out", + session: null, + isConfigured: true, + authorizationHeader: {}, + registerAuthRequest: () => ({ epoch: 1, release: vi.fn() }), + isAuthEpochCurrent: () => true, + markSessionExpired: vi.fn(), + signInWithEmail: vi.fn(), + signInWithPassword: vi.fn(), + signUpWithPassword: vi.fn(), + signInWithOAuth: vi.fn(), + signOut: vi.fn(), + }), +})); + +// The preview is next/dynamic-loaded and never mounts synchronously in jsdom; +// it is mocked defensively so a late resolve cannot pull a real canvas into the +// test environment. The shell state, not the raster preview, is under test. +vi.mock("@/components/document-viewer/pdf-canvas-viewer", () => ({ + PdfCanvasViewer: () => null, + NativePdfEmbed: () => null, +})); + +import { DocumentViewer } from "@/components/DocumentViewer"; + +function detailPayload() { + return { + document: { + id: "doc-1", + title: "Clozapine titration guideline", + file_name: "clozapine-titration.pdf", + file_type: "application/pdf", + status: "indexed", + page_count: 4, + chunk_count: 8, + image_count: 0, + updated_at: "2026-01-01T00:00:00.000Z", + labels: [], + metadata: {}, + summary: null, + }, + pages: [], + images: [], + tableFacts: [], + chunks: [], + demoMode: true, + assetScope: "public", + window: { requestedPage: 1, effectivePage: 1, selectedChunkId: null }, + } as never; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("DocumentViewer — shell states", () => { + it("shows the sign-in shell (never document content) when private access is required", () => { + render( + , + ); + + expect(screen.getByText("Sign in required")).toBeVisible(); + expect(screen.getByText("Sign in to open private source documents.")).toBeVisible(); + // The private-access gate must resolve to its own shell, not the generic + // failure shell (which would read as "broken" rather than "sign in"). + expect(screen.queryByText("Source unavailable")).toBeNull(); + }); + + it("shows the unavailable shell with the failure reason for a generic load error", () => { + render(); + + expect(screen.getByText("Source unavailable")).toBeVisible(); + expect(screen.getByText("Document could not be loaded.")).toBeVisible(); + expect(screen.queryByText("Sign in required")).toBeNull(); + }); + + it("shows the ready shell with the document identity when a detail payload is supplied", () => { + render(); + + // The display title is smart-cased, so match on a distinctive word. + expect(screen.getAllByText(/clozapine/i).length).toBeGreaterThan(0); + // A supplied payload must resolve to the ready shell — neither failure shell. + expect(screen.queryByText("Source unavailable")).toBeNull(); + expect(screen.queryByText("Sign in required")).toBeNull(); + }); +}); From 91d14788ceac845149e832e4894317a191ef474e Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:51:40 +0800 Subject: [PATCH 2/4] fix(test): make the DocumentViewer shell tests independent of the environment The auth-required assertion passed on a workstation and failed in CI. The viewer gates every document request on a local-project identity probe (/api/local-project-id); when that probe reports no safe local origin it replaces the viewer error with its own "unsafe local project" message, so the private-access gate never engages and the sign-in shell never renders. Whether the probe succeeds depended on a reachable dev server, not on the behaviour under test. Stub the probe as a safe local origin, and await the shell text so the assertion holds whichever tick the state settles on. Flipping the stub to an unsafe origin reproduces the CI failure exactly, which is the evidence that this was the cause. Co-Authored-By: Claude Opus 4.8 --- tests/document-viewer-shell.dom.test.tsx | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/document-viewer-shell.dom.test.tsx b/tests/document-viewer-shell.dom.test.tsx index ef21676f..4c471b1d 100644 --- a/tests/document-viewer-shell.dom.test.tsx +++ b/tests/document-viewer-shell.dom.test.tsx @@ -31,6 +31,17 @@ vi.mock("@/lib/supabase/client", () => ({ }), })); +// Every document request is gated on a local-project identity probe +// (/api/local-project-id). No server answers it in a unit run, and when the +// probe fails the viewer replaces the error under test with its own +// "unsafe local project" message — which is exactly why this file passed on a +// workstation with the dev server up and failed in CI. Stub it as a safe local +// origin so the shell state, not the environment, decides the outcome. +vi.mock("@/lib/local-project-identity", () => ({ + readLocalProjectIdentity: async () => ({ localServer: { safeLocalOrigin: true } }), + unsafeLocalProjectMessage: () => "This local server does not belong to this project.", +})); + // The preview is next/dynamic-loaded and never mounts synchronously in jsdom; // it is mocked defensively so a late resolve cannot pull a real canvas into the // test environment. The shell state, not the raster preview, is under test. @@ -72,31 +83,31 @@ afterEach(() => { }); describe("DocumentViewer — shell states", () => { - it("shows the sign-in shell (never document content) when private access is required", () => { + it("shows the sign-in shell (never document content) when private access is required", async () => { render( , ); - expect(screen.getByText("Sign in required")).toBeVisible(); + expect(await screen.findByText("Sign in required")).toBeVisible(); expect(screen.getByText("Sign in to open private source documents.")).toBeVisible(); // The private-access gate must resolve to its own shell, not the generic // failure shell (which would read as "broken" rather than "sign in"). expect(screen.queryByText("Source unavailable")).toBeNull(); }); - it("shows the unavailable shell with the failure reason for a generic load error", () => { + it("shows the unavailable shell with the failure reason for a generic load error", async () => { render(); - expect(screen.getByText("Source unavailable")).toBeVisible(); + expect(await screen.findByText("Source unavailable")).toBeVisible(); expect(screen.getByText("Document could not be loaded.")).toBeVisible(); expect(screen.queryByText("Sign in required")).toBeNull(); }); - it("shows the ready shell with the document identity when a detail payload is supplied", () => { + it("shows the ready shell with the document identity when a detail payload is supplied", async () => { render(); // The display title is smart-cased, so match on a distinctive word. - expect(screen.getAllByText(/clozapine/i).length).toBeGreaterThan(0); + expect((await screen.findAllByText(/clozapine/i)).length).toBeGreaterThan(0); // A supplied payload must resolve to the ready shell — neither failure shell. expect(screen.queryByText("Source unavailable")).toBeNull(); expect(screen.queryByText("Sign in required")).toBeNull(); From 69c193a195680b63b081495b6a32fecc0654750f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:01:19 +0800 Subject: [PATCH 3/4] fix(test): pin demo mode off in the DocumentViewer gate tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In demo / local-no-auth mode every document is public, so the private-access gate is deliberately inert and the sign-in shell can never render. The CI runner exports one of those, which turned the gate assertion into a failure there while it passed on a workstation without them — reproduced locally with NEXT_PUBLIC_LOCAL_NO_AUTH=true, which fails exactly the same single test. Stub both flags off per test so the assertion is about the gate rather than the runner's mode. Verified green with LOCAL_NO_AUTH=true, with DEMO_MODE=true, and with a clean environment; flipping the mocked session to authenticated still turns it red, so the gate is genuinely asserted. Co-Authored-By: Claude Opus 4.8 --- tests/document-viewer-shell.dom.test.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/document-viewer-shell.dom.test.tsx b/tests/document-viewer-shell.dom.test.tsx index 4c471b1d..b4cb2f63 100644 --- a/tests/document-viewer-shell.dom.test.tsx +++ b/tests/document-viewer-shell.dom.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // DocumentViewer resolves a four-way shell state (loading / ready / auth-required // / error) that decides whether a source document is shown at all. The @@ -78,7 +78,17 @@ function detailPayload() { } as never; } +// In demo / local-no-auth mode every document is public, so the private-access +// gate is deliberately inert and the sign-in shell can never render. Whichever +// of those a runner happens to export would silently turn the gate assertion +// into a no-op, so pin both off — this test is about the gate, not the mode. +beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_DEMO_MODE", "false"); + vi.stubEnv("NEXT_PUBLIC_LOCAL_NO_AUTH", "false"); +}); + afterEach(() => { + vi.unstubAllEnvs(); vi.clearAllMocks(); }); From f39aaa8f7edc478d72dd147f689c9ab1bc5403fb Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:15:48 +0000 Subject: [PATCH 4/4] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 3 unresolved review comments. Co-authored-by: CodeRabbit --- tests/document-viewer-shell.dom.test.tsx | 39 +++++++++++++++++++----- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/tests/document-viewer-shell.dom.test.tsx b/tests/document-viewer-shell.dom.test.tsx index b4cb2f63..c4751bc8 100644 --- a/tests/document-viewer-shell.dom.test.tsx +++ b/tests/document-viewer-shell.dom.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // DocumentViewer resolves a four-way shell state (loading / ready / auth-required @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // must get the sign-in shell, never document content. The state is prop-drivable // via initialDetail / initialError, so these tests pin it without a network. -const push = vi.fn(); +const { push } = vi.hoisted(() => ({ push: vi.fn() })); vi.mock("next/navigation", () => ({ useRouter: () => ({ push, replace: vi.fn(), refresh: vi.fn(), back: vi.fn(), forward: vi.fn(), prefetch: vi.fn() }), useSearchParams: () => new URLSearchParams(), @@ -51,19 +51,25 @@ vi.mock("@/components/document-viewer/pdf-canvas-viewer", () => ({ })); import { DocumentViewer } from "@/components/DocumentViewer"; +import type { DocumentDetailPayload } from "@/lib/document-detail-contract"; function detailPayload() { return { document: { id: "doc-1", title: "Clozapine titration guideline", + description: null, file_name: "clozapine-titration.pdf", file_type: "application/pdf", + file_size: 204800, + storage_path: "documents/doc-1/clozapine-titration.pdf", status: "indexed", page_count: 4, chunk_count: 8, image_count: 0, + error_message: null, updated_at: "2026-01-01T00:00:00.000Z", + created_at: "2026-01-01T00:00:00.000Z", labels: [], metadata: {}, summary: null, @@ -73,9 +79,17 @@ function detailPayload() { tableFacts: [], chunks: [], demoMode: true, - assetScope: "public", - window: { requestedPage: 1, effectivePage: 1, selectedChunkId: null }, - } as never; + assetScope: "document", + window: { + requestedPage: 1, + effectivePage: 1, + selectedChunkId: null, + pages: { from: 1, to: 4, limit: 4, total: 4, hasBefore: false, hasAfter: false }, + chunks: { offset: 0, limit: 8, total: 8, hasBefore: false, hasAfter: false, selectedChunkId: null }, + }, + pageWindow: { from: 1, to: 4, limit: 4, total: 4, hasBefore: false, hasAfter: false }, + chunkWindow: { offset: 0, limit: 8, total: 8, hasBefore: false, hasAfter: false, selectedChunkId: null }, + } satisfies DocumentDetailPayload; } // In demo / local-no-auth mode every document is public, so the private-access @@ -116,8 +130,19 @@ describe("DocumentViewer — shell states", () => { it("shows the ready shell with the document identity when a detail payload is supplied", async () => { render(); - // The display title is smart-cased, so match on a distinctive word. - expect((await screen.findAllByText(/clozapine/i)).length).toBeGreaterThan(0); + // The display title is smart-cased from "Clozapine titration guideline" and + // rendered in the header h1. The exact filename is visible only inside the + // document actions sheet, opened by the "Open document actions" button. + const heading = await screen.findByRole("heading", { level: 1, name: "Clozapine Titration Guideline" }); + expect(heading).toBeVisible(); + + // Open the document actions sheet and verify the exact filename is visible. + // There are two "Open document actions" buttons (header and floating composer); + // click the first one (header button) to open the actions sheet. + const actionsButtons = screen.getAllByRole("button", { name: "Open document actions" }); + fireEvent.click(actionsButtons[0]); + expect(await screen.findByText("clozapine-titration.pdf")).toBeVisible(); + // A supplied payload must resolve to the ready shell — neither failure shell. expect(screen.queryByText("Source unavailable")).toBeNull(); expect(screen.queryByText("Sign in required")).toBeNull();