fix: better handling of draft and published posts in preview

This commit is contained in:
2026-02-16 22:20:31 +01:00
parent 201a74f447
commit 4ce1654f47
4 changed files with 328 additions and 22 deletions

View File

@@ -2136,6 +2136,77 @@ Published content`);
});
});
describe('getPublishedVersion', () => {
it('should return null when post has no published file', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: 'draft-only-id',
projectId: 'default',
filePath: '',
}),
});
return chain;
});
const result = await postEngine.getPublishedVersion('draft-only-id');
expect(result).toBeNull();
});
it('should return published content and metadata from filesystem snapshot', async () => {
const publishedFilePath = '/mock/published/snapshot.md';
mockFiles.set(publishedFilePath, `---
id: snapshot-id
projectId: default
title: Published Snapshot Title
slug: published-snapshot
status: published
createdAt: 2024-01-01T00:00:00.000Z
updatedAt: 2024-01-02T00:00:00.000Z
publishedAt: 2024-01-03T00:00:00.000Z
tags:
- published-tag
categories:
- page
---
Published snapshot content`);
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: 'snapshot-id',
projectId: 'default',
title: 'Draft title should not be used',
slug: 'draft-slug',
status: 'draft',
content: 'Draft content should not be used',
filePath: publishedFilePath,
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-10T00:00:00.000Z'),
publishedAt: new Date('2024-01-03T00:00:00.000Z'),
}),
});
return chain;
});
const result = await postEngine.getPublishedVersion('snapshot-id');
expect(result).not.toBeNull();
expect(result?.status).toBe('published');
expect(result?.title).toBe('Published Snapshot Title');
expect(result?.slug).toBe('published-snapshot');
expect(result?.content).toBe('Published snapshot content');
expect(result?.tags).toEqual(['published-tag']);
expect(result?.categories).toEqual(['page']);
});
});
describe('getAllPosts', () => {
it('should return empty result when no posts exist', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {

View File

@@ -8,6 +8,8 @@ import { PreviewServer } from '../../src/main/engine/PreviewServer';
type PostEngineLike = {
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
getPost: (id: string) => Promise<PostData | null>;
hasPublishedVersion: (id: string) => Promise<boolean>;
getPublishedVersion: (id: string) => Promise<PostData | null>;
setProjectContext: (projectId: string, dataDir?: string) => void;
};
@@ -43,6 +45,12 @@ function makeEngine(posts: PostData[]): PostEngineLike {
return {
setProjectContext: vi.fn(),
async hasPublishedVersion(): Promise<boolean> {
return false;
},
async getPublishedVersion(): Promise<PostData | null> {
return null;
},
async getPost(id: string): Promise<PostData | null> {
return byId.get(id) ?? null;
},
@@ -431,4 +439,144 @@ describe('PreviewServer', () => {
const body = await response.text();
expect(body).toBe('fake-image-bytes');
});
it('uses published snapshot content and metadata for draft posts that have a published version', async () => {
const draftWithPublished = makePost({
id: 'draft-1',
status: 'draft',
title: 'Draft Title',
slug: 'draft-slug',
content: '# Draft content must not leak',
tags: ['draft-tag'],
categories: ['draft-category'],
createdAt: new Date('2025-02-14T10:00:00.000Z'),
});
const publishedSnapshot = makePost({
id: 'draft-1',
status: 'published',
title: 'Published Title',
slug: 'published-slug',
content: '# Published content only',
tags: ['published-tag'],
categories: ['page'],
createdAt: new Date('2025-02-14T10:00:00.000Z'),
});
const engine = makeEngine([draftWithPublished]);
engine.hasPublishedVersion = vi.fn(async (id: string) => id === 'draft-1');
engine.getPublishedVersion = vi.fn(async (id: string) => (id === 'draft-1' ? publishedSnapshot : null));
server = new PreviewServer({
postEngine: engine,
settingsEngine: makeSettings(50),
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const rootHtml = await (await fetch(`${server.getBaseUrl()}/`)).text();
expect(rootHtml).toContain('Published content only');
expect(rootHtml).not.toContain('Draft content must not leak');
const publishedSlugResponse = await fetch(`${server.getBaseUrl()}/posts/published-slug/`);
expect(publishedSlugResponse.status).toBe(200);
const draftSlugResponse = await fetch(`${server.getBaseUrl()}/posts/draft-slug/`);
expect(draftSlugResponse.status).toBe(404);
const publishedTagHtml = await (await fetch(`${server.getBaseUrl()}/tag/published-tag/`)).text();
expect(publishedTagHtml).toContain('Published content only');
const draftTagResponse = await fetch(`${server.getBaseUrl()}/tag/draft-tag/`);
expect(draftTagResponse.status).toBe(404);
const draftTagHtml = await draftTagResponse.text();
expect(draftTagHtml).not.toContain('Published content only');
});
it('discovers candidates via status-scoped DB filters for published and draft only', async () => {
const published = makePost({ id: 'pub-1', status: 'published', slug: 'pub-1', content: '# Published one' });
const draft = makePost({ id: 'draft-1', status: 'draft', slug: 'draft-1', content: '# Draft one' });
const getPostsFiltered = vi.fn(async (filter: PostFilter) => {
if (filter.status === 'published') return [published];
if (filter.status === 'draft') return [draft];
return [];
});
const engine: PostEngineLike = {
setProjectContext: vi.fn(),
getPostsFiltered,
getPost: vi.fn(async (id: string) => (id === published.id ? published : draft)),
hasPublishedVersion: vi.fn(async (id: string) => id === draft.id),
getPublishedVersion: vi.fn(async (id: string) => (id === draft.id
? makePost({ ...published, id: draft.id, slug: 'pub-draft', content: '# Published snapshot for draft' })
: null)),
};
server = new PreviewServer({
postEngine: engine,
settingsEngine: makeSettings(50),
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const response = await fetch(`${server.getBaseUrl()}/`);
expect(response.status).toBe(200);
const statusValues = getPostsFiltered.mock.calls.map((args) => args[0]?.status);
expect(statusValues.every((value) => value === 'published' || value === 'draft')).toBe(true);
expect(statusValues).toContain('published');
expect(statusValues).toContain('draft');
});
it('loads published filesystem content only for rendered posts', async () => {
const fullPublishedPosts = Array.from({ length: 60 }).map((_, index) =>
makePost({
id: `pub-full-${index + 1}`,
slug: `pub-full-${index + 1}`,
title: `Published Full ${index + 1}`,
content: `# Published Full ${index + 1}`,
status: 'published',
createdAt: new Date(Date.UTC(2025, 0, 1, 0, 0, index)),
})
);
const summaryPublishedPosts = fullPublishedPosts.map((post) => ({
...post,
content: '',
}));
const byId = new Map(fullPublishedPosts.map((post) => [post.id, post]));
const getPost = vi.fn(async (id: string) => byId.get(id) ?? null);
const engine: PostEngineLike = {
setProjectContext: vi.fn(),
getPost,
hasPublishedVersion: vi.fn(async () => false),
getPublishedVersion: vi.fn(async () => null),
getPostsFiltered: vi.fn(async (filter: PostFilter) => {
if (filter.status === 'published') {
return summaryPublishedPosts;
}
if (filter.status === 'draft') {
return [];
}
return [];
}),
};
server = new PreviewServer({
postEngine: engine,
settingsEngine: makeSettings(50),
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const response = await fetch(`${server.getBaseUrl()}/`);
expect(response.status).toBe(200);
expect(getPost).toHaveBeenCalledTimes(50);
});
});