ca26af9e9d
Security: - Clear the offline IndexedDB cache on sign-in (not only logout) so a previous user's persisted query cache and Yjs page bodies cannot leak to the next user on a shared device when the prior session ended without an explicit logout. Regressions: - Remove the double Yjs title write from the AI title-generation path: the title editor is bound to the Yjs `title` fragment and the server REST update reseeds it, so the local setContent raced that reseed and doubled/garbled the title. Conventions / i18n / docs: - Remove the unused showAiMenuAtom. - Register the 3 offline-fallback strings in en-US and ru-RU. - Fix the 5 broken links to the nonexistent docs/offline-sync-plan.md. Stability / simplification: - warmInfiniteAll now reports truncation (returns false) when it hits maxPages with a cursor still pending instead of silently succeeding. - space-tree make-offline catch logs the raw error and surfaces the real cause. - Move the Offline/Mobile/CORS CHANGELOG entries from the released 0.93.0 section into [Unreleased] (CORS is a documented breaking change). - Drop the pass-through sync-flag forwarders in use-page-collab-providers; set the atoms directly. - Collapse the three isSwaggerEnabled true-cases into it.each. Tests / architecture: - Extract collabTokenNeedsRefresh (pure) and cover all four token states. - Extract shouldPropagateTitleChange and cover the collab-origin skip; add a TitleEditor render test for the static-h1 vs collaborative-editor switch. - Add a use-auth test asserting the sign-in cache purge runs before login. - Add an OFFLINE_PERSIST_ROOTS guard test asserting every persisted root maps to an exported query-key factory; route make-offline's currentUser warm through a new userKeys factory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
251 lines
8.3 KiB
TypeScript
251 lines
8.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { renderHook, act } from "@testing-library/react";
|
|
import type { ReactNode } from "react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { Provider, createStore } from "jotai";
|
|
import type { Editor } from "@tiptap/core";
|
|
import {
|
|
pageEditorAtom,
|
|
titleEditorAtom,
|
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
|
|
|
// --- Mocks for the hook's collaborators ---------------------------------------
|
|
|
|
const generatePageTitleMock = vi.fn();
|
|
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
|
generatePageTitle: (content: string) => generatePageTitleMock(content),
|
|
}));
|
|
|
|
const updateTitleMock = vi.fn();
|
|
const updatePageDataMock = vi.fn();
|
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
|
useUpdateTitlePageMutation: () => ({ mutateAsync: updateTitleMock }),
|
|
updatePageData: (page: unknown) => updatePageDataMock(page),
|
|
}));
|
|
|
|
const emitMock = vi.fn();
|
|
vi.mock("@/features/websocket/use-query-emit.ts", () => ({
|
|
useQueryEmit: () => emitMock,
|
|
}));
|
|
|
|
const localEmitMock = vi.fn();
|
|
vi.mock("@/lib/local-emitter.ts", () => ({
|
|
default: { emit: (...args: unknown[]) => localEmitMock(...args) },
|
|
}));
|
|
|
|
// htmlToMarkdown just echoes the editor HTML so each test controls the markdown
|
|
// purely via the fake page editor's getHTML().
|
|
vi.mock("@docmost/editor-ext", () => ({
|
|
htmlToMarkdown: (html: string) => html,
|
|
}));
|
|
|
|
const notificationsShowMock = vi.fn();
|
|
vi.mock("@mantine/notifications", () => ({
|
|
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
|
|
}));
|
|
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({ t: (key: string) => key }),
|
|
}));
|
|
|
|
// Import after mocks are registered.
|
|
import { useGeneratePageTitle } from "./use-generate-page-title.ts";
|
|
|
|
// --- Test helpers -------------------------------------------------------------
|
|
|
|
function makePageEditor(pageId: string, html = "<p>content</p>"): Editor {
|
|
return {
|
|
isDestroyed: false,
|
|
getHTML: () => html,
|
|
storage: { pageId },
|
|
} as unknown as Editor;
|
|
}
|
|
|
|
function makeTitleEditor(): Editor & {
|
|
commands: { setContent: ReturnType<typeof vi.fn> };
|
|
} {
|
|
return {
|
|
isDestroyed: false,
|
|
isFocused: false,
|
|
commands: { setContent: vi.fn() },
|
|
} as unknown as Editor & {
|
|
commands: { setContent: ReturnType<typeof vi.fn> };
|
|
};
|
|
}
|
|
|
|
function setup(pageId: string, store = createStore()) {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { mutations: { retry: false } },
|
|
});
|
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
<QueryClientProvider client={queryClient}>
|
|
<Provider store={store}>{children}</Provider>
|
|
</QueryClientProvider>
|
|
);
|
|
const { result } = renderHook(() => useGeneratePageTitle(pageId), {
|
|
wrapper,
|
|
});
|
|
return { result, store };
|
|
}
|
|
|
|
const PAGE_A = {
|
|
id: "pageA",
|
|
title: "Generated Title",
|
|
spaceId: "space1",
|
|
slugId: "slugA",
|
|
parentPageId: null,
|
|
icon: null,
|
|
} as any;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("useGeneratePageTitle", () => {
|
|
it("shows a notice and bails when the editor content is empty", async () => {
|
|
const store = createStore();
|
|
store.set(pageEditorAtom as never, makePageEditor("pageA", " "));
|
|
store.set(titleEditorAtom as never, makeTitleEditor());
|
|
const { result } = setup("pageA", store);
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(notificationsShowMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ message: "The note is empty", color: "yellow" }),
|
|
);
|
|
expect(generatePageTitleMock).not.toHaveBeenCalled();
|
|
expect(updateTitleMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("leaves the title untouched when the model returns nothing usable", async () => {
|
|
const store = createStore();
|
|
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
|
store.set(titleEditorAtom as never, makeTitleEditor());
|
|
generatePageTitleMock.mockResolvedValue(" ");
|
|
const { result } = setup("pageA", store);
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(updateTitleMock).not.toHaveBeenCalled();
|
|
expect(notificationsShowMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "Could not generate a title",
|
|
color: "yellow",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("happy path: applies the title, refreshes cache, broadcasts, and does NOT write the editor", async () => {
|
|
const store = createStore();
|
|
const titleEditor = makeTitleEditor();
|
|
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
|
store.set(titleEditorAtom as never, titleEditor);
|
|
generatePageTitleMock.mockResolvedValue("Generated Title");
|
|
updateTitleMock.mockResolvedValue(PAGE_A);
|
|
const { result } = setup("pageA", store);
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(updateTitleMock).toHaveBeenCalledWith({
|
|
pageId: "pageA",
|
|
title: "Generated Title",
|
|
});
|
|
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
|
// The title editor is bound to the Yjs `title` fragment; the server REST
|
|
// update reseeds that fragment and the reseed reaches the bound editor on
|
|
// its own. Writing here too would double/garble the title, so the hook must
|
|
// NOT touch the editor (regression guard for the Yjs duplication trap).
|
|
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
|
expect(localEmitMock).toHaveBeenCalled();
|
|
expect(emitMock).toHaveBeenCalled();
|
|
expect(notificationsShowMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ message: "Title generated" }),
|
|
);
|
|
});
|
|
|
|
it("keeps the DB write keyed by the captured pageId and still broadcasts after navigation", async () => {
|
|
const store = createStore();
|
|
const titleEditor = makeTitleEditor(); // persistent across navigation
|
|
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
|
store.set(titleEditorAtom as never, titleEditor);
|
|
|
|
// Control when generation resolves so we can navigate mid-flight.
|
|
let resolveTitle!: (t: string) => void;
|
|
generatePageTitleMock.mockReturnValue(
|
|
new Promise<string>((res) => {
|
|
resolveTitle = res;
|
|
}),
|
|
);
|
|
updateTitleMock.mockResolvedValue(PAGE_A);
|
|
const { result } = setup("pageA", store);
|
|
|
|
let pending!: Promise<void>;
|
|
act(() => {
|
|
pending = result.current.mutateAsync();
|
|
});
|
|
|
|
// User navigates to page B: the live page editor now belongs to pageB.
|
|
act(() => {
|
|
store.set(pageEditorAtom as never, makePageEditor("pageB"));
|
|
});
|
|
|
|
await act(async () => {
|
|
resolveTitle("Generated Title");
|
|
await pending;
|
|
});
|
|
|
|
// DB write is still correct (keyed by the captured pageId)...
|
|
expect(updateTitleMock).toHaveBeenCalledWith({
|
|
pageId: "pageA",
|
|
title: "Generated Title",
|
|
});
|
|
// ...the hook never writes the editor regardless of navigation...
|
|
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
|
// ...and the change is still broadcast to other clients.
|
|
expect(emitMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it("bails before calling the model when the page editor is destroyed", async () => {
|
|
const store = createStore();
|
|
const pageEditor = makePageEditor("pageA");
|
|
(pageEditor as { isDestroyed: boolean }).isDestroyed = true;
|
|
store.set(pageEditorAtom as never, pageEditor);
|
|
store.set(titleEditorAtom as never, makeTitleEditor());
|
|
const { result } = setup("pageA", store);
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(generatePageTitleMock).not.toHaveBeenCalled();
|
|
expect(updateTitleMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
[403, "AI title generation is disabled"],
|
|
[503, "AI is not configured"],
|
|
[429, "Too many requests, please try again later"],
|
|
[500, "Failed to generate title"],
|
|
])("maps HTTP %s onError to a friendly message", async (status, message) => {
|
|
const store = createStore();
|
|
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
|
store.set(titleEditorAtom as never, makeTitleEditor());
|
|
generatePageTitleMock.mockRejectedValue({ response: { status } });
|
|
const { result } = setup("pageA", store);
|
|
|
|
await act(async () => {
|
|
await expect(result.current.mutateAsync()).rejects.toBeTruthy();
|
|
});
|
|
|
|
expect(notificationsShowMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ message, color: "red" }),
|
|
);
|
|
});
|
|
});
|