CHANGELOG: stop presenting the service-worker API cache as an active offline store (/api is NetworkOnly) — describe it as a defensive purge of the legacy api-get-cache from older clients; add an explicit upgrade note that the new CORS allowlist rejects previously-allowed cross-domain REST clients until their origin is added to CORS_ALLOWED_ORIGINS. test(offline): cover make-offline ancestor-walk + dedup — a real-ancestor case exercising the ancestorId===pageId guard (page warmed once), the dedup of repeated tree failures into a single "tree" label, and the "breadcrumbs" label when the breadcrumbs lookup rejects. test(auth): cover clearOfflineCache in handleLogout — purged exactly once before window.location.replace, and a thrown purge error does not block the redirect. conventions: use pageKeys.detail() instead of raw ["pages", …] literals in title-editor and use-page-collab-providers. cleanup: remove the dead emit() in title-editor (the gateway ignores it; the cross-user tree refresh is server-side via the Yjs title fragment); drop the trivial Array.isArray(tiptapExtensions) test (schema is exercised transitively). refactor: extract the shared page.<id> Yjs doc-name convention into pageYdocName()/PAGE_YDOC_NAME_PREFIX so the editor providers, offline warm, and offline purge can no longer drift apart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
155 lines
5.1 KiB
TypeScript
155 lines
5.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { renderHook, act } from "@testing-library/react";
|
|
|
|
// react-i18next: identity t() so the hook renders without an i18n provider.
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({ t: (k: string) => k }),
|
|
}));
|
|
|
|
// react-router-dom: only useNavigate is used by the hook.
|
|
const navigateMock = vi.fn();
|
|
vi.mock("react-router-dom", () => ({
|
|
useNavigate: () => navigateMock,
|
|
}));
|
|
|
|
// The auth service is the network boundary; stub login/logout per test.
|
|
const loginMock = vi.fn();
|
|
const logoutMock = vi.fn();
|
|
vi.mock("@/features/auth/services/auth-service", () => ({
|
|
login: (...args: unknown[]) => loginMock(...args),
|
|
logout: (...args: unknown[]) => logoutMock(...args),
|
|
forgotPassword: vi.fn(),
|
|
passwordReset: vi.fn(),
|
|
setupWorkspace: vi.fn(),
|
|
verifyUserToken: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/features/workspace/services/workspace-service.ts", () => ({
|
|
acceptInvitation: vi.fn(),
|
|
}));
|
|
|
|
// The offline cache purge is the unit under test — assert it is invoked.
|
|
const clearOfflineCacheMock = vi.fn();
|
|
vi.mock("@/features/offline/clear-offline-cache", () => ({
|
|
clearOfflineCache: () => clearOfflineCacheMock(),
|
|
}));
|
|
|
|
// app-route helpers are pure config; provide deterministic values.
|
|
vi.mock("@/lib/app-route.ts", () => ({
|
|
default: { AUTH: { LOGIN: "/login" }, HOME: "/home" },
|
|
getPostLoginRedirect: () => "/home",
|
|
}));
|
|
|
|
// Mantine notifications: avoid touching the DOM-bound notification system.
|
|
vi.mock("@mantine/notifications", () => ({
|
|
notifications: { show: vi.fn() },
|
|
}));
|
|
|
|
import useAuth from "./use-auth";
|
|
|
|
beforeEach(() => {
|
|
navigateMock.mockReset();
|
|
loginMock.mockReset();
|
|
loginMock.mockResolvedValue(undefined);
|
|
logoutMock.mockReset();
|
|
logoutMock.mockResolvedValue(undefined);
|
|
clearOfflineCacheMock.mockReset();
|
|
clearOfflineCacheMock.mockResolvedValue(undefined);
|
|
});
|
|
|
|
describe("useAuth.handleSignIn", () => {
|
|
it("clears the offline cache BEFORE logging in (cross-user leak guard)", async () => {
|
|
const order: string[] = [];
|
|
clearOfflineCacheMock.mockImplementation(async () => {
|
|
order.push("clear");
|
|
});
|
|
loginMock.mockImplementation(async () => {
|
|
order.push("login");
|
|
});
|
|
|
|
const { result } = renderHook(() => useAuth());
|
|
await act(async () => {
|
|
await result.current.signIn({ email: "b@x", password: "pw" } as any);
|
|
});
|
|
|
|
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
|
expect(loginMock).toHaveBeenCalledTimes(1);
|
|
// The purge must run before the new session's login resolves.
|
|
expect(order).toEqual(["clear", "login"]);
|
|
expect(navigateMock).toHaveBeenCalledWith("/home");
|
|
});
|
|
|
|
it("does not block sign-in when the cache purge throws (best-effort)", async () => {
|
|
clearOfflineCacheMock.mockRejectedValue(new Error("idb unavailable"));
|
|
|
|
const { result } = renderHook(() => useAuth());
|
|
await act(async () => {
|
|
await result.current.signIn({ email: "b@x", password: "pw" } as any);
|
|
});
|
|
|
|
// Login still proceeds despite the cleanup failure.
|
|
expect(loginMock).toHaveBeenCalledTimes(1);
|
|
expect(navigateMock).toHaveBeenCalledWith("/home");
|
|
});
|
|
});
|
|
|
|
describe("useAuth.handleLogout", () => {
|
|
const replaceMock = vi.fn();
|
|
let originalLocation: Location;
|
|
|
|
beforeEach(() => {
|
|
replaceMock.mockReset();
|
|
// window.location.replace is the post-logout redirect. jsdom's real `replace`
|
|
// is a non-configurable method that warns "not implemented", so swap the
|
|
// whole location object for one whose `replace` we can capture.
|
|
originalLocation = window.location;
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
writable: true,
|
|
value: { replace: replaceMock },
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
writable: true,
|
|
value: originalLocation,
|
|
});
|
|
});
|
|
|
|
it("purges the offline cache exactly once BEFORE redirecting (cross-user leak guard)", async () => {
|
|
const order: string[] = [];
|
|
clearOfflineCacheMock.mockImplementation(async () => {
|
|
order.push("clear");
|
|
});
|
|
replaceMock.mockImplementation((url: string) => {
|
|
order.push(`replace:${url}`);
|
|
});
|
|
|
|
const { result } = renderHook(() => useAuth());
|
|
await act(async () => {
|
|
await result.current.logout();
|
|
});
|
|
|
|
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
|
// Purge must complete before the redirect (which would otherwise interrupt
|
|
// the async cleanup).
|
|
expect(order).toEqual(["clear", "replace:/login?logout=1"]);
|
|
});
|
|
|
|
it("still redirects when the cache purge throws (best-effort, never blocks logout)", async () => {
|
|
clearOfflineCacheMock.mockRejectedValue(new Error("idb unavailable"));
|
|
|
|
const { result } = renderHook(() => useAuth());
|
|
await act(async () => {
|
|
await result.current.logout();
|
|
});
|
|
|
|
// The thrown purge error is swallowed and the redirect still fires.
|
|
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
|
expect(replaceMock).toHaveBeenCalledTimes(1);
|
|
expect(replaceMock).toHaveBeenCalledWith("/login?logout=1");
|
|
});
|
|
});
|