How to Test CMS Content Publishing Workflows
Short answer
CMS workflows fail when draft content leaks to public CDN, scheduled publish never fires, preview tokens work after expiry, or rollback leaves stale cache—not when editors can save drafts. Probe content_state on draft, preview, and public endpoints; use Playwright clock for publish_at; pair rich text tests with sanitized HTML probes; enforce RBAC on publish actions.
Part of Testing Guides by industry.
Who this is for
Teams shipping marketing CMS, headless Contentful/Sanity/Strapi workflows, or in-app page builders with draft → review → publish, scheduled go-live, preview URLs, and rollback.
Why testing CMS publishing matters
Publishing mistakes are public and immediate:
- Draft leak — preview or misconfigured CDN serves draft to Google; SEO and legal exposure.
- Scheduled publish miss — campaign launches empty; revenue event failure.
- Rollback incomplete — revert shows old title but body cached; brand inconsistency.
- RBAC bypass — writer publishes without approver; governance failure.
- Wrong locale publish — EN draft live on DE path; i18n incident.
Probe content_state, public cache headers, and slug resolution on three surfaces: editor API, preview route, anonymous public URL.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| Preview leak | Public CDN | Draft indexed | Probe cache + noindex |
| Scheduled publish | Cron lag | Still draft | clock + job trigger |
| Rollback | CDN stale | Mixed version | Probe version id |
| RBAC publish | Writer only | Published | Probe 403 + state draft |
| Preview token expiry | Old link works | Security | clock + 403 |
| Slug change | Old URL 404 | Broken links | Probe redirect |
| Rich text body | XSS in draft | Public exploit | Sanitizer probe |
| Multi-locale | Wrong lang live | Mixed | Probe locale field |
| Unpublish | Still in sitemap | SEO ghost | Probe sitemap + state |
| Concurrent edit | Last save wins | Lost content | Probe version conflict |
| Asset reference | Broken image | Live 404 | Probe asset id |
| Scheduled unpublish | Content remains | Policy | clock + probe archived |
Seed draft content
await request.post('/api/test/seed-content', {
data: {
runId,
slug: `campaign-${runId}`,
state: 'draft',
bodyHtml: '<h1>Draft headline</h1>',
publishAt: null,
},
});
Draft must not appear publicly
const publicRes = await request.get(`https://test-site.local/p/${slug}`);
expect(publicRes.status()).toBe(404);
const preview = await request.get(`/api/test/preview-token?slug=${slug}`).then(r => r.json());
await page.goto(preview.url);
await expect(page.getByRole('heading')).toContainText('Draft headline');
const publicAgain = await request.get(`https://test-site.local/p/${slug}`);
expect(publicAgain.status()).toBe(404);
Publish and probe
await page.goto(`/cms/edit/${slug}`);
await page.getByRole('button', { name: 'Publish' }).click(); // as approver role
await expect.poll(async () =>
(await request.get(`/api/test/content?slug=${slug}`)).json()
).toMatchObject({ state: 'published' });
const live = await request.get(`https://test-site.local/p/${slug}`);
expect(live.status()).toBe(200);
expect(live.headers()['cache-control']).toMatch(/public/);
Pair editor body tests with rich text guide.
Scheduled publish
await request.patch(`/api/test/content/${slug}`, {
data: { publishAt: '2024-06-15T09:00:00Z', state: 'scheduled' },
});
await context.clock.install({ time: new Date('2024-06-15T08:59:00Z') });
await page.goto(`https://test-site.local/p/${slug}`);
expect((await page.goto(`https://test-site.local/p/${slug}`))!.status()).toBe(404);
await context.clock.runFor(60_000);
await request.post('/api/test/trigger-publish-jobs'); // or wait cron
await expect.poll(async () =>
(await request.get(`/api/test/content?slug=${slug}`)).json().state
).toBe('published');
RBAC negative
// Login as writer without publish permission
await page.getByRole('button', { name: 'Publish' }).click();
await expect(page.getByRole('alert')).toContainText(/permission/i);
const doc = await request.get(`/api/test/content?slug=${slug}`).then(r => r.json());
expect(doc.state).toBe('draft');
See RBAC permissions.
Requirement slices to cover
content_state— draft, scheduled, published, archived
CI checklist
- Public 404 while draft for every slug spec
- Preview uses token; anonymous cannot access
- Scheduled publish with clock + job trigger
- Rollback changes version id on public probe
- Rich text XSS paste negative on publish path
- Writer cannot publish without approver probe
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
| Editor save only | Leak untested | Public URL probe |
| Assert CMS UI badge | Cache stale | version id probe |
| Real CDN purge wait | Slow | Test purge hook |
| Skip scheduled | Launch bugs | clock spec |
| One locale | Wrong lang live | locale probe |
| No RBAC negative | Governance | 403 publish |
Example scenario
Situation: Editor schedules campaign for 9 AM; public site must stay previous version until then.
Expected outcome: Before 9 AM public shows old content; at 9 AM new content live with version bump.
Why UI-only automation breaks: CMS shows scheduled but public CDN already serves new draft—early launch.
- Arrange: Seed published v1 and scheduled v2 with publishAt 9 AM.
- Act: clock before 9—public v1; trigger jobs at 9—public v2.
- Assert: Probe content_state and public body hash matches v2 only after 9; v1 before.
TestChimp workflow: Instrument cms_publish with content_state; evolve scheduled path if prod scheduled share high.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Connect scenarios to your QA workflow
Capture business rules in markdown test plans and enforce them with seed routes and probe Assert. Link SmartTests with // @Scenario: for requirement traceability. Use /testchimp test on PRs; /testchimp explore on SmartTest paths for non-functional gaps (ExploreChimp).
Related scenarios
- Rich text editors — body HTML
- RBAC permissions — approver roles
- Localization — locale publish
- Date/time/timezones — publish_at
External references
Frequently asked questions
How do I test scheduled publish?
Set publishAt via API, clock before time assert public 404/old version, advance clock and trigger publish job route, poll content_state published and public body updated.
How do preview URLs stay private?
Preview token route returns 200 with draft; anonymous public URL 404; expired token after clock returns 403.
How do rollbacks interact with CDN?
Revert via CMS, call test purge hook, probe public version id matches reverted revision—not just editor UI.
How do approver RBAC tests work?
Writer attempts publish, probe remains draft and API 403; approver publishes, probe published.
Rich text XSS before publish?
Paste malicious HTML in editor, publish attempt should fail sanitizer probe or strip before public—see rich text pattern guide.
Multi-locale publishing?
Publish EN only, probe DE path still old locale content; publish DE, probe hreflang paths independently.
Which content_state paths need TrueCoverage?
Compare prod editorial events—evolve scheduled and rollback SmartTests when undertested vs manual publish.
Apply these patterns in your repo
Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.