1455 lines
46 KiB
TypeScript
1455 lines
46 KiB
TypeScript
/**
|
||
* PostEngine Unit Tests
|
||
*
|
||
* Tests the REAL PostEngine class with mocked dependencies.
|
||
* Following TDD best practices: mock external dependencies, test real implementation.
|
||
*/
|
||
|
||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
import { PostEngine, PostData } from '../../src/main/engine/PostEngine';
|
||
import { resetMockCounters } from '../utils/factories';
|
||
|
||
// Create mock data stores
|
||
const mockPosts = new Map<string, any>();
|
||
const mockFiles = new Map<string, string>();
|
||
let mockExecuteArgs: any[] = [];
|
||
|
||
// Create chainable mock for Drizzle ORM
|
||
function createSelectChain() {
|
||
return {
|
||
from: vi.fn().mockReturnThis(),
|
||
where: vi.fn().mockImplementation(function(this: any) {
|
||
return this;
|
||
}),
|
||
orderBy: vi.fn().mockReturnThis(),
|
||
limit: vi.fn().mockReturnThis(),
|
||
offset: vi.fn().mockReturnThis(),
|
||
all: vi.fn().mockImplementation(() => Promise.resolve(Array.from(mockPosts.values()))),
|
||
get: vi.fn().mockImplementation(() => Promise.resolve(undefined)),
|
||
};
|
||
}
|
||
|
||
function createDrizzleMock() {
|
||
return {
|
||
select: vi.fn(() => createSelectChain()),
|
||
insert: vi.fn(() => ({
|
||
values: vi.fn((data: any) => {
|
||
if (data && data.id) {
|
||
mockPosts.set(data.id, data);
|
||
}
|
||
return Promise.resolve();
|
||
}),
|
||
})),
|
||
update: vi.fn(() => ({
|
||
set: vi.fn(() => ({
|
||
where: vi.fn(() => Promise.resolve()),
|
||
})),
|
||
})),
|
||
delete: vi.fn(() => ({
|
||
where: vi.fn(() => Promise.resolve()),
|
||
})),
|
||
};
|
||
}
|
||
|
||
const mockLocalDb = createDrizzleMock();
|
||
const mockLocalClient = {
|
||
execute: vi.fn(async (query: { sql: string; args: any[] }) => {
|
||
mockExecuteArgs.push(query);
|
||
return { rows: [] };
|
||
}),
|
||
};
|
||
|
||
// Mock the database module
|
||
vi.mock('../../src/main/database', () => ({
|
||
getDatabase: vi.fn(() => ({
|
||
getLocal: vi.fn(() => mockLocalDb),
|
||
getLocalClient: vi.fn(() => mockLocalClient),
|
||
getRemote: vi.fn(() => null),
|
||
getDataPaths: vi.fn(() => ({
|
||
database: '/mock/userData/bds.db',
|
||
posts: '/mock/userData/posts',
|
||
media: '/mock/userData/media',
|
||
})),
|
||
initializeLocal: vi.fn(),
|
||
initializeRemote: vi.fn(),
|
||
close: vi.fn(),
|
||
})),
|
||
}));
|
||
|
||
// Mock fs/promises
|
||
vi.mock('fs/promises', () => ({
|
||
readFile: vi.fn(async (path: string) => {
|
||
const content = mockFiles.get(path);
|
||
if (!content) {
|
||
const error = new Error(`ENOENT: no such file or directory, open '${path}'`);
|
||
(error as any).code = 'ENOENT';
|
||
throw error;
|
||
}
|
||
return content;
|
||
}),
|
||
writeFile: vi.fn(async (path: string, content: string) => {
|
||
mockFiles.set(path, content);
|
||
}),
|
||
unlink: vi.fn(async (path: string) => {
|
||
mockFiles.delete(path);
|
||
}),
|
||
mkdir: vi.fn(async () => {}),
|
||
readdir: vi.fn(async () => []),
|
||
stat: vi.fn(async (path: string) => ({
|
||
isFile: () => mockFiles.has(path),
|
||
isDirectory: () => !mockFiles.has(path),
|
||
size: mockFiles.get(path)?.length || 0,
|
||
})),
|
||
access: vi.fn(async (path: string) => {
|
||
if (!mockFiles.has(path)) {
|
||
const error = new Error(`ENOENT`);
|
||
(error as any).code = 'ENOENT';
|
||
throw error;
|
||
}
|
||
}),
|
||
}));
|
||
|
||
// Mock uuid
|
||
vi.mock('uuid', () => ({
|
||
v4: vi.fn(() => 'mock-uuid-' + Math.random().toString(36).substr(2, 9)),
|
||
}));
|
||
|
||
describe('PostEngine', () => {
|
||
let postEngine: PostEngine;
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
mockPosts.clear();
|
||
mockFiles.clear();
|
||
mockExecuteArgs = [];
|
||
resetMockCounters();
|
||
|
||
// Reset the mock implementations
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain());
|
||
|
||
postEngine = new PostEngine();
|
||
});
|
||
|
||
describe('Constructor and Initialization', () => {
|
||
it('should create a PostEngine instance', () => {
|
||
expect(postEngine).toBeInstanceOf(PostEngine);
|
||
});
|
||
|
||
it('should extend EventEmitter', () => {
|
||
expect(typeof postEngine.on).toBe('function');
|
||
expect(typeof postEngine.emit).toBe('function');
|
||
});
|
||
|
||
it('should have default project context', () => {
|
||
expect(postEngine.getProjectContext()).toBe('default');
|
||
});
|
||
});
|
||
|
||
describe('Project Context', () => {
|
||
it('should set project context', () => {
|
||
postEngine.setProjectContext('my-blog');
|
||
expect(postEngine.getProjectContext()).toBe('my-blog');
|
||
});
|
||
|
||
it('should allow changing project context multiple times', () => {
|
||
postEngine.setProjectContext('blog-1');
|
||
expect(postEngine.getProjectContext()).toBe('blog-1');
|
||
|
||
postEngine.setProjectContext('blog-2');
|
||
expect(postEngine.getProjectContext()).toBe('blog-2');
|
||
});
|
||
});
|
||
|
||
describe('Slug Generation via createPost', () => {
|
||
it('should generate slug from title with lowercase', async () => {
|
||
const post = await postEngine.createPost({ title: 'Hello World' });
|
||
expect(post.slug).toBe('hello-world');
|
||
});
|
||
|
||
it('should replace special characters with hyphens', async () => {
|
||
const post = await postEngine.createPost({ title: 'Hello, World! How are you?' });
|
||
expect(post.slug).toBe('hello-world-how-are-you');
|
||
});
|
||
|
||
it('should remove leading and trailing hyphens', async () => {
|
||
const post = await postEngine.createPost({ title: '---Test---' });
|
||
expect(post.slug).toBe('test');
|
||
});
|
||
|
||
it('should handle numbers in titles', async () => {
|
||
const post = await postEngine.createPost({ title: '10 Tips for Testing' });
|
||
expect(post.slug).toBe('10-tips-for-testing');
|
||
});
|
||
|
||
it('should convert multiple spaces to single hyphen', async () => {
|
||
const post = await postEngine.createPost({ title: 'Multiple Spaces Here' });
|
||
expect(post.slug).toBe('multiple-spaces-here');
|
||
});
|
||
|
||
it('should handle unicode characters by removing them', async () => {
|
||
const post = await postEngine.createPost({ title: 'Café Test' });
|
||
expect(post.slug).toBe('caf-test');
|
||
});
|
||
});
|
||
|
||
describe('Post Creation', () => {
|
||
it('should create a post with default values', async () => {
|
||
const post = await postEngine.createPost({ title: 'My Test Post' });
|
||
|
||
expect(post.id).toBeDefined();
|
||
expect(post.title).toBe('My Test Post');
|
||
expect(post.slug).toBe('my-test-post');
|
||
expect(post.status).toBe('draft');
|
||
expect(post.content).toBe('');
|
||
expect(post.tags).toEqual([]);
|
||
expect(post.categories).toEqual([]);
|
||
});
|
||
|
||
it('should create a post with provided content', async () => {
|
||
const post = await postEngine.createPost({
|
||
title: 'Content Test',
|
||
content: '# Hello\n\nThis is my post.',
|
||
});
|
||
|
||
expect(post.content).toBe('# Hello\n\nThis is my post.');
|
||
});
|
||
|
||
it('should create a post with custom status', async () => {
|
||
const post = await postEngine.createPost({
|
||
title: 'Published Post',
|
||
status: 'published',
|
||
});
|
||
|
||
expect(post.status).toBe('published');
|
||
});
|
||
|
||
it('should create a post with tags and categories', async () => {
|
||
const post = await postEngine.createPost({
|
||
title: 'Tagged Post',
|
||
tags: ['javascript', 'testing'],
|
||
categories: ['tutorials'],
|
||
});
|
||
|
||
expect(post.tags).toEqual(['javascript', 'testing']);
|
||
expect(post.categories).toEqual(['tutorials']);
|
||
});
|
||
|
||
it('should use custom slug when provided', async () => {
|
||
const post = await postEngine.createPost({
|
||
title: 'My Post',
|
||
slug: 'custom-permalink',
|
||
});
|
||
|
||
expect(post.slug).toBe('custom-permalink');
|
||
});
|
||
|
||
it('should set createdAt and updatedAt timestamps', async () => {
|
||
const before = new Date();
|
||
const post = await postEngine.createPost({ title: 'Timestamp Test' });
|
||
const after = new Date();
|
||
|
||
expect(post.createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||
expect(post.createdAt.getTime()).toBeLessThanOrEqual(after.getTime());
|
||
expect(post.updatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||
expect(post.updatedAt.getTime()).toBeLessThanOrEqual(after.getTime());
|
||
});
|
||
|
||
it('should emit postCreated event', async () => {
|
||
const handler = vi.fn();
|
||
postEngine.on('postCreated', handler);
|
||
|
||
const post = await postEngine.createPost({ title: 'Event Test' });
|
||
|
||
expect(handler).toHaveBeenCalledTimes(1);
|
||
expect(handler).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
title: 'Event Test',
|
||
})
|
||
);
|
||
});
|
||
|
||
it('should use current project context for projectId', async () => {
|
||
postEngine.setProjectContext('my-project');
|
||
const post = await postEngine.createPost({ title: 'Project Test' });
|
||
|
||
expect(post.projectId).toBe('my-project');
|
||
});
|
||
|
||
it('should write post to filesystem', async () => {
|
||
const fs = await import('fs/promises');
|
||
await postEngine.createPost({ title: 'File Test' });
|
||
|
||
expect(fs.writeFile).toHaveBeenCalled();
|
||
expect(fs.mkdir).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should insert into database', async () => {
|
||
await postEngine.createPost({ title: 'DB Test' });
|
||
|
||
expect(mockLocalDb.insert).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should update FTS index when client is available', async () => {
|
||
await postEngine.createPost({ title: 'FTS Test' });
|
||
|
||
const ftsInsert = mockExecuteArgs.find((q) => q.sql.includes('posts_fts'));
|
||
expect(ftsInsert).toBeDefined();
|
||
});
|
||
|
||
it('should handle post without title using "Untitled"', async () => {
|
||
const post = await postEngine.createPost({});
|
||
expect(post.title).toBe('Untitled');
|
||
expect(post.slug).toBe('untitled');
|
||
});
|
||
|
||
it('should create post with author', async () => {
|
||
const post = await postEngine.createPost({
|
||
title: 'Author Test',
|
||
author: 'John Doe',
|
||
});
|
||
|
||
expect(post.author).toBe('John Doe');
|
||
});
|
||
|
||
it('should create post with excerpt', async () => {
|
||
const post = await postEngine.createPost({
|
||
title: 'Excerpt Test',
|
||
excerpt: 'This is a short summary.',
|
||
});
|
||
|
||
expect(post.excerpt).toBe('This is a short summary.');
|
||
});
|
||
|
||
it('should handle publishedAt for published posts', async () => {
|
||
const publishDate = new Date('2024-01-15');
|
||
const post = await postEngine.createPost({
|
||
title: 'Published Post',
|
||
status: 'published',
|
||
publishedAt: publishDate,
|
||
});
|
||
|
||
expect(post.publishedAt).toEqual(publishDate);
|
||
});
|
||
});
|
||
|
||
describe('Event Emission', () => {
|
||
it('should be an EventEmitter', () => {
|
||
expect(postEngine.on).toBeDefined();
|
||
expect(postEngine.emit).toBeDefined();
|
||
expect(postEngine.removeListener).toBeDefined();
|
||
});
|
||
|
||
it('should allow adding event listeners', () => {
|
||
const listener = vi.fn();
|
||
postEngine.on('testEvent', listener);
|
||
postEngine.emit('testEvent', { data: 'test' });
|
||
|
||
expect(listener).toHaveBeenCalledWith({ data: 'test' });
|
||
});
|
||
|
||
it('should allow removing event listeners', () => {
|
||
const listener = vi.fn();
|
||
postEngine.on('testEvent', listener);
|
||
postEngine.removeListener('testEvent', listener);
|
||
postEngine.emit('testEvent', { data: 'test' });
|
||
|
||
expect(listener).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('Post Creation writes correct file format', () => {
|
||
it('should write markdown file with YAML frontmatter', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
await postEngine.createPost({
|
||
title: 'Frontmatter Test',
|
||
content: '# Hello World',
|
||
tags: ['test'],
|
||
});
|
||
|
||
expect(fs.writeFile).toHaveBeenCalled();
|
||
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
|
||
const filePath = writeCall[0] as string;
|
||
const content = writeCall[1] as string;
|
||
|
||
expect(filePath).toContain('frontmatter-test.md');
|
||
expect(content).toContain('---');
|
||
expect(content).toContain('title: Frontmatter Test');
|
||
expect(content).toContain('# Hello World');
|
||
});
|
||
});
|
||
|
||
describe('Multiple post creation', () => {
|
||
it('should create multiple posts with unique IDs', async () => {
|
||
const post1 = await postEngine.createPost({ title: 'Post 1' });
|
||
const post2 = await postEngine.createPost({ title: 'Post 2' });
|
||
|
||
expect(post1.id).toBeDefined();
|
||
expect(post2.id).toBeDefined();
|
||
expect(post1.id).not.toBe(post2.id);
|
||
});
|
||
|
||
it('should create posts with different slugs', async () => {
|
||
const post1 = await postEngine.createPost({ title: 'First Post' });
|
||
const post2 = await postEngine.createPost({ title: 'Second Post' });
|
||
|
||
expect(post1.slug).toBe('first-post');
|
||
expect(post2.slug).toBe('second-post');
|
||
});
|
||
});
|
||
|
||
describe('Post status values', () => {
|
||
it('should accept draft status', async () => {
|
||
const post = await postEngine.createPost({ title: 'Draft', status: 'draft' });
|
||
expect(post.status).toBe('draft');
|
||
});
|
||
|
||
it('should accept published status', async () => {
|
||
const post = await postEngine.createPost({ title: 'Published', status: 'published' });
|
||
expect(post.status).toBe('published');
|
||
});
|
||
|
||
it('should accept archived status', async () => {
|
||
const post = await postEngine.createPost({ title: 'Archived', status: 'archived' });
|
||
expect(post.status).toBe('archived');
|
||
});
|
||
});
|
||
|
||
describe('Post with all fields', () => {
|
||
it('should create a fully populated post', async () => {
|
||
const publishDate = new Date('2024-06-15');
|
||
const post = await postEngine.createPost({
|
||
title: 'Complete Post',
|
||
slug: 'complete-post',
|
||
content: '# Complete\n\nFull content here.',
|
||
excerpt: 'A complete post with all fields.',
|
||
status: 'published',
|
||
author: 'Jane Doe',
|
||
publishedAt: publishDate,
|
||
tags: ['complete', 'full', 'test'],
|
||
categories: ['testing', 'examples'],
|
||
});
|
||
|
||
expect(post.title).toBe('Complete Post');
|
||
expect(post.slug).toBe('complete-post');
|
||
expect(post.content).toBe('# Complete\n\nFull content here.');
|
||
expect(post.excerpt).toBe('A complete post with all fields.');
|
||
expect(post.status).toBe('published');
|
||
expect(post.author).toBe('Jane Doe');
|
||
expect(post.publishedAt).toEqual(publishDate);
|
||
expect(post.tags).toEqual(['complete', 'full', 'test']);
|
||
expect(post.categories).toEqual(['testing', 'examples']);
|
||
});
|
||
});
|
||
|
||
describe('getPost', () => {
|
||
it('should return null for non-existent post', async () => {
|
||
const result = await postEngine.getPost('non-existent-id');
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('should retrieve post from database and file system', async () => {
|
||
// Create a post first
|
||
const created = await postEngine.createPost({
|
||
title: 'Retrievable Post',
|
||
content: 'Content for retrieval test',
|
||
});
|
||
|
||
const filePath = `/mock/userData/projects/default/posts/${created.slug}.md`;
|
||
|
||
// Store the file content so readFile can retrieve it
|
||
mockFiles.set(filePath, `---
|
||
id: ${created.id}
|
||
projectId: ${created.projectId}
|
||
title: Retrievable Post
|
||
slug: ${created.slug}
|
||
status: draft
|
||
createdAt: ${created.createdAt.toISOString()}
|
||
updatedAt: ${created.updatedAt.toISOString()}
|
||
tags: []
|
||
categories: []
|
||
---
|
||
Content for retrieval test`);
|
||
|
||
// Mock the select chain to find the post by ID
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: created.id,
|
||
projectId: created.projectId,
|
||
title: created.title,
|
||
slug: created.slug,
|
||
status: created.status,
|
||
filePath,
|
||
tags: '[]',
|
||
categories: '[]',
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getPost(created.id);
|
||
|
||
expect(result).not.toBeNull();
|
||
expect(result?.title).toBe('Retrievable Post');
|
||
expect(result?.content).toBe('Content for retrieval test');
|
||
});
|
||
|
||
it('should return database-only data when file not found', async () => {
|
||
// Mock database returning a post but file missing
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: 'db-only-id',
|
||
projectId: 'default',
|
||
title: 'DB Only Post',
|
||
slug: 'db-only-post',
|
||
status: 'draft',
|
||
filePath: '/mock/path/to/missing-file.md',
|
||
tags: '["test"]',
|
||
categories: '["category"]',
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getPost('db-only-id');
|
||
|
||
expect(result).not.toBeNull();
|
||
expect(result?.title).toBe('DB Only Post');
|
||
expect(result?.content).toBe(''); // Empty content when file not found
|
||
expect(result?.tags).toEqual(['test']);
|
||
expect(result?.categories).toEqual(['category']);
|
||
});
|
||
});
|
||
|
||
describe('updatePost', () => {
|
||
it('should return null when updating non-existent post', async () => {
|
||
const result = await postEngine.updatePost('non-existent-id', { title: 'New Title' });
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('should update post title', async () => {
|
||
// Create a post first
|
||
const created = await postEngine.createPost({ title: 'Original Title' });
|
||
|
||
// Mock getPost to return the created post
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: created.id,
|
||
projectId: created.projectId,
|
||
title: created.title,
|
||
slug: created.slug,
|
||
status: created.status,
|
||
filePath: `/mock/userData/projects/default/posts/${created.slug}.md`,
|
||
tags: '[]',
|
||
categories: '[]',
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.updatePost(created.id, { title: 'Updated Title' });
|
||
|
||
expect(result).not.toBeNull();
|
||
expect(result?.title).toBe('Updated Title');
|
||
});
|
||
|
||
it('should update updatedAt timestamp on update', async () => {
|
||
const created = await postEngine.createPost({ title: 'Timestamp Test' });
|
||
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: created.id,
|
||
projectId: created.projectId,
|
||
title: created.title,
|
||
slug: created.slug,
|
||
status: created.status,
|
||
filePath: `/mock/userData/projects/default/posts/${created.slug}.md`,
|
||
tags: '[]',
|
||
categories: '[]',
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const beforeUpdate = new Date();
|
||
const result = await postEngine.updatePost(created.id, { content: 'Changed' });
|
||
const afterUpdate = new Date();
|
||
|
||
expect(result?.updatedAt.getTime()).toBeGreaterThanOrEqual(beforeUpdate.getTime());
|
||
expect(result?.updatedAt.getTime()).toBeLessThanOrEqual(afterUpdate.getTime());
|
||
});
|
||
|
||
it('should emit postUpdated event', async () => {
|
||
const handler = vi.fn();
|
||
postEngine.on('postUpdated', handler);
|
||
|
||
const created = await postEngine.createPost({ title: 'Event Update Test' });
|
||
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: created.id,
|
||
projectId: created.projectId,
|
||
title: created.title,
|
||
slug: created.slug,
|
||
status: created.status,
|
||
filePath: `/mock/userData/projects/default/posts/${created.slug}.md`,
|
||
tags: '[]',
|
||
categories: '[]',
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
await postEngine.updatePost(created.id, { title: 'Updated Event Test' });
|
||
|
||
expect(handler).toHaveBeenCalledWith(
|
||
expect.objectContaining({ title: 'Updated Event Test' })
|
||
);
|
||
});
|
||
|
||
it('should handle slug change by deleting old file', async () => {
|
||
const fs = await import('fs/promises');
|
||
const created = await postEngine.createPost({ title: 'Slug Change Test' });
|
||
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: created.id,
|
||
projectId: created.projectId,
|
||
title: created.title,
|
||
slug: created.slug,
|
||
status: created.status,
|
||
filePath: `/mock/userData/projects/default/posts/${created.slug}.md`,
|
||
tags: '[]',
|
||
categories: '[]',
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
vi.mocked(fs.unlink).mockClear();
|
||
await postEngine.updatePost(created.id, { slug: 'new-slug' });
|
||
|
||
// Should try to delete old file
|
||
expect(fs.unlink).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should update tags and categories', async () => {
|
||
const created = await postEngine.createPost({
|
||
title: 'Tag Update Test',
|
||
tags: ['old-tag'],
|
||
categories: ['old-category'],
|
||
});
|
||
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: created.id,
|
||
projectId: created.projectId,
|
||
title: created.title,
|
||
slug: created.slug,
|
||
status: created.status,
|
||
filePath: `/mock/userData/projects/default/posts/${created.slug}.md`,
|
||
tags: JSON.stringify(created.tags),
|
||
categories: JSON.stringify(created.categories),
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.updatePost(created.id, {
|
||
tags: ['new-tag-1', 'new-tag-2'],
|
||
categories: ['new-category'],
|
||
});
|
||
|
||
expect(result?.tags).toEqual(['new-tag-1', 'new-tag-2']);
|
||
expect(result?.categories).toEqual(['new-category']);
|
||
});
|
||
|
||
it('should preserve original projectId and id', async () => {
|
||
postEngine.setProjectContext('original-project');
|
||
const created = await postEngine.createPost({ title: 'Protect IDs Test' });
|
||
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: created.id,
|
||
projectId: 'original-project',
|
||
title: created.title,
|
||
slug: created.slug,
|
||
status: created.status,
|
||
filePath: `/mock/userData/projects/original-project/posts/${created.slug}.md`,
|
||
tags: '[]',
|
||
categories: '[]',
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.updatePost(created.id, {
|
||
projectId: 'hacked-project' as any,
|
||
id: 'hacked-id' as any,
|
||
title: 'Safe Update',
|
||
});
|
||
|
||
expect(result?.id).toBe(created.id); // ID preserved
|
||
expect(result?.projectId).toBe('original-project'); // projectId preserved
|
||
});
|
||
});
|
||
|
||
describe('deletePost', () => {
|
||
it('should return false when deleting non-existent post', async () => {
|
||
const result = await postEngine.deletePost('non-existent-id');
|
||
expect(result).toBe(false);
|
||
});
|
||
|
||
it('should delete post and return true', async () => {
|
||
const created = await postEngine.createPost({ title: 'Delete Me' });
|
||
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: created.id,
|
||
filePath: `/mock/userData/projects/default/posts/${created.slug}.md`,
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.deletePost(created.id);
|
||
|
||
expect(result).toBe(true);
|
||
});
|
||
|
||
it('should emit postDeleted event', async () => {
|
||
const handler = vi.fn();
|
||
postEngine.on('postDeleted', handler);
|
||
|
||
const created = await postEngine.createPost({ title: 'Delete Event Test' });
|
||
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: created.id,
|
||
filePath: `/mock/userData/projects/default/posts/${created.slug}.md`,
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
await postEngine.deletePost(created.id);
|
||
|
||
expect(handler).toHaveBeenCalledWith(created.id);
|
||
});
|
||
|
||
it('should delete file from filesystem', async () => {
|
||
const fs = await import('fs/promises');
|
||
const created = await postEngine.createPost({ title: 'File Delete Test' });
|
||
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: created.id,
|
||
filePath: `/mock/userData/projects/default/posts/${created.slug}.md`,
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
vi.mocked(fs.unlink).mockClear();
|
||
await postEngine.deletePost(created.id);
|
||
|
||
expect(fs.unlink).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should delete from database', async () => {
|
||
const created = await postEngine.createPost({ title: 'DB Delete Test' });
|
||
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: created.id,
|
||
filePath: `/mock/userData/projects/default/posts/${created.slug}.md`,
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
await postEngine.deletePost(created.id);
|
||
|
||
expect(mockLocalDb.delete).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should delete from FTS index', async () => {
|
||
const created = await postEngine.createPost({ title: 'FTS Delete Test' });
|
||
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: created.id,
|
||
filePath: `/mock/userData/projects/default/posts/${created.slug}.md`,
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
mockExecuteArgs = [];
|
||
await postEngine.deletePost(created.id);
|
||
|
||
const ftsDelete = mockExecuteArgs.find((q) =>
|
||
q.sql.includes('DELETE FROM posts_fts')
|
||
);
|
||
expect(ftsDelete).toBeDefined();
|
||
});
|
||
|
||
it('should handle file deletion error gracefully', async () => {
|
||
const fs = await import('fs/promises');
|
||
const created = await postEngine.createPost({ title: 'Error Delete Test' });
|
||
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: created.id,
|
||
filePath: `/mock/userData/projects/default/posts/missing.md`,
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
vi.mocked(fs.unlink).mockRejectedValueOnce(new Error('ENOENT'));
|
||
|
||
// Should not throw
|
||
const result = await postEngine.deletePost(created.id);
|
||
expect(result).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('Metadata roundtrip (write -> read integrity)', () => {
|
||
it('should preserve all fields through write and read cycle', async () => {
|
||
const fs = await import('fs/promises');
|
||
const publishDate = new Date('2024-03-15T10:30:00.000Z');
|
||
|
||
const original = await postEngine.createPost({
|
||
title: 'Roundtrip Test Post',
|
||
slug: 'roundtrip-test',
|
||
content: '# Roundtrip\n\nTesting data integrity.',
|
||
excerpt: 'Testing the roundtrip',
|
||
status: 'published',
|
||
author: 'Test Author',
|
||
publishedAt: publishDate,
|
||
tags: ['roundtrip', 'integrity', 'test'],
|
||
categories: ['testing'],
|
||
});
|
||
|
||
// Get the written file content
|
||
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
|
||
(call) => (call[0] as string).includes('roundtrip-test.md')
|
||
);
|
||
expect(writeCall).toBeDefined();
|
||
const fileContent = writeCall![1] as string;
|
||
|
||
// Verify frontmatter contains all fields
|
||
expect(fileContent).toContain('title: Roundtrip Test Post');
|
||
expect(fileContent).toContain('slug: roundtrip-test');
|
||
expect(fileContent).toContain('status: published');
|
||
expect(fileContent).toContain('author: Test Author');
|
||
expect(fileContent).toContain('excerpt: Testing the roundtrip');
|
||
expect(fileContent).toContain('publishedAt:');
|
||
expect(fileContent).toContain('- roundtrip');
|
||
expect(fileContent).toContain('- integrity');
|
||
expect(fileContent).toContain('- test');
|
||
expect(fileContent).toContain('- testing');
|
||
expect(fileContent).toContain('# Roundtrip');
|
||
});
|
||
|
||
it('should handle empty tags and categories', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
await postEngine.createPost({
|
||
title: 'No Tags Post',
|
||
content: 'Content without tags',
|
||
tags: [],
|
||
categories: [],
|
||
});
|
||
|
||
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
|
||
(call) => (call[0] as string).includes('no-tags-post.md')
|
||
);
|
||
const fileContent = writeCall![1] as string;
|
||
|
||
expect(fileContent).toContain('tags: []');
|
||
expect(fileContent).toContain('categories: []');
|
||
});
|
||
|
||
it('should not include undefined optional fields in frontmatter', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
await postEngine.createPost({
|
||
title: 'Minimal Post',
|
||
content: 'Just content',
|
||
});
|
||
|
||
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
|
||
(call) => (call[0] as string).includes('minimal-post.md')
|
||
);
|
||
const fileContent = writeCall![1] as string;
|
||
|
||
// These optional fields should NOT appear if not set
|
||
expect(fileContent).not.toContain('excerpt:');
|
||
expect(fileContent).not.toContain('author:');
|
||
expect(fileContent).not.toContain('publishedAt:');
|
||
});
|
||
});
|
||
|
||
describe('Edge cases - special characters and unicode', () => {
|
||
it('should handle unicode characters in content', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
const post = await postEngine.createPost({
|
||
title: 'Unicode Test',
|
||
content: '# 你好世界\n\nЗдравствуй мир\n\nこんにちは\n\nEmoji: 🚀💻📝',
|
||
});
|
||
|
||
expect(post.content).toContain('你好世界');
|
||
expect(post.content).toContain('Здравствуй');
|
||
expect(post.content).toContain('こんにちは');
|
||
expect(post.content).toContain('🚀💻📝');
|
||
|
||
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
|
||
(call) => (call[0] as string).includes('unicode-test.md')
|
||
);
|
||
const fileContent = writeCall![1] as string;
|
||
|
||
expect(fileContent).toContain('你好世界');
|
||
expect(fileContent).toContain('🚀💻📝');
|
||
});
|
||
|
||
it('should handle special characters in title', async () => {
|
||
const post = await postEngine.createPost({
|
||
title: 'Post: With Special "Characters" & <Symbols>!',
|
||
});
|
||
|
||
// Slug should be sanitized
|
||
expect(post.slug).toBe('post-with-special-characters-symbols');
|
||
// Title should be preserved
|
||
expect(post.title).toBe('Post: With Special "Characters" & <Symbols>!');
|
||
});
|
||
|
||
it('should handle YAML special characters in excerpt', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
await postEngine.createPost({
|
||
title: 'YAML Safe Test',
|
||
excerpt: 'Contains: colons, "quotes", and #hash',
|
||
});
|
||
|
||
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
|
||
(call) => (call[0] as string).includes('yaml-safe-test.md')
|
||
);
|
||
const fileContent = writeCall![1] as string;
|
||
|
||
// gray-matter should properly escape these
|
||
expect(fileContent).toContain('excerpt:');
|
||
});
|
||
|
||
it('should handle multiline content correctly', async () => {
|
||
const fs = await import('fs/promises');
|
||
const multilineContent = `# Heading 1
|
||
|
||
Some paragraph text.
|
||
|
||
## Heading 2
|
||
|
||
- List item 1
|
||
- List item 2
|
||
|
||
\`\`\`javascript
|
||
const code = 'example';
|
||
\`\`\`
|
||
|
||
> A blockquote`;
|
||
|
||
await postEngine.createPost({
|
||
title: 'Multiline Test',
|
||
content: multilineContent,
|
||
});
|
||
|
||
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
|
||
(call) => (call[0] as string).includes('multiline-test.md')
|
||
);
|
||
const fileContent = writeCall![1] as string;
|
||
|
||
expect(fileContent).toContain('# Heading 1');
|
||
expect(fileContent).toContain('## Heading 2');
|
||
expect(fileContent).toContain('```javascript');
|
||
expect(fileContent).toContain('> A blockquote');
|
||
});
|
||
|
||
it('should handle empty content', async () => {
|
||
const post = await postEngine.createPost({
|
||
title: 'Empty Content Post',
|
||
content: '',
|
||
});
|
||
|
||
expect(post.content).toBe('');
|
||
});
|
||
|
||
it('should handle very long titles', async () => {
|
||
const longTitle = 'A'.repeat(500);
|
||
const post = await postEngine.createPost({ title: longTitle });
|
||
|
||
expect(post.title.length).toBe(500);
|
||
expect(post.slug.length).toBe(500); // slug is all lowercase a's
|
||
});
|
||
|
||
it('should handle newlines in excerpt', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
await postEngine.createPost({
|
||
title: 'Newline Excerpt',
|
||
excerpt: 'First line.\nSecond line.',
|
||
});
|
||
|
||
// Should be written without breaking YAML
|
||
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
|
||
(call) => (call[0] as string).includes('newline-excerpt.md')
|
||
);
|
||
expect(writeCall).toBeDefined();
|
||
});
|
||
});
|
||
|
||
describe('Checksum calculation', () => {
|
||
it('should calculate consistent checksum for same content', async () => {
|
||
const post1 = await postEngine.createPost({
|
||
title: 'Checksum Test 1',
|
||
content: 'Identical content for testing',
|
||
});
|
||
|
||
const post2 = await postEngine.createPost({
|
||
title: 'Checksum Test 2',
|
||
content: 'Identical content for testing',
|
||
});
|
||
|
||
// Both inserts should have been called with same checksum
|
||
const insertCalls = vi.mocked(mockLocalDb.insert).mock.results;
|
||
expect(insertCalls.length).toBeGreaterThanOrEqual(2);
|
||
});
|
||
|
||
it('should calculate different checksum for different content', async () => {
|
||
const insertValues: any[] = [];
|
||
|
||
// Capture insert values
|
||
vi.mocked(mockLocalDb.insert).mockImplementation(() => ({
|
||
values: vi.fn((data: any) => {
|
||
insertValues.push(data);
|
||
if (data && data.id) {
|
||
mockPosts.set(data.id, data);
|
||
}
|
||
return Promise.resolve();
|
||
}),
|
||
}));
|
||
|
||
await postEngine.createPost({
|
||
title: 'Different 1',
|
||
content: 'Content A',
|
||
});
|
||
|
||
await postEngine.createPost({
|
||
title: 'Different 2',
|
||
content: 'Content B',
|
||
});
|
||
|
||
const checksums = insertValues.map(v => v.checksum);
|
||
expect(checksums[0]).not.toBe(checksums[1]);
|
||
});
|
||
});
|
||
|
||
describe('rebuildDatabaseFromFiles', () => {
|
||
it('should scan posts directory for markdown files', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
vi.mocked(fs.readdir).mockResolvedValueOnce(['post1.md', 'post2.md', 'other.txt'] as any);
|
||
|
||
// Mock readFile to return valid post content
|
||
vi.mocked(fs.readFile).mockImplementation(async (filePath: any) => {
|
||
if (filePath.includes('post1.md')) {
|
||
return `---
|
||
id: post-1-id
|
||
projectId: default
|
||
title: Post 1
|
||
slug: post1
|
||
status: draft
|
||
createdAt: 2024-01-01T00:00:00.000Z
|
||
updatedAt: 2024-01-01T00:00:00.000Z
|
||
tags: []
|
||
categories: []
|
||
---
|
||
Content 1`;
|
||
}
|
||
if (filePath.includes('post2.md')) {
|
||
return `---
|
||
id: post-2-id
|
||
projectId: default
|
||
title: Post 2
|
||
slug: post2
|
||
status: published
|
||
createdAt: 2024-01-02T00:00:00.000Z
|
||
updatedAt: 2024-01-02T00:00:00.000Z
|
||
tags: []
|
||
categories: []
|
||
---
|
||
Content 2`;
|
||
}
|
||
throw new Error('ENOENT');
|
||
});
|
||
|
||
await postEngine.rebuildDatabaseFromFiles();
|
||
|
||
// Should have processed only .md files
|
||
expect(mockLocalDb.insert).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should emit databaseRebuilt event on completion', async () => {
|
||
const fs = await import('fs/promises');
|
||
const handler = vi.fn();
|
||
postEngine.on('databaseRebuilt', handler);
|
||
|
||
vi.mocked(fs.readdir).mockResolvedValueOnce([]);
|
||
|
||
await postEngine.rebuildDatabaseFromFiles();
|
||
|
||
expect(handler).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should handle empty posts directory', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
vi.mocked(fs.readdir).mockResolvedValueOnce([]);
|
||
|
||
await postEngine.rebuildDatabaseFromFiles();
|
||
|
||
// Should complete without errors
|
||
expect(mockLocalDb.insert).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should create posts directory if it does not exist', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
vi.mocked(fs.readdir).mockRejectedValueOnce(new Error('ENOENT'));
|
||
|
||
await postEngine.rebuildDatabaseFromFiles();
|
||
|
||
expect(fs.mkdir).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should update existing posts in database', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
vi.mocked(fs.readdir).mockResolvedValueOnce(['existing.md'] as any);
|
||
|
||
vi.mocked(fs.readFile).mockResolvedValueOnce(`---
|
||
id: existing-id
|
||
projectId: default
|
||
title: Existing Post
|
||
slug: existing
|
||
status: draft
|
||
createdAt: 2024-01-01T00:00:00.000Z
|
||
updatedAt: 2024-01-01T00:00:00.000Z
|
||
tags: []
|
||
categories: []
|
||
---
|
||
Updated content`);
|
||
|
||
// Mock that post exists in database
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: 'existing-id',
|
||
title: 'Old Title',
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
await postEngine.rebuildDatabaseFromFiles();
|
||
|
||
expect(mockLocalDb.update).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should insert new posts not in database', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
vi.mocked(fs.readdir).mockResolvedValueOnce(['new-post.md'] as any);
|
||
|
||
vi.mocked(fs.readFile).mockResolvedValueOnce(`---
|
||
id: new-post-id
|
||
projectId: default
|
||
title: New Post
|
||
slug: new-post
|
||
status: draft
|
||
createdAt: 2024-01-01T00:00:00.000Z
|
||
updatedAt: 2024-01-01T00:00:00.000Z
|
||
tags: []
|
||
categories: []
|
||
---
|
||
New content`);
|
||
|
||
// Mock that post doesn't exist in database
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue(null),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
await postEngine.rebuildDatabaseFromFiles();
|
||
|
||
expect(mockLocalDb.insert).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should update FTS index for each processed post', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
vi.mocked(fs.readdir).mockResolvedValueOnce(['fts-test.md'] as any);
|
||
|
||
vi.mocked(fs.readFile).mockResolvedValueOnce(`---
|
||
id: fts-test-id
|
||
projectId: default
|
||
title: FTS Test
|
||
slug: fts-test
|
||
status: draft
|
||
createdAt: 2024-01-01T00:00:00.000Z
|
||
updatedAt: 2024-01-01T00:00:00.000Z
|
||
tags:
|
||
- search
|
||
- test
|
||
categories: []
|
||
---
|
||
Searchable content`);
|
||
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue(null),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
mockExecuteArgs = [];
|
||
await postEngine.rebuildDatabaseFromFiles();
|
||
|
||
const ftsInsert = mockExecuteArgs.find((q) =>
|
||
q.sql.includes('INSERT INTO posts_fts')
|
||
);
|
||
expect(ftsInsert).toBeDefined();
|
||
});
|
||
|
||
it('should skip invalid/corrupted post files', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
vi.mocked(fs.readdir).mockResolvedValueOnce(['valid.md', 'corrupted.md'] as any);
|
||
|
||
vi.mocked(fs.readFile).mockImplementation(async (filePath: any) => {
|
||
if (filePath.includes('valid.md')) {
|
||
return `---
|
||
id: valid-id
|
||
projectId: default
|
||
title: Valid Post
|
||
slug: valid
|
||
status: draft
|
||
createdAt: 2024-01-01T00:00:00.000Z
|
||
updatedAt: 2024-01-01T00:00:00.000Z
|
||
tags: []
|
||
categories: []
|
||
---
|
||
Valid content`;
|
||
}
|
||
if (filePath.includes('corrupted.md')) {
|
||
return 'This is not valid YAML frontmatter';
|
||
}
|
||
throw new Error('ENOENT');
|
||
});
|
||
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue(null),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
// Should not throw
|
||
await postEngine.rebuildDatabaseFromFiles();
|
||
});
|
||
});
|
||
|
||
describe('Date-based folder structure', () => {
|
||
it('should store posts in YYYY/MM folder based on createdAt date', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
const post = await postEngine.createPost({
|
||
title: 'Date Folder Test',
|
||
content: 'Testing date-based folder structure',
|
||
});
|
||
|
||
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
|
||
(call) => (call[0] as string).includes('date-folder-test.md')
|
||
);
|
||
expect(writeCall).toBeDefined();
|
||
|
||
const filePath = writeCall![0] as string;
|
||
const year = post.createdAt.getFullYear();
|
||
const month = (post.createdAt.getMonth() + 1).toString().padStart(2, '0');
|
||
|
||
// Path should contain YYYY/MM structure (handle both / and \ separators)
|
||
expect(filePath).toMatch(new RegExp(`[/\\\\]${year}[/\\\\]${month}[/\\\\]`));
|
||
expect(filePath).toContain('date-folder-test.md');
|
||
});
|
||
|
||
it('should generate correct path for posts in different months', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
// Create a post - the current date will be used
|
||
await postEngine.createPost({
|
||
title: 'Current Month Post',
|
||
content: 'This post should go in current month folder',
|
||
});
|
||
|
||
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
|
||
(call) => (call[0] as string).includes('current-month-post.md')
|
||
);
|
||
|
||
const filePath = writeCall![0] as string;
|
||
|
||
// Should have year/month in the path (handle both / and \ separators)
|
||
expect(filePath).toMatch(/[/\\]\d{4}[/\\]\d{2}[/\\]/);
|
||
});
|
||
|
||
it('should use zero-padded month numbers (01-12)', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
await postEngine.createPost({
|
||
title: 'Zero Padded Month Test',
|
||
});
|
||
|
||
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
|
||
(call) => (call[0] as string).includes('zero-padded-month-test.md')
|
||
);
|
||
|
||
const filePath = writeCall![0] as string;
|
||
|
||
// Month should be zero-padded (01, 02, ..., 09, 10, 11, 12)
|
||
expect(filePath).toMatch(/[/\\]\d{4}[/\\](?:0[1-9]|1[0-2])[/\\]/);
|
||
});
|
||
|
||
it('should create nested year/month directories on post creation', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
await postEngine.createPost({
|
||
title: 'Nested Dirs Test',
|
||
});
|
||
|
||
// mkdir should be called with recursive: true
|
||
expect(fs.mkdir).toHaveBeenCalled();
|
||
const mkdirCalls = vi.mocked(fs.mkdir).mock.calls;
|
||
|
||
// Should have created directory containing year/month structure
|
||
const yearMonthDirCall = mkdirCalls.find((call) => {
|
||
const dirPath = call[0] as string;
|
||
return dirPath.match(/[/\\]\d{4}[/\\]\d{2}$/);
|
||
});
|
||
expect(yearMonthDirCall).toBeDefined();
|
||
});
|
||
|
||
it('should return correct path via getPostPath method', async () => {
|
||
const now = new Date();
|
||
const year = now.getFullYear();
|
||
const month = (now.getMonth() + 1).toString().padStart(2, '0');
|
||
|
||
// getPostPath should return the date-based path
|
||
const postPath = postEngine.getPostPath('my-test-slug', now);
|
||
|
||
// Handle both Windows (\) and Unix (/) path separators
|
||
expect(postPath).toMatch(new RegExp(`[/\\\\]${year}[/\\\\]${month}[/\\\\]`));
|
||
expect(postPath).toContain('my-test-slug.md');
|
||
});
|
||
|
||
it('should handle posts from previous years correctly', async () => {
|
||
const oldDate = new Date('2021-03-15');
|
||
const postPath = postEngine.getPostPath('old-post', oldDate);
|
||
|
||
expect(postPath).toMatch(/[/\\]2021[/\\]03[/\\]/);
|
||
expect(postPath).toContain('old-post.md');
|
||
});
|
||
|
||
it('should handle December correctly (month 12)', async () => {
|
||
const december = new Date('2024-12-25');
|
||
const postPath = postEngine.getPostPath('december-post', december);
|
||
|
||
expect(postPath).toMatch(/[/\\]2024[/\\]12[/\\]/);
|
||
});
|
||
|
||
it('should handle January correctly (month 01)', async () => {
|
||
const january = new Date('2024-01-01');
|
||
const postPath = postEngine.getPostPath('january-post', january);
|
||
|
||
expect(postPath).toMatch(/[/\\]2024[/\\]01[/\\]/);
|
||
});
|
||
});
|
||
});
|