diff --git a/.changeset/fix-admin-locale-edit.md b/.changeset/fix-admin-locale-edit.md new file mode 100644 index 000000000..a901331b7 --- /dev/null +++ b/.changeset/fix-admin-locale-edit.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Forward `locale` query param through admin content edit route and API client to resolve correct i18n variant for slug-based lookups (#1242) diff --git a/.changeset/fix-core-locale-write.md b/.changeset/fix-core-locale-write.md new file mode 100644 index 000000000..ea65edb1c --- /dev/null +++ b/.changeset/fix-core-locale-write.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Read `?locale=` in content write routes (DELETE, publish, unpublish, discard-draft, schedule, unschedule) and forward it to `handleContentGet` for locale-aware slug resolution (#1242) diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 3f4834b2a..d4aadea19 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -1024,6 +1024,7 @@ export function ContentEditor({ navigate({ to: "/content/$collection/$id", params: { collection, id: tr.id }, + search: { locale: tr.locale }, }) } onCreate={onTranslate} diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index cd43ea62c..6122fb0b9 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -621,6 +621,7 @@ function ContentListItem({ { - const response = await apiFetch(`${API_BASE}/content/${collection}/${id}`); +export async function fetchContent( + collection: string, + id: string, + options?: { locale?: string }, +): Promise { + const params = new URLSearchParams(); + if (options?.locale) params.set("locale", options.locale); + const query = params.toString() ? `?${params}` : ""; + const response = await apiFetch(`${API_BASE}/content/${collection}/${id}${query}`); const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to fetch content"); return data.item; } @@ -200,8 +207,12 @@ export async function updateContent( collection: string, id: string, input: UpdateContentInput, + options?: { locale?: string }, ): Promise { - const response = await apiFetch(`${API_BASE}/content/${collection}/${id}`, { + const params = new URLSearchParams(); + if (options?.locale) params.set("locale", options.locale); + const query = params.toString() ? `?${params}` : ""; + const response = await apiFetch(`${API_BASE}/content/${collection}/${id}${query}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), @@ -213,8 +224,15 @@ export async function updateContent( /** * Delete content (moves to trash) */ -export async function deleteContent(collection: string, id: string): Promise { - const response = await apiFetch(`${API_BASE}/content/${collection}/${id}`, { +export async function deleteContent( + collection: string, + id: string, + options?: { locale?: string }, +): Promise { + const params = new URLSearchParams(); + if (options?.locale) params.set("locale", options.locale); + const query = params.toString() ? `?${params}` : ""; + const response = await apiFetch(`${API_BASE}/content/${collection}/${id}${query}`, { method: "DELETE", }); if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to delete content`)); @@ -284,8 +302,12 @@ export async function scheduleContent( collection: string, id: string, scheduledAt: string, + options?: { locale?: string }, ): Promise { - const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/schedule`, { + const params = new URLSearchParams(); + if (options?.locale) params.set("locale", options.locale); + const query = params.toString() ? `?${params}` : ""; + const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/schedule${query}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scheduledAt }), @@ -300,8 +322,15 @@ export async function scheduleContent( /** * Unschedule content (revert to draft) */ -export async function unscheduleContent(collection: string, id: string): Promise { - const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/schedule`, { +export async function unscheduleContent( + collection: string, + id: string, + options?: { locale?: string }, +): Promise { + const params = new URLSearchParams(); + if (options?.locale) params.set("locale", options.locale); + const query = params.toString() ? `?${params}` : ""; + const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/schedule${query}`, { method: "DELETE", }); const data = await parseApiResponse<{ item: ContentItem }>( @@ -366,8 +395,15 @@ export async function getPreviewUrl( /** * Publish content - promotes current draft to live */ -export async function publishContent(collection: string, id: string): Promise { - const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/publish`, { +export async function publishContent( + collection: string, + id: string, + options?: { locale?: string }, +): Promise { + const params = new URLSearchParams(); + if (options?.locale) params.set("locale", options.locale); + const query = params.toString() ? `?${params}` : ""; + const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/publish${query}`, { method: "POST", }); const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to publish content"); @@ -377,8 +413,15 @@ export async function publishContent(collection: string, id: string): Promise { - const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/unpublish`, { +export async function unpublishContent( + collection: string, + id: string, + options?: { locale?: string }, +): Promise { + const params = new URLSearchParams(); + if (options?.locale) params.set("locale", options.locale); + const query = params.toString() ? `?${params}` : ""; + const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/unpublish${query}`, { method: "POST", }); const data = await parseApiResponse<{ item: ContentItem }>( @@ -391,8 +434,15 @@ export async function unpublishContent(collection: string, id: string): Promise< /** * Discard draft changes - reverts to live version */ -export async function discardDraft(collection: string, id: string): Promise { - const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/discard-draft`, { +export async function discardDraft( + collection: string, + id: string, + options?: { locale?: string }, +): Promise { + const params = new URLSearchParams(); + if (options?.locale) params.set("locale", options.locale); + const query = params.toString() ? `?${params}` : ""; + const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/discard-draft${query}`, { method: "POST", }); const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to discard draft"); diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 9bc8f2aa1..28379e37d 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -135,9 +135,10 @@ function patchAutosaveQueries( data?: Record; slug?: string; }; + locale?: string; }, ) { - const { collection, id, savedItem, payload } = params; + const { collection, id, savedItem, payload, locale } = params; const draftRevisionId = savedItem.draftRevisionId; if (draftRevisionId) { @@ -162,7 +163,10 @@ function patchAutosaveQueries( }); } - queryClient.setQueryData(["content", collection, id], savedItem); + queryClient.setQueryData( + locale ? ["content", collection, id, { locale }] : ["content", collection, id], + savedItem, + ); } // Create a base root route without Shell for setup @@ -493,6 +497,7 @@ function ContentNewPage() { void navigate({ to: "/content/$collection/$id", params: { collection, id: result.id }, + search: { locale: result.locale }, }); }, }); @@ -582,6 +587,7 @@ const contentEditRoute = createRoute({ component: ContentEditPage, validateSearch: (search) => ({ ...(typeof search.field === "string" && { field: search.field }), + ...(typeof search.locale === "string" && { locale: search.locale }), }), }); @@ -606,10 +612,12 @@ function ContentEditPage() { }); const i18n = manifest?.i18n; + const activeLocale = i18n ? (searchParams.locale ?? i18n.defaultLocale) : undefined; const { data: rawItem, isLoading } = useQuery({ - queryKey: ["content", collection, id], - queryFn: () => fetchContent(collection, id), + queryKey: ["content", collection, id, { locale: activeLocale }], + queryFn: () => fetchContent(collection, id, { locale: activeLocale }), + enabled: !i18n || !!activeLocale, }); React.useEffect(() => { @@ -725,9 +733,11 @@ function ContentEditPage() { bylines?: BylineCreditInput[]; skipRevision?: boolean; seo?: ContentSeoInput; - }) => updateContent(collection, id, data), + }) => updateContent(collection, id, data, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); + void queryClient.invalidateQueries({ + queryKey: ["content", collection, id, { locale: rawItem?.locale ?? activeLocale }], + }); // Also invalidate revisions since a new one was created void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] }); // Invalidate the cached draft revision so stale data doesn't overwrite the form @@ -753,7 +763,13 @@ function ContentEditPage() { data?: Record; slug?: string; bylines?: BylineCreditInput[]; - }) => updateContent(collection, id, { ...data, skipRevision: true }), + }) => + updateContent( + collection, + id, + { ...data, skipRevision: true }, + { locale: rawItem?.locale ?? activeLocale }, + ), onSuccess: (savedItem, variables) => { patchAutosaveQueries(queryClient, { collection, @@ -763,6 +779,7 @@ function ContentEditPage() { data: variables.data, slug: variables.slug, }, + locale: rawItem?.locale ?? activeLocale, }); setLastAutosaveAt(new Date()); // Keep the cache fresh without refetching older server state back into the form @@ -778,9 +795,11 @@ function ContentEditPage() { }); const publishMutation = useMutation({ - mutationFn: () => publishContent(collection, id), + mutationFn: () => publishContent(collection, id, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); + void queryClient.invalidateQueries({ + queryKey: ["content", collection, id, { locale: rawItem?.locale ?? activeLocale }], + }); void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] }); toastManager.add({ title: t`Published`, description: t`Content is now live` }); }, @@ -794,9 +813,11 @@ function ContentEditPage() { }); const unpublishMutation = useMutation({ - mutationFn: () => unpublishContent(collection, id), + mutationFn: () => unpublishContent(collection, id, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); + void queryClient.invalidateQueries({ + queryKey: ["content", collection, id, { locale: rawItem?.locale ?? activeLocale }], + }); void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] }); toastManager.add({ title: t`Unpublished`, description: t`Content removed from public view` }); }, @@ -810,9 +831,11 @@ function ContentEditPage() { }); const discardDraftMutation = useMutation({ - mutationFn: () => discardDraft(collection, id), + mutationFn: () => discardDraft(collection, id, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); + void queryClient.invalidateQueries({ + queryKey: ["content", collection, id, { locale: rawItem?.locale ?? activeLocale }], + }); void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] }); toastManager.add({ title: t`Changes discarded`, @@ -829,9 +852,12 @@ function ContentEditPage() { }); const scheduleMutation = useMutation({ - mutationFn: (scheduledAt: string) => scheduleContent(collection, id, scheduledAt), + mutationFn: (scheduledAt: string) => + scheduleContent(collection, id, scheduledAt, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); + void queryClient.invalidateQueries({ + queryKey: ["content", collection, id, { locale: rawItem?.locale ?? activeLocale }], + }); toastManager.add({ title: t`Scheduled`, description: t`Content has been scheduled for publishing`, @@ -847,9 +873,12 @@ function ContentEditPage() { }); const unscheduleMutation = useMutation({ - mutationFn: () => unscheduleContent(collection, id), + mutationFn: () => + unscheduleContent(collection, id, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); + void queryClient.invalidateQueries({ + queryKey: ["content", collection, id, { locale: rawItem?.locale ?? activeLocale }], + }); toastManager.add({ title: t`Unscheduled`, description: t`Content reverted to draft`, @@ -879,6 +908,7 @@ function ContentEditPage() { void navigate({ to: "/content/$collection/$id", params: { collection, id: result.id }, + search: { locale: result.locale }, }); toastManager.add({ title: t`Translation created`, @@ -895,14 +925,14 @@ function ContentEditPage() { }); const deleteMutation = useMutation({ - mutationFn: () => deleteContent(collection, id), + mutationFn: () => deleteContent(collection, id, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["content", collection] }); void queryClient.invalidateQueries({ queryKey: ["content", collection, "trash"] }); void navigate({ to: "/content/$collection", params: { collection }, - search: { locale: undefined }, + search: { locale: activeLocale }, }); }, onError: (error) => { diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id].ts b/packages/core/src/astro/routes/api/content/[collection]/[id].ts index e17b16bce..16374bd63 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id].ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id].ts @@ -132,7 +132,7 @@ export const PUT: APIRoute = async ({ params, request, locals, cache }) => { return unwrapResult(result); }; -export const DELETE: APIRoute = async ({ params, locals, cache }) => { +export const DELETE: APIRoute = async ({ params, locals, url, cache }) => { const { emdash, user } = locals; const collection = params.collection!; const id = params.id!; @@ -141,8 +141,10 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } + const locale = url.searchParams.get("locale") || undefined; + // Fetch item to check ownership - const existing = await emdash.handleContentGet(collection, id); + const existing = await emdash.handleContentGet(collection, id, locale); if (!existing.success) { return apiError( existing.error?.code ?? "UNKNOWN_ERROR", diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts b/packages/core/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts index 321d7d21e..8433a60ae 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts @@ -11,7 +11,7 @@ import { apiError, mapErrorStatus, unwrapResult } from "#api/error.js"; export const prerender = false; -export const POST: APIRoute = async ({ params, locals, cache }) => { +export const POST: APIRoute = async ({ params, locals, url, cache }) => { const { emdash, user } = locals; const collection = params.collection!; const id = params.id!; @@ -20,8 +20,10 @@ export const POST: APIRoute = async ({ params, locals, cache }) => { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } + const locale = url.searchParams.get("locale") || undefined; + // Fetch item to check ownership - const existing = await emdash.handleContentGet(collection, id); + const existing = await emdash.handleContentGet(collection, id, locale); if (!existing.success) { return apiError( existing.error?.code ?? "UNKNOWN_ERROR", diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id]/publish.ts b/packages/core/src/astro/routes/api/content/[collection]/[id]/publish.ts index 8a80e6c6e..636e5f64a 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id]/publish.ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id]/publish.ts @@ -20,7 +20,7 @@ import { contentPublishBody } from "#api/schemas.js"; export const prerender = false; -export const POST: APIRoute = async ({ params, request, locals, cache }) => { +export const POST: APIRoute = async ({ params, request, locals, url, cache }) => { const { emdash, user } = locals; const collection = params.collection!; const id = params.id!; @@ -34,8 +34,10 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => { const body = await parseOptionalBody(request, contentPublishBody, {}); if (isParseError(body)) return body; + const locale = url.searchParams.get("locale") || undefined; + // Fetch item to check ownership - const existing = await emdash.handleContentGet(collection, id); + const existing = await emdash.handleContentGet(collection, id, locale); if (!existing.success) { return apiError( existing.error?.code ?? "UNKNOWN_ERROR", diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id]/schedule.ts b/packages/core/src/astro/routes/api/content/[collection]/[id]/schedule.ts index 74a0dd5aa..e833a826b 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id]/schedule.ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id]/schedule.ts @@ -34,7 +34,7 @@ function extractOwnership(data: unknown): { authorId: string; resolvedId: string }; } -export const POST: APIRoute = async ({ params, request, locals, cache }) => { +export const POST: APIRoute = async ({ params, request, locals, url, cache }) => { const { emdash, user } = locals; const collection = params.collection!; const id = params.id!; @@ -45,8 +45,10 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } + const locale = url.searchParams.get("locale") || undefined; + // Fetch item to check ownership - const existing = await emdash.handleContentGet(collection, id); + const existing = await emdash.handleContentGet(collection, id, locale); if (!existing.success) { return apiError( existing.error?.code ?? "UNKNOWN_ERROR", @@ -68,7 +70,7 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => { return unwrapResult(result); }; -export const DELETE: APIRoute = async ({ params, locals, cache }) => { +export const DELETE: APIRoute = async ({ params, locals, url, cache }) => { const { emdash, user } = locals; const collection = params.collection!; const id = params.id!; @@ -77,8 +79,10 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } + const locale = url.searchParams.get("locale") || undefined; + // Fetch item to check ownership - const existing = await emdash.handleContentGet(collection, id); + const existing = await emdash.handleContentGet(collection, id, locale); if (!existing.success) { return apiError( existing.error?.code ?? "UNKNOWN_ERROR", diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id]/unpublish.ts b/packages/core/src/astro/routes/api/content/[collection]/[id]/unpublish.ts index 1092612e2..5f62e0652 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id]/unpublish.ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id]/unpublish.ts @@ -11,7 +11,7 @@ import { apiError, mapErrorStatus, unwrapResult } from "#api/error.js"; export const prerender = false; -export const POST: APIRoute = async ({ params, locals, cache }) => { +export const POST: APIRoute = async ({ params, locals, url, cache }) => { const { emdash, user } = locals; const collection = params.collection!; const id = params.id!; @@ -20,8 +20,10 @@ export const POST: APIRoute = async ({ params, locals, cache }) => { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } + const locale = url.searchParams.get("locale") || undefined; + // Fetch item to check ownership - const existing = await emdash.handleContentGet(collection, id); + const existing = await emdash.handleContentGet(collection, id, locale); if (!existing.success) { return apiError( existing.error?.code ?? "UNKNOWN_ERROR", diff --git a/packages/core/tests/unit/api/content-route-permissions.test.ts b/packages/core/tests/unit/api/content-route-permissions.test.ts index 05d524952..be21f3d64 100644 --- a/packages/core/tests/unit/api/content-route-permissions.test.ts +++ b/packages/core/tests/unit/api/content-route-permissions.test.ts @@ -180,6 +180,7 @@ describe("content route — publishedAt / createdAt permission gate", () => { const response = await updateContent({ params: { collection: "post", id: "c1" }, request, + url: new URL(request.url), locals: { emdash: { handleContentUpdate, handleContentGet }, user: makeUser(Role.AUTHOR), @@ -204,6 +205,7 @@ describe("content route — publishedAt / createdAt permission gate", () => { const response = await updateContent({ params: { collection: "post", id: "c1" }, request, + url: new URL(request.url), locals: { emdash: { handleContentUpdate, handleContentGet }, user: makeUser(Role.AUTHOR), @@ -234,6 +236,7 @@ describe("content route — publishedAt / createdAt permission gate", () => { const response = await updateContent({ params: { collection: "post", id: "c1" }, request, + url: new URL(request.url), locals: { emdash: { handleContentUpdate, handleContentGet }, user: makeUser(Role.EDITOR), @@ -265,6 +268,7 @@ describe("content route — publishedAt / createdAt permission gate", () => { const response = await updateContent({ params: { collection: "post", id: "c1" }, request, + url: new URL(request.url), locals: { emdash: { handleContentUpdate, handleContentGet }, user: makeUser(Role.AUTHOR), diff --git a/packages/core/tests/unit/astro/content-route-locale.test.ts b/packages/core/tests/unit/astro/content-route-locale.test.ts new file mode 100644 index 000000000..17e33abc1 --- /dev/null +++ b/packages/core/tests/unit/astro/content-route-locale.test.ts @@ -0,0 +1,238 @@ +/** + * Content route locale forwarding. + * + * When a ?locale query param is present, single-item write routes must + * forward it to handleContentGet so slug-based lookups resolve to the + * correct i18n variant instead of returning the first matching row. + */ + +import { Role } from "@emdash-cms/auth"; +import type { APIContext } from "astro"; +import { describe, it, expect, vi } from "vitest"; + +import { + PUT as putItem, + DELETE as deleteItem, +} from "../../../src/astro/routes/api/content/[collection]/[id].js"; +import { POST as postDiscard } from "../../../src/astro/routes/api/content/[collection]/[id]/discard-draft.js"; +import { POST as postPublish } from "../../../src/astro/routes/api/content/[collection]/[id]/publish.js"; +import { + POST as postSchedule, + DELETE as deleteSchedule, +} from "../../../src/astro/routes/api/content/[collection]/[id]/schedule.js"; +import { POST as postUnpublish } from "../../../src/astro/routes/api/content/[collection]/[id]/unpublish.js"; + +const editor = { id: "u-edit", role: Role.EDITOR }; + +function buildEmdash() { + const handleContentGet = vi.fn(async (_collection: string, _id: string, _locale?: string) => ({ + success: true as const, + data: { + item: { + id: "resolved-id", + type: "post", + slug: "hello", + status: "published", + data: {}, + authorId: "u-edit", + primaryBylineId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + publishedAt: "2026-01-01T00:00:00Z", + scheduledAt: null, + liveRevisionId: null, + draftRevisionId: null, + version: 1, + locale: "en", + translationGroup: "tg-1", + }, + _rev: "rev1", + }, + })); + + const handleContentUpdate = vi.fn(async () => ({ + success: true as const, + data: { item: { id: "resolved-id" } }, + })); + + const handleContentDelete = vi.fn(async () => ({ + success: true as const, + data: { deleted: true }, + })); + + const handleContentPublish = vi.fn(async () => ({ + success: true as const, + data: { item: { id: "resolved-id" } }, + })); + + const handleContentUnpublish = vi.fn(async () => ({ + success: true as const, + data: { item: { id: "resolved-id" } }, + })); + + const handleContentDiscardDraft = vi.fn(async () => ({ + success: true as const, + data: { item: { id: "resolved-id" } }, + })); + + const handleContentSchedule = vi.fn(async () => ({ + success: true as const, + data: { item: { id: "resolved-id" } }, + })); + + const handleContentUnschedule = vi.fn(async () => ({ + success: true as const, + data: { item: { id: "resolved-id" } }, + })); + + return { + handleContentGet, + handleContentUpdate, + handleContentDelete, + handleContentPublish, + handleContentUnpublish, + handleContentDiscardDraft, + handleContentSchedule, + handleContentUnschedule, + }; +} + +function ctx(opts: { + user: typeof editor; + emdash: ReturnType; + method?: string; + body?: unknown; + url?: string; +}): APIContext { + const url = new URL(opts.url ?? "http://localhost/_emdash/api/content/post/hello"); + const request = new Request(url, { + method: opts.method ?? "GET", + headers: { "content-type": "application/json" }, + body: opts.body ? JSON.stringify(opts.body) : undefined, + }); + return { + params: { collection: "post", id: "hello" }, + url, + request, + locals: { + user: opts.user, + emdash: opts.emdash, + }, + cache: { enabled: false, invalidate: vi.fn() }, + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- minimal stub for tests + } as unknown as APIContext; +} + +describe("PUT /content/:collection/:id forwards locale to handleContentGet", () => { + it("passes locale=en when query param is present", async () => { + const emdash = buildEmdash(); + const res = await putItem( + ctx({ + user: editor, + emdash, + method: "PUT", + body: { data: { title: "Updated" } }, + url: "http://localhost/_emdash/api/content/post/hello?locale=en", + }), + ); + expect(res.status).toBe(200); + expect(emdash.handleContentGet).toHaveBeenCalledWith("post", "hello", "en"); + }); +}); + +describe("DELETE /content/:collection/:id forwards locale to handleContentGet", () => { + it("passes locale=en when query param is present", async () => { + const emdash = buildEmdash(); + const res = await deleteItem( + ctx({ + user: editor, + emdash, + method: "DELETE", + url: "http://localhost/_emdash/api/content/post/hello?locale=en", + }), + ); + expect(res.status).toBe(200); + expect(emdash.handleContentGet).toHaveBeenCalledWith("post", "hello", "en"); + }); +}); + +describe("POST /content/:collection/:id/publish forwards locale to handleContentGet", () => { + it("passes locale=en when query param is present", async () => { + const emdash = buildEmdash(); + const res = await postPublish( + ctx({ + user: editor, + emdash, + method: "POST", + url: "http://localhost/_emdash/api/content/post/hello/publish?locale=en", + }), + ); + expect(res.status).toBe(200); + expect(emdash.handleContentGet).toHaveBeenCalledWith("post", "hello", "en"); + }); +}); + +describe("POST /content/:collection/:id/unpublish forwards locale to handleContentGet", () => { + it("passes locale=en when query param is present", async () => { + const emdash = buildEmdash(); + const res = await postUnpublish( + ctx({ + user: editor, + emdash, + method: "POST", + url: "http://localhost/_emdash/api/content/post/hello/unpublish?locale=en", + }), + ); + expect(res.status).toBe(200); + expect(emdash.handleContentGet).toHaveBeenCalledWith("post", "hello", "en"); + }); +}); + +describe("POST /content/:collection/:id/discard-draft forwards locale to handleContentGet", () => { + it("passes locale=en when query param is present", async () => { + const emdash = buildEmdash(); + const res = await postDiscard( + ctx({ + user: editor, + emdash, + method: "POST", + url: "http://localhost/_emdash/api/content/post/hello/discard-draft?locale=en", + }), + ); + expect(res.status).toBe(200); + expect(emdash.handleContentGet).toHaveBeenCalledWith("post", "hello", "en"); + }); +}); + +describe("POST /content/:collection/:id/schedule forwards locale to handleContentGet", () => { + it("passes locale=en when query param is present", async () => { + const emdash = buildEmdash(); + const res = await postSchedule( + ctx({ + user: editor, + emdash, + method: "POST", + body: { scheduledAt: "2026-12-31T23:59:59Z" }, + url: "http://localhost/_emdash/api/content/post/hello/schedule?locale=en", + }), + ); + expect(res.status).toBe(200); + expect(emdash.handleContentGet).toHaveBeenCalledWith("post", "hello", "en"); + }); +}); + +describe("DELETE /content/:collection/:id/schedule forwards locale to handleContentGet", () => { + it("passes locale=en when query param is present", async () => { + const emdash = buildEmdash(); + const res = await deleteSchedule( + ctx({ + user: editor, + emdash, + method: "DELETE", + url: "http://localhost/_emdash/api/content/post/hello/schedule?locale=en", + }), + ); + expect(res.status).toBe(200); + expect(emdash.handleContentGet).toHaveBeenCalledWith("post", "hello", "en"); + }); +});