2026-09-02 16:24:15 +01:00
|
|
|
/**
|
|
|
|
|
* Payload is ESM-only and next/jest will not transform it, so the collections
|
|
|
|
|
* cannot be imported and their sanitised config inspected here (see
|
|
|
|
|
* lib/payloadRoutes.ts for the full reasoning). These assert the source of the
|
|
|
|
|
* collection definitions instead — enough to catch the settings whose loss is
|
|
|
|
|
* silent, and cheap. Behaviour is proved by the e2e journeys against staging.
|
|
|
|
|
*/
|
|
|
|
|
import fs from 'fs';
|
|
|
|
|
import path from 'path';
|
|
|
|
|
|
|
|
|
|
const read = (file: string) =>
|
|
|
|
|
fs.readFileSync(path.join(__dirname, '..', '..', 'collections', file), 'utf8');
|
|
|
|
|
|
|
|
|
|
const POSTS = read('Posts.ts');
|
|
|
|
|
const MEDIA = read('Media.ts');
|
|
|
|
|
const CONFIG = fs.readFileSync(
|
|
|
|
|
path.join(__dirname, '..', '..', 'payload.config.ts'),
|
|
|
|
|
'utf8',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
describe('posts collection', () => {
|
|
|
|
|
it('supports drafts, so saving is not publishing', () => {
|
|
|
|
|
expect(POSTS).toMatch(/drafts:\s*true/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('has a unique, indexed slug for stable URLs', () => {
|
|
|
|
|
const slugField = POSTS.slice(POSTS.indexOf("name: 'slug'"));
|
|
|
|
|
expect(slugField).toMatch(/unique:\s*true/);
|
|
|
|
|
expect(slugField).toMatch(/index:\s*true/);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-02 16:48:59 +01:00
|
|
|
it('hides drafts from anonymous readers at the access layer', () => {
|
|
|
|
|
// Payload's docs are explicit: "The `draft` argument alone does not
|
|
|
|
|
// restrict documents with _status: 'draft' from being returned by the
|
|
|
|
|
// API." The blog pages' where-clause is not enforcement — a direct GET
|
|
|
|
|
// /cms-api/posts would return unpublished drafts to anyone. Access
|
|
|
|
|
// control returning a query constraint is the only thing that stops it.
|
|
|
|
|
expect(POSTS).toMatch(/_status:\s*\{\s*equals:\s*'published'\s*\}/);
|
|
|
|
|
expect(POSTS).toMatch(/if\s*\(req\.user\)\s*return true/);
|
2026-09-02 16:24:15 +01:00
|
|
|
});
|
|
|
|
|
|
2026-09-02 16:28:58 +01:00
|
|
|
it('revalidates the post page when a post changes or is deleted', () => {
|
|
|
|
|
// /blog/[slug] is ISR — generated on first request and cached — so an edit
|
|
|
|
|
// to an already-published post would otherwise not appear until the
|
|
|
|
|
// revalidate window expired, up to an hour of a writer concluding that
|
|
|
|
|
// saving is broken. The index and feeds are force-dynamic and need no hook.
|
2026-09-02 16:24:15 +01:00
|
|
|
expect(POSTS).toContain('afterChange');
|
|
|
|
|
expect(POSTS).toContain('afterDelete');
|
|
|
|
|
expect(POSTS).toMatch(/revalidatePath\(`\/blog\/\$\{[^}]+\}`\)/);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('media collection', () => {
|
|
|
|
|
it('writes uploads to the mounted volume, by absolute path', () => {
|
|
|
|
|
// Must match the payload_media mount in docker-compose.portainer.yml.
|
|
|
|
|
// Payload 3 requires staticDir to be absolute.
|
|
|
|
|
expect(MEDIA).toMatch(/staticDir:\s*'\/app\/media'/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('requires alt text on every upload', () => {
|
|
|
|
|
const altField = MEDIA.slice(MEDIA.indexOf("name: 'alt'"));
|
|
|
|
|
expect(altField).toMatch(/required:\s*true/);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('payload config', () => {
|
|
|
|
|
it('registers every collection', () => {
|
|
|
|
|
expect(CONFIG).toMatch(/collections:\s*\[Users,\s*Posts,\s*Media\]/);
|
|
|
|
|
});
|
|
|
|
|
});
|