/**
* Reproduction for emdash-cms/emdash#1557 — "Bug with publish button when editing posts".
*
* Two reported symptoms, one root cause:
* 1. After saving an edit to a published post, no "Publish changes" button appears
* until the page is refreshed.
* 2. After refreshing and publishing, the button stays active ("Publish changes")
* instead of flipping to "Unpublish".
*
* Root cause (hypothesis under test):
* The editor reads the content item with the query key
* ["content", collection, id, { locale: activeLocale }]
* where `activeLocale` is `undefined` when i18n is NOT configured (router.tsx).
* The save/publish mutations, however, invalidate with
* ["content", collection, id, { locale: rawItem.locale ?? activeLocale }]
* and `rawItem.locale` is the DB default "en" even on non-i18n sites. React Query's
* partial matcher compares { locale: undefined } against { locale: "en" } and finds
* no match, so the invalidation never refetches the item. The editor keeps the stale
* draft-status pointers and the publish button never updates.
*
* This test drives the publish flow (symptom #2) because it needs no typing — just a
* button click — but it exercises the exact same mismatched invalidation key as the
* save flow (symptom #1).
*
* Expected: after publishing, the button becomes "Unpublish".
* Actual (bug): the stale cache is never refetched, so it stays "Publish changes".
*
* NOTE: this file deliberately does NOT mock ContentEditor (unlike router.test.tsx),
* so the real publish-button logic renders.
*/
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, beforeEach, afterEach } from "vitest";
import { ThemeProvider } from "../src/components/ThemeProvider";
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";
// ---------------------------------------------------------------------------
// Fixtures — i18n is intentionally OFF, which is the condition that triggers
// the bug (activeLocale === undefined while rawItem.locale === "en").
// ---------------------------------------------------------------------------
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: undefined,
};
/** A published post that has pending draft changes (live !== draft). */
function publishedWithChanges() {
return {
id: "post_1",
type: "posts",
slug: "published-slug",
status: "published",
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",
};
}
/** Same post after publishing: draft has been promoted to live (live === draft). */
function publishedNoChanges() {
return {
...publishedWithChanges(),
updatedAt: "2025-01-02T00:00:00Z",
publishedAt: "2025-01-02T00:00:00Z",
liveRevisionId: "rev_draft",
draftRevisionId: "rev_draft",
};
}
function buildRouter() {
const queryClient = createTestQueryClient();
const router = createAdminRouter(queryClient);
if (!i18n.locale) {
i18n.loadAndActivate({ locale: "en", messages: {} });
}
function TestApp() {
return (
);
}
return { router, queryClient, TestApp };
}
// ---------------------------------------------------------------------------
describe("ContentEditPage – publish button stays in sync after publishing (#1557)", () => {
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: 30 } })
.on("GET", "/_emdash/api/bylines", { data: { items: [] } })
.on("GET", "/_emdash/api/users", { data: { items: [] } })
// Initial state: published WITH pending draft changes -> "Publish changes" shows.
.on("GET", "/_emdash/api/content/posts/post_1", { data: { item: publishedWithChanges() } })
.on("GET", "/_emdash/api/revisions/rev_draft", {
data: {
item: {
id: "rev_draft",
collection: "posts",
entryId: "post_1",
data: { title: "Draft Title" },
authorId: null,
createdAt: "2025-01-01T00:00:00Z",
},
},
})
// Publishing succeeds; the server has now promoted the draft to live.
.on("POST", "/_emdash/api/content/posts/post_1/publish", {
data: { item: publishedNoChanges() },
});
});
afterEach(() => {
mockFetch.restore();
});
it("flips 'Publish changes' to 'Unpublish' after a successful publish", async () => {
const { router, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection/$id",
params: { collection: "posts", id: "post_1" },
});
const screen = await render();
// The editor loads in the published-with-changes state.
const publishBtn = screen.getByRole("button", { name: "Publish changes" });
await expect.element(publishBtn).toBeInTheDocument();
// After this point the server reports no pending changes (live === draft),
// so a refetch would reveal the post is fully published.
mockFetch.on("GET", "/_emdash/api/content/posts/post_1", {
data: { item: publishedNoChanges() },
});
await publishBtn.click();
// Wait for the publish toast so we know the mutation's onSuccess has run
// (this is where the cache invalidation fires).
await waitFor(() => {
expect(document.body.textContent).toContain("Content is now live");
});
// The button must now reflect the published state. With the locale-key
// mismatch the invalidation matches nothing, the stale item is never
// refetched, and this assertion fails because "Publish changes" is still
// shown instead of "Unpublish".
await expect.element(screen.getByRole("button", { name: "Unpublish" })).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
/** A published post with no pending changes (live === draft). */
function publishedClean() {
return {
id: "post_1",
type: "posts",
slug: "published-slug",
status: "published",
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_1",
draftRevisionId: "rev_1",
};
}
/** Same post after editing + saving: a new draft revision now exists (live !== draft). */
function publishedDirty() {
return {
...publishedClean(),
updatedAt: "2025-01-02T00:00:00Z",
draftRevisionId: "rev_2",
};
}
describe("ContentEditPage – publish button appears after saving an edit (#1557)", () => {
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: 30 } })
.on("GET", "/_emdash/api/bylines", { data: { items: [] } })
.on("GET", "/_emdash/api/users", { data: { items: [] } })
// Initial state: published, no pending changes -> "Unpublish" shows, no
// "Publish changes" button.
.on("GET", "/_emdash/api/content/posts/post_1", { data: { item: publishedClean() } })
.on("GET", "/_emdash/api/revisions/rev_1", {
data: {
item: {
id: "rev_1",
collection: "posts",
entryId: "post_1",
data: { title: "Published Title" },
authorId: null,
createdAt: "2025-01-01T00:00:00Z",
},
},
})
.on("GET", "/_emdash/api/revisions/rev_2", {
data: {
item: {
id: "rev_2",
collection: "posts",
entryId: "post_1",
data: { title: "Published Title edited" },
authorId: null,
createdAt: "2025-01-02T00:00:00Z",
},
},
})
// The PUT response itself reports no pending changes, so that an incidental
// autosave (which patches the cache directly) cannot surface the button on
// its own — only the manual-save invalidation + refetch of the GET below can.
.on("PUT", "/_emdash/api/content/posts/post_1", { data: { item: publishedClean() } });
});
afterEach(() => {
mockFetch.restore();
});
it("shows 'Publish changes' after editing the title and saving", async () => {
const { router, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection/$id",
params: { collection: "posts", id: "post_1" },
});
const screen = await render();
// Loads in the clean published state: "Unpublish" present, no "Publish changes".
await expect.element(screen.getByRole("button", { name: "Unpublish" })).toBeInTheDocument();
// Edit the title so the form is dirty and Save becomes enabled.
const titleInput = screen.getByRole("textbox", { name: "Title" });
await titleInput.fill("Published Title edited");
// From now on the server reports a pending draft revision (live !== draft).
mockFetch.on("GET", "/_emdash/api/content/posts/post_1", { data: { item: publishedDirty() } });
// Two SaveButtons render (header + end-of-form); both submit the same form.
await screen.getByRole("button", { name: "Save", exact: true }).first().click();
// After saving, the editor must offer to publish the new draft. With the
// locale-key mismatch the invalidation matches nothing, the item is never
// refetched, and this assertion fails because no "Publish changes" button
// is rendered until a hard refresh.
await expect
.element(screen.getByRole("button", { name: "Publish changes" }))
.toBeInTheDocument();
});
});