Skip to content
Merged
150 changes: 150 additions & 0 deletions tests/document-viewer-shell.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
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
// / 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.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() }),
Comment thread
BigSimmo marked this conversation as resolved.
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(),
Comment thread
BigSimmo marked this conversation as resolved.
Comment on lines +22 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stabilize the auth mock dependencies

The fresh evidence in this head is that useAuthSession still constructs a new authorizationHeader object and auth callbacks on every render. DocumentViewer includes these values in the document-load effect dependencies, and each initialDetail/initialError load writes fresh array state before rerendering, so this new jsdom suite can keep aborting and restarting the same load path until it times out or hits update-depth/act noise instead of reliably proving the private-document shell. Hoist a single auth session/header/functions object from the mock.

AGENTS.md reference: AGENTS.md:L490-L503

Useful? React with 👍 / 👎.

signInWithEmail: vi.fn(),
signInWithPassword: vi.fn(),
signUpWithPassword: vi.fn(),
signInWithOAuth: vi.fn(),
signOut: vi.fn(),
}),
}));

// 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.
vi.mock("@/components/document-viewer/pdf-canvas-viewer", () => ({
PdfCanvasViewer: () => null,
NativePdfEmbed: () => null,
}));

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,
},
pages: [],
images: [],
tableFacts: [],
chunks: [],
demoMode: true,
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
// 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();
});

describe("DocumentViewer — shell states", () => {
it("shows the sign-in shell (never document content) when private access is required", async () => {
render(
<DocumentViewer documentId="doc-1" initialPage={1} initialError="Sign in to open private source documents." />,
Comment thread
BigSimmo marked this conversation as resolved.
);

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", async () => {
render(<DocumentViewer documentId="doc-1" initialPage={1} initialError="Document could not be loaded." />);

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", async () => {
render(<DocumentViewer documentId="doc-1" initialPage={1} initialDetail={detailPayload()} />);

// 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();
});
});