Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
573 lines
18 KiB
TypeScript
573 lines
18 KiB
TypeScript
/**
|
|
* Content Actions E2E Tests
|
|
*
|
|
* Tests content lifecycle actions that go beyond basic CRUD:
|
|
* - Schedule / unschedule for future publishing
|
|
* - Duplicate content
|
|
* - Soft delete (trash) and restore from trash
|
|
* - Permanent delete
|
|
* - Discard draft changes (revert to published version)
|
|
*
|
|
* Uses the seeded "posts" collection which supports drafts and revisions.
|
|
*/
|
|
|
|
import { test, expect } from "../fixtures";
|
|
|
|
// ---------- regex patterns ----------
|
|
|
|
const SCHEDULE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/schedule/;
|
|
const DUPLICATE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/duplicate/;
|
|
const DISCARD_DRAFT_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/discard-draft/;
|
|
const RESTORE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/restore/;
|
|
const PERMANENT_DELETE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/permanent/;
|
|
|
|
// Button/tab label patterns
|
|
const TRASH_TAB_LABEL = /Trash/i;
|
|
const RESTORE_LABEL = /Restore/i;
|
|
const PERM_DELETE_LABEL = /Permanently delete/i;
|
|
|
|
// ---------- helpers ----------
|
|
|
|
function apiHeaders(token: string, baseUrl: string) {
|
|
return {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
"X-EmDash-Request": "1",
|
|
Origin: baseUrl,
|
|
};
|
|
}
|
|
|
|
/** Create a post via API and return its ID */
|
|
async function createPost(
|
|
baseUrl: string,
|
|
headers: Record<string, string>,
|
|
title: string,
|
|
slug: string,
|
|
): Promise<string> {
|
|
const res = await fetch(`${baseUrl}/_emdash/api/content/posts`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({ data: { title }, slug }),
|
|
});
|
|
const json: any = await res.json();
|
|
return json.data?.item?.id ?? json.data?.id;
|
|
}
|
|
|
|
/** Publish a post via API */
|
|
async function publishPost(
|
|
baseUrl: string,
|
|
headers: Record<string, string>,
|
|
id: string,
|
|
): Promise<void> {
|
|
await fetch(`${baseUrl}/_emdash/api/content/posts/${id}/publish`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({}),
|
|
});
|
|
}
|
|
|
|
/** Soft-delete a post via API (move to trash) */
|
|
async function trashPost(
|
|
baseUrl: string,
|
|
headers: Record<string, string>,
|
|
id: string,
|
|
): Promise<void> {
|
|
await fetch(`${baseUrl}/_emdash/api/content/posts/${id}`, {
|
|
method: "DELETE",
|
|
headers,
|
|
});
|
|
}
|
|
|
|
/** Clean up a post — trash then permanently delete, ignoring errors */
|
|
async function cleanupPost(
|
|
baseUrl: string,
|
|
headers: Record<string, string>,
|
|
id: string,
|
|
): Promise<void> {
|
|
await fetch(`${baseUrl}/_emdash/api/content/posts/${id}`, {
|
|
method: "DELETE",
|
|
headers,
|
|
}).catch(() => {});
|
|
await fetch(`${baseUrl}/_emdash/api/content/posts/${id}/permanent`, {
|
|
method: "DELETE",
|
|
headers,
|
|
}).catch(() => {});
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Schedule / Unschedule
|
|
// ==========================================================================
|
|
|
|
test.describe("Schedule content", () => {
|
|
let headers: Record<string, string>;
|
|
let baseUrl: string;
|
|
let postId: string;
|
|
|
|
test.beforeEach(async ({ admin, serverInfo }) => {
|
|
await admin.devBypassAuth();
|
|
baseUrl = serverInfo.baseUrl;
|
|
headers = apiHeaders(serverInfo.token, baseUrl);
|
|
|
|
// Create a fresh draft post for scheduling tests
|
|
postId = await createPost(
|
|
baseUrl,
|
|
headers,
|
|
"Schedule Test Post",
|
|
`schedule-test-${Date.now()}`,
|
|
);
|
|
});
|
|
|
|
test.afterEach(async () => {
|
|
await cleanupPost(baseUrl, headers, postId);
|
|
});
|
|
|
|
test("schedule a draft post for future publishing", async ({ admin, page }) => {
|
|
await admin.goToEditContent("posts", postId);
|
|
await admin.waitForLoading();
|
|
|
|
// Verify we're on the edit page with our post
|
|
await expect(page.locator("#field-title")).toHaveValue("Schedule Test Post");
|
|
|
|
// The "Schedule for later" button should be visible in the sidebar
|
|
const scheduleButton = page.getByRole("button", { name: "Schedule for later" });
|
|
await expect(scheduleButton).toBeVisible({ timeout: 5000 });
|
|
await scheduleButton.click();
|
|
|
|
// A datetime input should appear
|
|
const dateInput = page.getByLabel("Schedule for");
|
|
await expect(dateInput).toBeVisible({ timeout: 5000 });
|
|
|
|
// Set a future date (tomorrow)
|
|
const tomorrow = new Date();
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
tomorrow.setHours(9, 0, 0, 0);
|
|
const dateValue = tomorrow.toISOString().slice(0, 16); // datetime-local format
|
|
await dateInput.fill(dateValue);
|
|
|
|
// Click the "Schedule" confirm button and wait for the API response
|
|
const scheduleResponse = page.waitForResponse(
|
|
(res) =>
|
|
SCHEDULE_API_PATTERN.test(res.url()) &&
|
|
res.request().method() === "POST" &&
|
|
res.status() === 200,
|
|
{ timeout: 10000 },
|
|
);
|
|
await page.getByRole("button", { name: "Schedule", exact: true }).click();
|
|
await scheduleResponse;
|
|
|
|
// A toast confirming scheduling should appear
|
|
await expect(page.getByRole("heading", { name: "Scheduled" })).toBeVisible({ timeout: 5000 });
|
|
|
|
// The scheduled date info should be visible
|
|
await expect(page.locator("text=Scheduled for:")).toBeVisible({ timeout: 5000 });
|
|
|
|
// An "Unschedule" button should be visible
|
|
await expect(page.getByRole("button", { name: "Unschedule" })).toBeVisible();
|
|
});
|
|
|
|
test("unschedule a scheduled post", async ({ admin, page }) => {
|
|
// Schedule the post via API first
|
|
const tomorrow = new Date();
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
await fetch(`${baseUrl}/_emdash/api/content/posts/${postId}/schedule`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({ scheduledAt: tomorrow.toISOString() }),
|
|
});
|
|
|
|
await admin.goToEditContent("posts", postId);
|
|
await admin.waitForLoading();
|
|
|
|
// Verify scheduled state is shown
|
|
await expect(page.locator("text=Scheduled for:")).toBeVisible({ timeout: 5000 });
|
|
|
|
// Click unschedule and wait for API response
|
|
const unscheduleResponse = page.waitForResponse(
|
|
(res) =>
|
|
SCHEDULE_API_PATTERN.test(res.url()) &&
|
|
res.request().method() === "DELETE" &&
|
|
res.status() === 200,
|
|
{ timeout: 10000 },
|
|
);
|
|
await page.getByRole("button", { name: "Unschedule" }).click();
|
|
await unscheduleResponse;
|
|
|
|
// The scheduled info should disappear
|
|
await expect(page.locator("text=Scheduled for:")).not.toBeVisible({ timeout: 5000 });
|
|
|
|
// The "Schedule for later" button should reappear
|
|
await expect(page.getByRole("button", { name: "Schedule for later" })).toBeVisible({
|
|
timeout: 5000,
|
|
});
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// Duplicate
|
|
// ==========================================================================
|
|
|
|
test.describe("Duplicate content", () => {
|
|
let headers: Record<string, string>;
|
|
let baseUrl: string;
|
|
let postId: string;
|
|
let duplicateId: string | undefined;
|
|
|
|
test.beforeEach(async ({ admin, serverInfo }) => {
|
|
await admin.devBypassAuth();
|
|
baseUrl = serverInfo.baseUrl;
|
|
headers = apiHeaders(serverInfo.token, baseUrl);
|
|
|
|
postId = await createPost(
|
|
baseUrl,
|
|
headers,
|
|
"Duplicate Source Post",
|
|
`dup-source-${Date.now()}`,
|
|
);
|
|
});
|
|
|
|
test.afterEach(async () => {
|
|
await cleanupPost(baseUrl, headers, postId);
|
|
if (duplicateId) {
|
|
await cleanupPost(baseUrl, headers, duplicateId);
|
|
}
|
|
});
|
|
|
|
test("duplicate a post from the content list", async ({ admin, page }) => {
|
|
await admin.goToContent("posts");
|
|
await admin.waitForLoading();
|
|
|
|
// Find the row for our post and click the duplicate button
|
|
const row = page.locator("tr", { hasText: "Duplicate Source Post" });
|
|
await expect(row).toBeVisible({ timeout: 5000 });
|
|
|
|
const duplicateResponse = page.waitForResponse(
|
|
(res) =>
|
|
DUPLICATE_API_PATTERN.test(res.url()) &&
|
|
res.request().method() === "POST" &&
|
|
(res.status() === 200 || res.status() === 201),
|
|
{ timeout: 10000 },
|
|
);
|
|
|
|
await row.getByRole("button", { name: "Duplicate Duplicate Source Post" }).click();
|
|
const response = await duplicateResponse;
|
|
const body = await response.json();
|
|
duplicateId = body.data?.item?.id ?? body.data?.id;
|
|
|
|
// Wait for the list to refresh
|
|
await admin.waitForLoading();
|
|
|
|
// A copy should now appear in the list (typically with "(Copy)" suffix or similar)
|
|
// Reload to ensure fresh data
|
|
await page.reload();
|
|
await admin.waitForShell();
|
|
await admin.waitForLoading();
|
|
|
|
// The duplicate should exist -- verify via API since the title pattern may vary
|
|
expect(duplicateId).toBeTruthy();
|
|
const getRes = await fetch(`${baseUrl}/_emdash/api/content/posts/${duplicateId}`, {
|
|
headers,
|
|
});
|
|
expect(getRes.ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// Trash (soft delete) and Restore
|
|
// ==========================================================================
|
|
|
|
test.describe("Trash and restore content", () => {
|
|
let headers: Record<string, string>;
|
|
let baseUrl: string;
|
|
let postId: string;
|
|
|
|
test.beforeEach(async ({ admin, serverInfo }) => {
|
|
await admin.devBypassAuth();
|
|
baseUrl = serverInfo.baseUrl;
|
|
headers = apiHeaders(serverInfo.token, baseUrl);
|
|
|
|
postId = await createPost(baseUrl, headers, "Trash Test Post", `trash-test-${Date.now()}`);
|
|
});
|
|
|
|
test.afterEach(async () => {
|
|
await cleanupPost(baseUrl, headers, postId);
|
|
});
|
|
|
|
test("move a post to trash from the content list", async ({ admin, page }) => {
|
|
await admin.goToContent("posts");
|
|
await admin.waitForLoading();
|
|
|
|
// Find the row and click the trash button
|
|
const row = page.locator("tr", { hasText: "Trash Test Post" });
|
|
await expect(row).toBeVisible({ timeout: 5000 });
|
|
await row.getByRole("button", { name: "Move Trash Test Post to trash" }).click();
|
|
|
|
// A confirmation dialog should appear
|
|
const dialog = page.locator('[role="dialog"]').filter({ hasText: "Move to Trash?" });
|
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
|
await expect(dialog.locator("text=Trash Test Post")).toBeVisible();
|
|
|
|
// Confirm the deletion
|
|
const deleteResponse = page.waitForResponse(
|
|
(res) =>
|
|
res.url().includes(`/api/content/posts/${postId}`) &&
|
|
res.request().method() === "DELETE" &&
|
|
res.status() === 200,
|
|
{ timeout: 10000 },
|
|
);
|
|
await dialog.getByRole("button", { name: "Move to Trash" }).click();
|
|
await deleteResponse;
|
|
|
|
// The dialog should close
|
|
await expect(dialog).not.toBeVisible({ timeout: 5000 });
|
|
|
|
// The post should no longer appear in the "All" tab
|
|
await admin.waitForLoading();
|
|
await expect(page.locator("tr", { hasText: "Trash Test Post" })).not.toBeVisible({
|
|
timeout: 5000,
|
|
});
|
|
});
|
|
|
|
test("restore a trashed post from the trash tab", async ({ admin, page }) => {
|
|
// Trash the post via API first
|
|
await trashPost(baseUrl, headers, postId);
|
|
|
|
await admin.goToContent("posts");
|
|
await admin.waitForLoading();
|
|
|
|
// Switch to the Trash tab
|
|
const trashTab = page.getByRole("tab", { name: TRASH_TAB_LABEL });
|
|
await expect(trashTab).toBeVisible({ timeout: 5000 });
|
|
await trashTab.click();
|
|
|
|
// Wait for trashed items to load
|
|
await admin.waitForLoading();
|
|
|
|
// The trashed post should appear
|
|
const trashedRow = page.locator("tr", { hasText: "Trash Test Post" });
|
|
await expect(trashedRow).toBeVisible({ timeout: 10000 });
|
|
|
|
// Click the restore button
|
|
const restoreResponse = page.waitForResponse(
|
|
(res) =>
|
|
RESTORE_API_PATTERN.test(res.url()) &&
|
|
res.request().method() === "POST" &&
|
|
res.status() === 200,
|
|
{ timeout: 10000 },
|
|
);
|
|
await trashedRow.getByRole("button", { name: RESTORE_LABEL }).click();
|
|
await restoreResponse;
|
|
|
|
// Wait for the list to refresh
|
|
await admin.waitForLoading();
|
|
|
|
// Switch back to the All tab
|
|
const allTab = page.getByRole("tab", { name: "All" });
|
|
await allTab.click();
|
|
await admin.waitForLoading();
|
|
|
|
// The post should be back in the main list
|
|
await expect(page.locator("tr", { hasText: "Trash Test Post" })).toBeVisible({
|
|
timeout: 10000,
|
|
});
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// Permanent delete
|
|
// ==========================================================================
|
|
|
|
test.describe("Permanent delete", () => {
|
|
let headers: Record<string, string>;
|
|
let baseUrl: string;
|
|
let postId: string;
|
|
|
|
test.beforeEach(async ({ admin, serverInfo }) => {
|
|
await admin.devBypassAuth();
|
|
baseUrl = serverInfo.baseUrl;
|
|
headers = apiHeaders(serverInfo.token, baseUrl);
|
|
|
|
postId = await createPost(baseUrl, headers, "Permanent Delete Post", `perm-del-${Date.now()}`);
|
|
// Trash it first -- permanent delete only works on trashed items
|
|
await trashPost(baseUrl, headers, postId);
|
|
});
|
|
|
|
test("permanently delete a trashed post", async ({ admin, page }) => {
|
|
await admin.goToContent("posts");
|
|
await admin.waitForLoading();
|
|
|
|
// Switch to the Trash tab
|
|
const trashTab = page.getByRole("tab", { name: TRASH_TAB_LABEL });
|
|
await trashTab.click();
|
|
await admin.waitForLoading();
|
|
|
|
// The trashed post should appear
|
|
const trashedRow = page.locator("tr", { hasText: "Permanent Delete Post" });
|
|
await expect(trashedRow).toBeVisible({ timeout: 10000 });
|
|
|
|
// Click the permanent delete button (trash icon in trash view)
|
|
await trashedRow.getByRole("button", { name: PERM_DELETE_LABEL }).click();
|
|
|
|
// A confirmation dialog should appear
|
|
const dialog = page.locator('[role="dialog"]').filter({ hasText: "Delete Permanently?" });
|
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
|
await expect(dialog.locator("text=Permanent Delete Post")).toBeVisible();
|
|
|
|
// Confirm permanent deletion
|
|
const permanentDeleteResponse = page.waitForResponse(
|
|
(res) =>
|
|
PERMANENT_DELETE_API_PATTERN.test(res.url()) &&
|
|
res.request().method() === "DELETE" &&
|
|
res.status() === 200,
|
|
{ timeout: 10000 },
|
|
);
|
|
await dialog.getByRole("button", { name: "Delete Permanently" }).click();
|
|
await permanentDeleteResponse;
|
|
|
|
// The dialog should close
|
|
await expect(dialog).not.toBeVisible({ timeout: 5000 });
|
|
|
|
// The post should disappear from the trash
|
|
await admin.waitForLoading();
|
|
await expect(page.locator("tr", { hasText: "Permanent Delete Post" })).not.toBeVisible({
|
|
timeout: 5000,
|
|
});
|
|
|
|
// Verify via API that the post is truly gone
|
|
const getRes = await fetch(`${baseUrl}/_emdash/api/content/posts/${postId}`, { headers });
|
|
expect(getRes.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// Discard draft changes
|
|
// ==========================================================================
|
|
|
|
test.describe("Discard draft changes", () => {
|
|
let headers: Record<string, string>;
|
|
let baseUrl: string;
|
|
let postId: string;
|
|
|
|
test.beforeEach(async ({ admin, serverInfo }) => {
|
|
await admin.devBypassAuth();
|
|
baseUrl = serverInfo.baseUrl;
|
|
headers = apiHeaders(serverInfo.token, baseUrl);
|
|
|
|
// Create and publish a post
|
|
postId = await createPost(
|
|
baseUrl,
|
|
headers,
|
|
"Published Original Title",
|
|
`discard-draft-${Date.now()}`,
|
|
);
|
|
await publishPost(baseUrl, headers, postId);
|
|
});
|
|
|
|
test.afterEach(async () => {
|
|
await cleanupPost(baseUrl, headers, postId);
|
|
});
|
|
|
|
test("discard draft reverts to the published version", async ({ admin, page }) => {
|
|
// Navigate to the editor and make changes to create a draft
|
|
await admin.goToEditContent("posts", postId);
|
|
await admin.waitForLoading();
|
|
|
|
// Verify the published title
|
|
const titleInput = page.locator("#field-title");
|
|
await expect(titleInput).toHaveValue("Published Original Title");
|
|
|
|
// Edit the title
|
|
await titleInput.fill("Draft Modified Title");
|
|
|
|
// Save to create a draft revision
|
|
await admin.clickSave();
|
|
await admin.waitForSaveComplete();
|
|
|
|
// The "Pending changes" badge should appear
|
|
await expect(page.locator("text=Pending changes")).toBeVisible({ timeout: 5000 });
|
|
|
|
// The "Discard changes" button should be visible
|
|
const discardButton = page.getByRole("button", { name: "Discard changes" });
|
|
await expect(discardButton).toBeVisible({ timeout: 5000 });
|
|
await discardButton.click();
|
|
|
|
// A confirmation dialog should appear
|
|
const dialog = page.locator('[role="dialog"]').filter({ hasText: "Discard draft changes?" });
|
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
|
|
|
// Confirm discarding the draft
|
|
const discardResponse = page.waitForResponse(
|
|
(res) =>
|
|
DISCARD_DRAFT_API_PATTERN.test(res.url()) &&
|
|
res.request().method() === "POST" &&
|
|
res.status() === 200,
|
|
{ timeout: 10000 },
|
|
);
|
|
await dialog.getByRole("button", { name: "Discard changes" }).click();
|
|
await discardResponse;
|
|
|
|
// Wait for the page to update
|
|
await admin.waitForLoading();
|
|
|
|
// The title should revert to the published version
|
|
await expect(titleInput).toHaveValue("Published Original Title", { timeout: 10000 });
|
|
|
|
// The "Pending changes" badge should be gone
|
|
await expect(page.locator("text=Pending changes")).not.toBeVisible({ timeout: 5000 });
|
|
|
|
// The "Discard changes" button should also be gone
|
|
await expect(discardButton).not.toBeVisible({ timeout: 5000 });
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// Trash from the editor
|
|
// ==========================================================================
|
|
|
|
test.describe("Trash from editor", () => {
|
|
let headers: Record<string, string>;
|
|
let baseUrl: string;
|
|
let postId: string;
|
|
|
|
test.beforeEach(async ({ admin, serverInfo }) => {
|
|
await admin.devBypassAuth();
|
|
baseUrl = serverInfo.baseUrl;
|
|
headers = apiHeaders(serverInfo.token, baseUrl);
|
|
|
|
postId = await createPost(baseUrl, headers, "Editor Trash Post", `editor-trash-${Date.now()}`);
|
|
});
|
|
|
|
test.afterEach(async () => {
|
|
await cleanupPost(baseUrl, headers, postId);
|
|
});
|
|
|
|
test("move a post to trash from the editor sidebar", async ({ admin, page }) => {
|
|
await admin.goToEditContent("posts", postId);
|
|
await admin.waitForLoading();
|
|
|
|
// The "Move to Trash" button should be in the sidebar
|
|
const trashButton = page.getByRole("button", { name: "Move to Trash" });
|
|
await expect(trashButton).toBeVisible({ timeout: 5000 });
|
|
await trashButton.click();
|
|
|
|
// A confirmation dialog should appear
|
|
const dialog = page.locator('[role="dialog"]').filter({ hasText: "Move to Trash?" });
|
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
|
|
|
// Confirm trashing
|
|
const deleteResponse = page.waitForResponse(
|
|
(res) =>
|
|
res.url().includes(`/api/content/posts/${postId}`) &&
|
|
res.request().method() === "DELETE" &&
|
|
res.status() === 200,
|
|
{ timeout: 10000 },
|
|
);
|
|
await dialog.getByRole("button", { name: "Move to Trash" }).click();
|
|
await deleteResponse;
|
|
|
|
// Should navigate back to the content list (or show a confirmation)
|
|
// Verify the post is trashed via API
|
|
const getRes = await fetch(`${baseUrl}/_emdash/api/content/posts/${postId}`, { headers });
|
|
expect(getRes.status).toBe(404);
|
|
});
|
|
});
|