Files
kunthawat 2d1be52177 Emdash source with visual editor image upload fix
Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
2026-05-03 10:44:54 +07:00

419 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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"
* <Link> 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 }) => <div data-testid="shell">{children}</div>,
}));
vi.mock("../src/components/AdminCommandPalette", () => ({
AdminCommandPalette: () => null,
}));
vi.mock("../src/components/ContentEditor", () => ({
ContentEditor: ({
item,
onSave,
onAutosave,
}: {
item?: { data?: { title?: string }; slug?: string | null };
onSave?: (payload: { data: Record<string, unknown> }) => void;
onAutosave?: (payload: { data: Record<string, unknown>; slug?: string }) => void;
}) => (
<div data-testid="content-editor">
<div data-testid="mock-title">{item?.data?.title ?? ""}</div>
<div data-testid="mock-slug">{item?.slug ?? ""}</div>
<form
onSubmit={(e) => {
e.preventDefault();
onSave?.({ data: { title: "Test Post" } });
}}
>
<button type="submit">Save</button>
</form>
<button
type="button"
onClick={() =>
onAutosave?.({
data: { title: "Autosaved Title" },
slug: "autosaved-title",
})
}
>
Trigger Draft Sync
</button>
</div>
),
}));
// ---------------------------------------------------------------------------
// 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 (
<I18nProvider i18n={i18n}>
<Toasty>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</Toasty>
</I18nProvider>
);
}
return { router, queryClient, TestApp };
}
// ---------------------------------------------------------------------------
// Tests: ContentListPage locale forwarded to "Add New" link
// ---------------------------------------------------------------------------
describe("ContentListPage locale forwarding to the new-content route", () => {
let mockFetch: ReturnType<typeof createMockFetch>;
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" <Link> 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(<TestApp />);
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" <Link> 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(<TestApp />);
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(<TestApp />);
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: ContentNewPage locale passed to createContent
// ---------------------------------------------------------------------------
describe("ContentNewPage locale passed to createContent", () => {
let mockFetch: ReturnType<typeof createMockFetch>;
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(<TestApp />);
// 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<typeof createMockFetch>;
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/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(<TestApp />);
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");
});
});
});