/** * 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, title: string, slug: string, ): Promise { 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, id: string, ): Promise { 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, id: string, ): Promise { 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, id: string, ): Promise { 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; 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; 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; 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; 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; 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; 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); }); });