/**
* Tests for admin router page components.
*
* Bug: content created in the wrong locale when using the locale switcher.
*
* Root cause (two parts):
* 1. ContentListPage renders ContentList with `activeLocale` but the "Add New"
* in ContentList does NOT forward `search={{ locale: activeLocale }}` to
* the new-content route. The locale is silently dropped on navigation.
* 2. ContentNewPage (router.tsx:380) has no `validateSearch` and never reads the
* locale from URL search params, so `createContent` is always called without a
* locale, defaulting to English regardless of what is configured.
*
* Fix required in:
* packages/admin/src/components/ContentList.tsx – forward locale on Add-New links
* packages/admin/src/router.tsx (ContentNewPage) – read locale from search params
* and pass it to createContent
*/
import { Toasty } from "@cloudflare/kumo";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import * as React from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { AdminManifest } from "../src/lib/api";
import { createAdminRouter } from "../src/router";
import { render } from "./utils/render.tsx";
import { createTestQueryClient, createMockFetch, waitFor } from "./utils/test-helpers";
// ---------------------------------------------------------------------------
// Component mocks – keep layout plumbing out of these tests
// ---------------------------------------------------------------------------
vi.mock("../src/components/Shell", () => ({
Shell: ({ children }: { children: React.ReactNode }) =>
{children}
,
}));
vi.mock("../src/components/AdminCommandPalette", () => ({
AdminCommandPalette: () => null,
}));
vi.mock("../src/components/ContentEditor", () => ({
ContentEditor: ({
item,
onSave,
onAutosave,
onSeoChange,
isSaving,
isAutosaving,
isSaveFeedbackActive,
autosaveCompletionToken,
}: {
item?: { data?: { title?: string }; slug?: string | null };
onSave?: (payload: { data: Record }) => void;
onAutosave?: (payload: { data: Record; slug?: string }) => void;
onSeoChange?: (seo: { title: string }) => void;
isSaving?: boolean;
isAutosaving?: boolean;
isSaveFeedbackActive?: boolean;
autosaveCompletionToken?: number;
}) => (
{item?.data?.title ?? ""}
{item?.slug ?? ""}
{isSaveFeedbackActive ? "saving" : "idle"}
{isSaving ? "blocked" : "ready"}
{isSaving || isAutosaving ? "blocked" : "ready"}
{autosaveCompletionToken ?? 0}
onAutosave?.({
data: { title: "Autosaved Title" },
slug: "autosaved-title",
})
}
>
Trigger Draft Sync
onSeoChange?.({ title: "Search title" })}>
Trigger SEO Sync
),
}));
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const MANIFEST: AdminManifest = {
version: "1.0.0",
hash: "abc123",
authMode: "passkey",
collections: {
posts: {
label: "Posts",
labelSingular: "Post",
supports: ["drafts"],
hasSeo: false,
fields: {
title: { kind: "string", label: "Title" },
},
},
},
plugins: {},
taxonomies: [],
i18n: {
defaultLocale: "fr",
locales: ["fr", "en", "de"],
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildRouter() {
const queryClient = createTestQueryClient();
const router = createAdminRouter(queryClient);
if (!i18n.locale) {
i18n.loadAndActivate({ locale: "en", messages: {} });
}
// Toasty and I18nProvider are provided by App.tsx in production.
// Mirror that here so useLingui() and Toast.useToastManager() work inside page components.
function TestApp() {
return (
);
}
return { router, queryClient, TestApp };
}
// ---------------------------------------------------------------------------
// Tests: ContentListPage – locale forwarded to "Add New" link
// ---------------------------------------------------------------------------
describe("ContentListPage – locale forwarding to the new-content route", () => {
let mockFetch: ReturnType;
beforeEach(() => {
mockFetch = createMockFetch();
mockFetch
.on("GET", "/_emdash/api/manifest", { data: MANIFEST })
.on("GET", "/_emdash/api/auth/me", {
data: { id: "user_01", role: 60 },
})
.on("GET", "/_emdash/api/content/posts", {
data: { items: [], nextCursor: undefined },
})
.on("GET", "/_emdash/api/content/posts/trashed", {
data: { items: [] },
});
});
afterEach(() => {
mockFetch.restore();
});
it("Add New link includes the active locale when a non-default locale (de) is selected", async () => {
// Navigate to the content list with locale=de selected in the switcher.
// The default locale is fr, so de is a non-default locale.
// The "Add New" must carry ?locale=de so that ContentNewPage
// receives it and creates content in German, not the default French.
const { router, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection",
params: { collection: "posts" },
search: { locale: "de" },
});
const screen = await render( );
const addNewLink = screen.getByRole("link", { name: /add new/i });
await expect.element(addNewLink).toBeInTheDocument();
const href = addNewLink.element().getAttribute("href") ?? "";
expect(href).toContain("locale=de");
});
it("Add New link uses the default locale (fr) when no locale is set in the URL", async () => {
// Navigate to the content list without an explicit locale param.
// activeLocale falls back to the configured defaultLocale ("fr").
// The "Add New" must carry ?locale=fr so that ContentNewPage
// creates content in the correct default language.
const { router, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection",
params: { collection: "posts" },
});
const screen = await render( );
const addNewLink = screen.getByRole("link", { name: /add new/i });
await expect.element(addNewLink).toBeInTheDocument();
const href = addNewLink.element().getAttribute("href") ?? "";
expect(href).toContain("locale=fr");
});
it("Add New link does not include a locale param when i18n is not configured", async () => {
const manifestWithoutI18n: AdminManifest = { ...MANIFEST, i18n: undefined };
mockFetch.on("GET", "/_emdash/api/manifest", { data: manifestWithoutI18n });
const { router, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection",
params: { collection: "posts" },
});
const screen = await render( );
const addNewLink = screen.getByRole("link", { name: /add new/i });
await expect.element(addNewLink).toBeInTheDocument();
const href = addNewLink.element().getAttribute("href") ?? "";
expect(href).not.toContain("locale=");
});
});
// ---------------------------------------------------------------------------
// Tests: ContentListPage – hook order stays stable across an erroring refetch
// (regression for #1415: inline useCallback for onLoadMore sat below the early
// returns, so a render that took the `error` guard ran one fewer hook → React
// #300 "Rendered fewer hooks than expected").
// ---------------------------------------------------------------------------
describe("ContentListPage – hook order is stable when a refetch errors (#1415)", () => {
let mockFetch: ReturnType;
beforeEach(() => {
mockFetch = createMockFetch();
mockFetch
.on("GET", "/_emdash/api/manifest", { data: MANIFEST })
.on("GET", "/_emdash/api/auth/me", {
data: { id: "user_01", role: 60 },
})
.on("GET", "/_emdash/api/content/posts", {
data: { items: [], nextCursor: undefined },
})
.on("GET", "/_emdash/api/content/posts/trashed", {
data: { items: [] },
});
});
afterEach(() => {
mockFetch.restore();
});
it("renders the ErrorScreen instead of crashing when a content refetch fails", async () => {
const { router, queryClient, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection",
params: { collection: "posts" },
});
const screen = await render( );
// First render succeeds and reaches the inline onLoadMore useCallback.
const addNewLink = screen.getByRole("link", { name: /add new/i });
await expect.element(addNewLink).toBeInTheDocument();
// Make the next content fetch fail, then force a refetch. The component
// re-renders with `error` truthy and takes the ErrorScreen early return,
// which sits ABOVE the onLoadMore useCallback. If that callback hook is
// below the guards, this render runs one fewer hook and React throws #300
// — so the ErrorScreen (and its Retry button) never appears.
mockFetch.on("GET", "/_emdash/api/content/posts", { error: { message: "Boom" } }, 500);
await queryClient.refetchQueries({ queryKey: ["content", "posts"] });
const retryButton = screen.getByRole("button", { name: /retry/i });
await expect.element(retryButton).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Tests: ContentNewPage – locale passed to createContent
// ---------------------------------------------------------------------------
describe("ContentNewPage – locale passed to createContent", () => {
let mockFetch: ReturnType;
beforeEach(() => {
mockFetch = createMockFetch();
mockFetch
.on("GET", "/_emdash/api/manifest", { data: MANIFEST })
.on("GET", "/_emdash/api/auth/me", {
data: { id: "user_01", role: 60 },
})
.on("GET", "/_emdash/api/bylines", { data: { items: [] } })
.on("POST", "/_emdash/api/content/posts", {
data: {
item: {
id: "new_01",
type: "posts",
slug: null,
status: "draft",
locale: "de",
translationGroup: null,
data: { title: "Test Post" },
authorId: null,
primaryBylineId: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
publishedAt: null,
scheduledAt: null,
liveRevisionId: null,
draftRevisionId: null,
},
},
});
});
afterEach(() => {
mockFetch.restore();
});
it("passes locale=de to the API when ?locale=de is in the URL", async () => {
// The default locale is fr; navigating with ?locale=de tests that the
// non-default locale is read from search params and forwarded to createContent.
const { router, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection/new",
params: { collection: "posts" },
search: { locale: "de" },
});
const screen = await render( );
// Wait for the editor to appear (manifest must have loaded)
await expect
.element(screen.getByRole("button", { name: "Save", exact: true }))
.toBeInTheDocument();
// Capture outgoing requests
const requests: { url: string; body: unknown }[] = [];
const origFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/content/posts") && init?.method === "POST") {
const body = init.body ? JSON.parse(init.body as string) : null;
requests.push({ url, body });
}
return origFetch(input, init);
};
await screen.getByRole("button", { name: "Save", exact: true }).click();
globalThis.fetch = origFetch;
// After the fix: the POST body must include locale: "de"
expect(requests).toHaveLength(1);
expect(requests[0]!.body).toMatchObject({ locale: "de" });
});
});
// ---------------------------------------------------------------------------
// Tests: ContentEditPage – autosave cache stays in sync
// ---------------------------------------------------------------------------
describe("ContentEditPage – autosave cache patching", () => {
let mockFetch: ReturnType;
beforeEach(() => {
mockFetch = createMockFetch();
const manifestWithRevisions: AdminManifest = {
...MANIFEST,
i18n: undefined,
collections: {
posts: {
...MANIFEST.collections.posts,
supports: ["drafts", "revisions"],
},
},
};
mockFetch
.on("GET", "/_emdash/api/manifest", { data: manifestWithRevisions })
.on("GET", "/_emdash/api/auth/me", {
data: { id: "user_01", role: 30 },
})
.on("GET", "/_emdash/api/bylines", { data: { items: [] } })
.on("GET", "/_emdash/api/content/posts/post_1", {
data: {
item: {
id: "post_1",
type: "posts",
slug: "published-slug",
status: "draft",
locale: "en",
translationGroup: null,
data: { title: "Published Title" },
authorId: null,
primaryBylineId: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
publishedAt: "2025-01-01T00:00:00Z",
scheduledAt: null,
liveRevisionId: "rev_live",
draftRevisionId: "rev_draft",
},
},
})
.on("GET", "/_emdash/api/content/posts/post_2", {
data: {
item: {
id: "post_2",
type: "posts",
slug: "second-post",
status: "draft",
locale: "en",
translationGroup: null,
data: { title: "Second Post" },
authorId: null,
primaryBylineId: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
publishedAt: null,
scheduledAt: null,
liveRevisionId: null,
draftRevisionId: null,
},
},
})
.on("GET", "/_emdash/api/revisions/rev_draft", {
data: {
item: {
id: "rev_draft",
collection: "posts",
entryId: "post_1",
data: { title: "Draft Title", _slug: "draft-slug" },
authorId: null,
createdAt: "2025-01-01T00:00:00Z",
},
},
})
.on("PUT", "/_emdash/api/content/posts/post_1", {
data: {
item: {
id: "post_1",
type: "posts",
slug: "published-slug",
status: "draft",
locale: "en",
translationGroup: null,
data: { title: "Published Title" },
authorId: null,
primaryBylineId: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
publishedAt: "2025-01-01T00:00:00Z",
scheduledAt: null,
liveRevisionId: "rev_live",
draftRevisionId: "rev_draft",
},
},
});
});
afterEach(() => {
mockFetch.restore();
});
it("keeps the edited draft title and slug after autosave completes", async () => {
const { router, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection/$id",
params: { collection: "posts", id: "post_1" },
});
const screen = await render( );
await waitFor(() => {
expect(screen.getByTestId("mock-title").element().textContent).toBe("Draft Title");
expect(screen.getByTestId("mock-slug").element().textContent).toBe("draft-slug");
});
await screen.getByRole("button", { name: "Trigger Draft Sync" }).click();
await waitFor(() => {
expect(screen.getByTestId("mock-title").element().textContent).toBe("Autosaved Title");
expect(screen.getByTestId("mock-slug").element().textContent).toBe("autosaved-title");
});
});
it("does not report auxiliary writes as saving; editor saves still do", async () => {
const { router, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection/$id",
params: { collection: "posts", id: "post_1" },
});
const screen = await render( );
await waitFor(() => {
expect(screen.getByTestId("mock-title").element().textContent).toBe("Draft Title");
});
// Hold every PUT open so the mutation's pending window is observable.
const fetchWithMocks = globalThis.fetch;
let resolvePut: (() => void) | undefined;
globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (init?.method === "PUT" && url.includes("/content/posts/post_1")) {
return new Promise((resolve) => {
resolvePut = () =>
resolve(
new Response(
JSON.stringify({
data: {
item: {
id: "post_1",
type: "posts",
slug: "published-slug",
status: "draft",
locale: "en",
data: { title: "Published Title" },
updatedAt: "2025-01-02T00:00:00Z",
draftRevisionId: "rev_draft",
},
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
});
}
return fetchWithMocks(input, init);
}) as typeof fetch;
try {
// Auxiliary write (SEO): the Save control must stay idle while it flies.
await screen.getByRole("button", { name: "Trigger SEO Sync" }).click();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(screen.getByTestId("is-saving").element().textContent).toBe("idle");
expect(screen.getByTestId("manual-save-blocked").element().textContent).toBe("blocked");
await expect
.element(screen.getByRole("button", { name: "Save", exact: true }))
.toBeDisabled();
resolvePut?.();
resolvePut = undefined;
await waitFor(() => {
expect(screen.getByTestId("manual-save-blocked").element().textContent).toBe("ready");
});
// Editor save: the same mutation with source "editor" must report saving.
await screen.getByRole("button", { name: "Save", exact: true }).click();
await waitFor(() => {
expect(screen.getByTestId("is-saving").element().textContent).toBe("saving");
expect(screen.getByTestId("manual-save-blocked").element().textContent).toBe("blocked");
});
resolvePut?.();
await waitFor(() => {
expect(screen.getByTestId("is-saving").element().textContent).toBe("idle");
});
} finally {
globalThis.fetch = fetchWithMocks;
}
});
it("keeps editor save feedback visual without strengthening main's operation gating", async () => {
const { router, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection/$id",
params: { collection: "posts", id: "post_1" },
});
const screen = await render( );
await waitFor(() => {
expect(screen.getByTestId("mock-title").element().textContent).toBe("Draft Title");
});
const fetchWithMocks = globalThis.fetch;
const resolvers: (() => void)[] = [];
globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (init?.method === "PUT" && url.includes("/content/posts/post_1")) {
return new Promise((resolve) => {
resolvers.push(() =>
resolve(
new Response(
JSON.stringify({
data: {
item: {
id: "post_1",
type: "posts",
slug: "published-slug",
status: "draft",
locale: "en",
data: { title: "Published Title" },
updatedAt: "2025-01-02T00:00:00Z",
draftRevisionId: "rev_draft",
},
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
),
);
});
}
return fetchWithMocks(input, init);
}) as typeof fetch;
try {
await screen.getByRole("button", { name: "Save", exact: true }).click();
await waitFor(() => {
expect(screen.getByTestId("is-saving").element().textContent).toBe("saving");
});
// Auxiliary write lands while the editor save is still in flight.
await screen.getByRole("button", { name: "Trigger SEO Sync" }).click();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(screen.getByTestId("is-saving").element().textContent).toBe("saving");
// Main's shared mutation observer follows the latest auxiliary write.
// When it settles, operation gating becomes idle even though the older
// editor request is still running; feedback remains visual-only.
expect(resolvers).toHaveLength(2);
resolvers[1]?.();
await waitFor(() => {
expect(screen.getByTestId("manual-save-blocked").element().textContent).toBe("ready");
});
expect(screen.getByTestId("is-saving").element().textContent).toBe("saving");
await expect.element(screen.getByRole("button", { name: "Save", exact: true })).toBeEnabled();
resolvers[0]?.();
await waitFor(() => {
expect(screen.getByTestId("is-saving").element().textContent).toBe("idle");
});
} finally {
globalThis.fetch = fetchWithMocks;
}
});
it("does not deliver an old entry's autosave completion to the current entry", async () => {
const { router, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection/$id",
params: { collection: "posts", id: "post_1" },
});
const screen = await render( );
await waitFor(() => {
expect(screen.getByTestId("mock-title").element().textContent).toBe("Draft Title");
});
const fetchWithMocks = globalThis.fetch;
let resolveFirstAutosave: (() => void) | undefined;
globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (init?.method === "PUT" && url.includes("/content/posts/post_1")) {
return new Promise((resolve) => {
resolveFirstAutosave = () =>
resolve(
new Response(
JSON.stringify({
data: {
item: {
id: "post_1",
type: "posts",
slug: "autosaved-title",
status: "draft",
locale: "en",
data: { title: "Autosaved Title" },
updatedAt: "2025-01-02T00:00:00Z",
draftRevisionId: "rev_draft",
},
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
});
}
return fetchWithMocks(input, init);
}) as typeof fetch;
try {
await screen.getByRole("button", { name: "Trigger Draft Sync" }).click();
await router.navigate({
to: "/content/$collection/$id",
params: { collection: "posts", id: "post_2" },
});
await waitFor(() => {
expect(screen.getByTestId("mock-title").element().textContent).toBe("Second Post");
});
expect(screen.getByTestId("manual-save-blocked").element().textContent).toBe("ready");
expect(screen.getByTestId("autosave-blocked").element().textContent).toBe("blocked");
await expect.element(screen.getByRole("button", { name: "Save", exact: true })).toBeEnabled();
resolveFirstAutosave?.();
await waitFor(() => {
expect(screen.getByTestId("autosave-blocked").element().textContent).toBe("ready");
});
expect(screen.getByTestId("autosave-completion-token").element().textContent).toBe("0");
} finally {
globalThis.fetch = fetchWithMocks;
}
});
});