The rendering split is dictated by CI building with no database. /blog, /blog/rss.xml and /content-sitemap.xml have no dynamic params, so Next prerenders them at build time and the build fails on a missing Payload secret — caught here, not on staging. They are force-dynamic instead: one indexed query against Postgres on the same Docker network, and a newly published post appears immediately rather than waiting on a revalidation. /blog/[slug] keeps ISR, because with no generateStaticParams there is nothing to prerender; it is generated on first request and cached, which is exactly what the collection's afterChange hook exists to invalidate. RichText takes `converters`, not `blocks`, in Payload 3.88, and the default converters must be spread or every paragraph and heading loses its renderer and the body comes out empty. BlogPosting references the Person and Organization by @id rather than repeating them, so every post and the About page resolve to one author entity instead of declaring several people with the same name. /sitemap.xml is proxied from FastAPI, which knows nothing about Payload, so the Next-owned URLs get their own sitemap and robots.txt lists both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
65 lines
2.4 KiB
TypeScript
65 lines
2.4 KiB
TypeScript
/**
|
|
* 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/);
|
|
});
|
|
|
|
it('is publicly readable', () => {
|
|
expect(POSTS).toMatch(/access:\s*\{\s*read:\s*\(\)\s*=>\s*true/);
|
|
});
|
|
|
|
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.
|
|
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\]/);
|
|
});
|
|
});
|