2961 lines
95 KiB
TypeScript
2961 lines
95 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';
|
||
import * as fs from 'fs/promises';
|
||
|
||
// 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 - implementations MUST be inline in vi.mock due to hoisting
|
||
vi.mock('fs/promises', () => {
|
||
// These implementations use a global mockFiles Map from the test
|
||
const getMockFiles = () => (globalThis as any).__mockFiles || new Map();
|
||
|
||
return {
|
||
readFile: vi.fn(async (path: string) => {
|
||
const mockFiles = getMockFiles();
|
||
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) => {
|
||
getMockFiles().set(path, content);
|
||
}),
|
||
unlink: vi.fn(async (path: string) => {
|
||
getMockFiles().delete(path);
|
||
}),
|
||
mkdir: vi.fn(async () => {}),
|
||
readdir: vi.fn(async () => []),
|
||
stat: vi.fn(async (path: string) => {
|
||
const mockFiles = getMockFiles();
|
||
return {
|
||
isFile: () => mockFiles.has(path),
|
||
isDirectory: () => !mockFiles.has(path),
|
||
size: mockFiles.get(path)?.length || 0,
|
||
};
|
||
}),
|
||
access: vi.fn(async (path: string) => {
|
||
const mockFiles = getMockFiles();
|
||
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)),
|
||
}));
|
||
|
||
// Helper functions to reset fs mocks to default implementations
|
||
function createDefaultFsReadFile(mockFilesRef: Map<string, string>) {
|
||
return async (path: string) => {
|
||
const content = mockFilesRef.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;
|
||
};
|
||
}
|
||
|
||
function createDefaultFsAccess(mockFilesRef: Map<string, string>) {
|
||
return async (path: string) => {
|
||
if (!mockFilesRef.has(path)) {
|
||
const error = new Error(`ENOENT`);
|
||
(error as any).code = 'ENOENT';
|
||
throw error;
|
||
}
|
||
};
|
||
}
|
||
|
||
describe('PostEngine', () => {
|
||
let postEngine: PostEngine;
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
mockPosts.clear();
|
||
mockFiles.clear();
|
||
mockExecuteArgs = [];
|
||
resetMockCounters();
|
||
|
||
// Sync mockFiles with globalThis for the mocked fs module
|
||
(globalThis as any).__mockFiles = mockFiles;
|
||
|
||
// Reset the mock implementations
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain());
|
||
|
||
// Reset fs implementations to use mockFiles map (fixes test leakage from other tests)
|
||
vi.mocked(fs.readFile).mockImplementation(createDefaultFsReadFile(mockFiles) as any);
|
||
vi.mocked(fs.access).mockImplementation(createDefaultFsAccess(mockFiles) as any);
|
||
|
||
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 NOT write to filesystem (draft content stays in DB)', async () => {
|
||
const fs = await import('fs/promises');
|
||
vi.mocked(fs.writeFile).mockClear();
|
||
await postEngine.createPost({ title: 'File Test' });
|
||
|
||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should store content in database with empty filePath', async () => {
|
||
const insertValues: any[] = [];
|
||
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: 'DB Content Test',
|
||
content: '# Hello World',
|
||
});
|
||
|
||
const postInsert = insertValues.find(v => v.title === 'DB Content Test');
|
||
expect(postInsert).toBeDefined();
|
||
expect(postInsert.content).toBe('# Hello World');
|
||
expect(postInsert.filePath).toBe('');
|
||
});
|
||
|
||
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 empty string', async () => {
|
||
const post = await postEngine.createPost({});
|
||
expect(post.title).toBe('');
|
||
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 stores content in database only', () => {
|
||
it('should store draft content and metadata in database, not filesystem', async () => {
|
||
const fs = await import('fs/promises');
|
||
vi.mocked(fs.writeFile).mockClear();
|
||
|
||
const insertValues: any[] = [];
|
||
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: 'DB Store Test',
|
||
content: '# Hello World',
|
||
tags: ['test'],
|
||
});
|
||
|
||
// No file written for drafts
|
||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||
|
||
// Content saved to DB
|
||
const postInsert = insertValues.find(v => v.title === 'DB Store Test');
|
||
expect(postInsert).toBeDefined();
|
||
expect(postInsert.content).toBe('# Hello World');
|
||
expect(postInsert.filePath).toBe('');
|
||
expect(postInsert.tags).toBe('["test"]');
|
||
});
|
||
});
|
||
|
||
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 NOT touch filesystem on slug change (handled at publish time)', 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,
|
||
content: created.content || '',
|
||
filePath: '',
|
||
tags: '[]',
|
||
categories: '[]',
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
vi.mocked(fs.unlink).mockClear();
|
||
vi.mocked(fs.writeFile).mockClear();
|
||
await postEngine.updatePost(created.id, { slug: 'new-slug' });
|
||
|
||
// No file operations on update — filesystem is only touched on publish
|
||
expect(fs.unlink).not.toHaveBeenCalled();
|
||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should auto-transition published post to draft when content changes', async () => {
|
||
const created = await postEngine.createPost({ title: 'Auto Draft 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: 'published',
|
||
content: null,
|
||
filePath: '/mock/published-file.md',
|
||
tags: '[]',
|
||
categories: '[]',
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
// Mock file read for published content
|
||
mockFiles.set('/mock/published-file.md', `---
|
||
id: ${created.id}
|
||
projectId: default
|
||
title: ${created.title}
|
||
slug: ${created.slug}
|
||
status: published
|
||
createdAt: ${created.createdAt.toISOString()}
|
||
updatedAt: ${created.updatedAt.toISOString()}
|
||
tags: []
|
||
categories: []
|
||
---
|
||
Original content`);
|
||
|
||
const result = await postEngine.updatePost(created.id, { content: 'New draft content' });
|
||
|
||
expect(result).not.toBeNull();
|
||
expect(result?.status).toBe('draft');
|
||
expect(result?.content).toBe('New draft content');
|
||
});
|
||
|
||
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
|
||
});
|
||
|
||
it('should auto-update slug when title changes on a never-published draft', async () => {
|
||
const created = await postEngine.createPost({ title: 'Original Title' });
|
||
expect(created.slug).toBe('original-title');
|
||
|
||
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: 'draft',
|
||
content: created.content || '',
|
||
filePath: '',
|
||
tags: '[]',
|
||
categories: '[]',
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
publishedAt: null,
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.updatePost(created.id, { title: 'New Title' });
|
||
|
||
expect(result).not.toBeNull();
|
||
expect(result?.slug).toBe('new-title');
|
||
});
|
||
|
||
it('should NOT auto-update slug when title changes on a previously published post', async () => {
|
||
const created = await postEngine.createPost({ title: 'Published 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: 'draft',
|
||
content: created.content || '',
|
||
filePath: '',
|
||
tags: '[]',
|
||
categories: '[]',
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
publishedAt: new Date('2025-01-01'),
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.updatePost(created.id, { title: 'Changed Title' });
|
||
|
||
expect(result).not.toBeNull();
|
||
expect(result?.slug).toBe('published-post'); // slug preserved
|
||
});
|
||
|
||
it('should allow empty title and use untitled as slug base', async () => {
|
||
const created = await postEngine.createPost({ title: '' });
|
||
expect(created.title).toBe('');
|
||
expect(created.slug).toBe('untitled');
|
||
});
|
||
});
|
||
|
||
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 (create -> DB storage integrity)', () => {
|
||
it('should preserve all fields when storing to database', async () => {
|
||
const insertValues: any[] = [];
|
||
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();
|
||
}),
|
||
}));
|
||
|
||
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'],
|
||
});
|
||
|
||
// Verify data was stored in DB correctly
|
||
const postInsert = insertValues.find(v => v.slug === 'roundtrip-test');
|
||
expect(postInsert).toBeDefined();
|
||
expect(postInsert.title).toBe('Roundtrip Test Post');
|
||
expect(postInsert.content).toBe('# Roundtrip\n\nTesting data integrity.');
|
||
expect(postInsert.excerpt).toBe('Testing the roundtrip');
|
||
expect(postInsert.author).toBe('Test Author');
|
||
expect(postInsert.tags).toBe('["roundtrip","integrity","test"]');
|
||
expect(postInsert.categories).toBe('["testing"]');
|
||
expect(postInsert.filePath).toBe('');
|
||
});
|
||
|
||
it('should handle empty tags and categories in DB', async () => {
|
||
const insertValues: any[] = [];
|
||
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: 'No Tags Post',
|
||
content: 'Content without tags',
|
||
tags: [],
|
||
categories: [],
|
||
});
|
||
|
||
const postInsert = insertValues.find(v => v.title === 'No Tags Post');
|
||
expect(postInsert.tags).toBe('[]');
|
||
expect(postInsert.categories).toBe('[]');
|
||
});
|
||
|
||
it('should handle optional fields as undefined', async () => {
|
||
const post = await postEngine.createPost({
|
||
title: 'Minimal Post',
|
||
content: 'Just content',
|
||
});
|
||
|
||
// Optional fields should be undefined
|
||
expect(post.excerpt).toBeUndefined();
|
||
expect(post.author).toBeUndefined();
|
||
expect(post.publishedAt).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe('Edge cases - special characters and unicode', () => {
|
||
it('should handle unicode characters in content', async () => {
|
||
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('🚀💻📝');
|
||
});
|
||
|
||
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 post = await postEngine.createPost({
|
||
title: 'YAML Safe Test',
|
||
excerpt: 'Contains: colons, "quotes", and #hash',
|
||
});
|
||
|
||
expect(post.excerpt).toBe('Contains: colons, "quotes", and #hash');
|
||
});
|
||
|
||
it('should handle multiline content correctly', async () => {
|
||
const multilineContent = `# Heading 1
|
||
|
||
Some paragraph text.
|
||
|
||
## Heading 2
|
||
|
||
- List item 1
|
||
- List item 2
|
||
|
||
\`\`\`javascript
|
||
const code = 'example';
|
||
\`\`\`
|
||
|
||
> A blockquote`;
|
||
|
||
const post = await postEngine.createPost({
|
||
title: 'Multiline Test',
|
||
content: multilineContent,
|
||
});
|
||
|
||
expect(post.content).toContain('# Heading 1');
|
||
expect(post.content).toContain('## Heading 2');
|
||
expect(post.content).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 post = await postEngine.createPost({
|
||
title: 'Newline Excerpt',
|
||
excerpt: 'First line.\nSecond line.',
|
||
});
|
||
|
||
expect(post.excerpt).toBe('First line.\nSecond line.');
|
||
});
|
||
});
|
||
|
||
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', () => {
|
||
// Helper for Dirent-like objects (readdir with withFileTypes)
|
||
const mockDirent = (name: string, isDir = false) => ({
|
||
name,
|
||
isDirectory: () => isDir,
|
||
});
|
||
|
||
it('should scan posts directory for markdown files recursively', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
vi.mocked(fs.readdir).mockResolvedValueOnce([
|
||
mockDirent('post1.md'),
|
||
mockDirent('post2.md'),
|
||
mockDirent('other.txt'),
|
||
] as any);
|
||
|
||
// Mock access to allow file reads
|
||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||
|
||
// 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: published
|
||
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 include .markdown files during rebuild', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
vi.mocked(fs.readdir).mockResolvedValueOnce([
|
||
mockDirent('legacy-post.markdown'),
|
||
] as any);
|
||
|
||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||
vi.mocked(fs.readFile).mockResolvedValueOnce(`---
|
||
id: legacy-post-id
|
||
projectId: default
|
||
title: Legacy Post
|
||
slug: legacy-post
|
||
status: published
|
||
createdAt: 2024-01-01T00:00:00.000Z
|
||
updatedAt: 2024-01-01T00:00:00.000Z
|
||
tags: []
|
||
categories: []
|
||
---
|
||
Legacy content`);
|
||
|
||
await postEngine.rebuildDatabaseFromFiles();
|
||
|
||
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([] as any);
|
||
|
||
await postEngine.rebuildDatabaseFromFiles();
|
||
|
||
expect(handler).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should handle empty posts directory', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
vi.mocked(fs.readdir).mockResolvedValueOnce([] as any);
|
||
|
||
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 delete all existing posts before inserting fresh from files', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
vi.mocked(fs.readdir).mockResolvedValueOnce([mockDirent('existing.md')] as any);
|
||
|
||
// Mock access to allow file reads
|
||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||
|
||
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 project has existing posts to delete
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
all: vi.fn().mockResolvedValue([{ id: 'existing-id' }]),
|
||
get: vi.fn().mockResolvedValue(null),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
await postEngine.rebuildDatabaseFromFiles();
|
||
|
||
// Should delete existing posts first, then insert fresh
|
||
expect(mockLocalDb.delete).toHaveBeenCalled();
|
||
expect(mockLocalDb.insert).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should insert new posts not in database', async () => {
|
||
const fs = await import('fs/promises');
|
||
|
||
vi.mocked(fs.readdir).mockResolvedValueOnce([mockDirent('new-post.md')] as any);
|
||
|
||
// Mock access to allow file reads
|
||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||
|
||
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([mockDirent('fts-test.md')] as any);
|
||
|
||
// Mock access to allow file reads
|
||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||
|
||
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([mockDirent('valid.md'), mockDirent('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();
|
||
});
|
||
|
||
it('should import posts with duplicate slugs by auto-deduplicating slugs', async () => {
|
||
const fs = await import('fs/promises');
|
||
const insertedSlugs: string[] = [];
|
||
|
||
vi.mocked(mockLocalDb.insert).mockImplementation(() => ({
|
||
values: vi.fn((data: any) => {
|
||
insertedSlugs.push(data.slug);
|
||
if (data?.id) {
|
||
mockPosts.set(data.id, data);
|
||
}
|
||
return Promise.resolve();
|
||
}),
|
||
}));
|
||
|
||
vi.mocked(fs.readdir).mockResolvedValueOnce([
|
||
mockDirent('post-a.md'),
|
||
mockDirent('post-b.md'),
|
||
] as any);
|
||
|
||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||
vi.mocked(fs.readFile).mockImplementation(async (filePath: any) => {
|
||
if (filePath.includes('post-a.md')) {
|
||
return `---
|
||
id: post-a-id
|
||
projectId: default
|
||
title: Post A
|
||
slug: same-slug
|
||
status: published
|
||
createdAt: 2024-01-01T00:00:00.000Z
|
||
updatedAt: 2024-01-01T00:00:00.000Z
|
||
tags: []
|
||
categories: []
|
||
---
|
||
Content A`;
|
||
}
|
||
return `---
|
||
id: post-b-id
|
||
projectId: default
|
||
title: Post B
|
||
slug: same-slug
|
||
status: published
|
||
createdAt: 2024-01-02T00:00:00.000Z
|
||
updatedAt: 2024-01-02T00:00:00.000Z
|
||
tags: []
|
||
categories: []
|
||
---
|
||
Content B`;
|
||
});
|
||
|
||
await postEngine.rebuildDatabaseFromFiles();
|
||
|
||
const uniqueSlugs = new Set(insertedSlugs);
|
||
expect(uniqueSlugs.has('same-slug')).toBe(true);
|
||
expect(uniqueSlugs.has('same-slug-2')).toBe(true);
|
||
});
|
||
|
||
it('should ignore frontmatter projectId and import into current project', async () => {
|
||
const fs = await import('fs/promises');
|
||
const insertedProjects: string[] = [];
|
||
|
||
postEngine.setProjectContext('current-project-id');
|
||
|
||
vi.mocked(mockLocalDb.insert).mockImplementation(() => ({
|
||
values: vi.fn((data: any) => {
|
||
insertedProjects.push(data.projectId);
|
||
if (data?.id) {
|
||
mockPosts.set(data.id, data);
|
||
}
|
||
return Promise.resolve();
|
||
}),
|
||
}));
|
||
|
||
vi.mocked(fs.readdir).mockResolvedValueOnce([mockDirent('post-with-old-project.md')] as any);
|
||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||
vi.mocked(fs.readFile).mockResolvedValueOnce(`---
|
||
id: post-old-project
|
||
projectId: old-project-id
|
||
title: Old Project Post
|
||
slug: old-project-post
|
||
status: published
|
||
createdAt: 2024-01-01T00:00:00.000Z
|
||
updatedAt: 2024-01-01T00:00:00.000Z
|
||
tags: []
|
||
categories: []
|
||
---
|
||
Content`);
|
||
|
||
await postEngine.rebuildDatabaseFromFiles();
|
||
|
||
expect(insertedProjects).toHaveLength(1);
|
||
expect(insertedProjects[0]).toBe('current-project-id');
|
||
});
|
||
});
|
||
|
||
describe('Date-based folder structure', () => {
|
||
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[/\\]/);
|
||
});
|
||
});
|
||
|
||
describe('publishPost', () => {
|
||
it('should return null for non-existent post', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue(null),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.publishPost('non-existent-id');
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('should change status to published', async () => {
|
||
const created = await postEngine.createPost({
|
||
title: 'Publish Test',
|
||
content: '# Published content'
|
||
});
|
||
|
||
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: 'draft',
|
||
content: created.content,
|
||
filePath: '',
|
||
tags: '[]',
|
||
categories: '[]',
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
}),
|
||
all: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.publishPost(created.id);
|
||
|
||
expect(result).not.toBeNull();
|
||
expect(result?.status).toBe('published');
|
||
});
|
||
|
||
it('should set publishedAt timestamp on first publish', async () => {
|
||
const created = await postEngine.createPost({ title: 'First Publish' });
|
||
|
||
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: 'draft',
|
||
content: 'Test content',
|
||
filePath: '',
|
||
tags: '[]',
|
||
categories: '[]',
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
publishedAt: null,
|
||
}),
|
||
all: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const before = new Date();
|
||
const result = await postEngine.publishPost(created.id);
|
||
const after = new Date();
|
||
|
||
expect(result?.publishedAt).toBeDefined();
|
||
expect(result?.publishedAt!.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||
expect(result?.publishedAt!.getTime()).toBeLessThanOrEqual(after.getTime());
|
||
});
|
||
|
||
it('should preserve existing publishedAt on re-publish', async () => {
|
||
const existingPublishedAt = new Date('2024-01-15T10:00:00.000Z');
|
||
const created = await postEngine.createPost({ title: 'Re-publish 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: 'draft',
|
||
content: 'Updated content',
|
||
filePath: '/mock/existing-file.md',
|
||
tags: '[]',
|
||
categories: '[]',
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
publishedAt: existingPublishedAt,
|
||
}),
|
||
all: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.publishPost(created.id);
|
||
|
||
expect(result?.publishedAt).toEqual(existingPublishedAt);
|
||
});
|
||
|
||
it('should write post to filesystem', async () => {
|
||
const fs = await import('fs/promises');
|
||
const created = await postEngine.createPost({
|
||
title: 'File Write Test',
|
||
content: 'Published content'
|
||
});
|
||
|
||
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: 'draft',
|
||
content: 'Published content',
|
||
filePath: '',
|
||
tags: '[]',
|
||
categories: '[]',
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
}),
|
||
all: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
vi.mocked(fs.writeFile).mockClear();
|
||
await postEngine.publishPost(created.id);
|
||
|
||
expect(fs.writeFile).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should not write projectId to frontmatter when publishing', async () => {
|
||
const fs = await import('fs/promises');
|
||
postEngine.setProjectContext('my-project-id');
|
||
const created = await postEngine.createPost({
|
||
title: 'No ProjectId Frontmatter',
|
||
content: 'Published content',
|
||
});
|
||
|
||
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: 'draft',
|
||
content: created.content,
|
||
filePath: '',
|
||
tags: '[]',
|
||
categories: '[]',
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
}),
|
||
all: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
await postEngine.publishPost(created.id);
|
||
|
||
const writeCalls = vi.mocked(fs.writeFile).mock.calls;
|
||
expect(writeCalls.length).toBeGreaterThan(0);
|
||
const writtenContent = writeCalls[0][1] as string;
|
||
expect(writtenContent).not.toContain('projectId:');
|
||
});
|
||
|
||
it('should emit postUpdated event', async () => {
|
||
const handler = vi.fn();
|
||
postEngine.on('postUpdated', handler);
|
||
|
||
const created = await postEngine.createPost({ title: 'Event Publish 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: 'draft',
|
||
content: 'Test content',
|
||
filePath: '',
|
||
tags: '[]',
|
||
categories: '[]',
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
}),
|
||
all: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
await postEngine.publishPost(created.id);
|
||
|
||
expect(handler).toHaveBeenCalledWith(
|
||
expect.objectContaining({ status: 'published' })
|
||
);
|
||
});
|
||
|
||
it('should update FTS index on publish', async () => {
|
||
const created = await postEngine.createPost({
|
||
title: 'FTS Publish Test',
|
||
content: 'Searchable content'
|
||
});
|
||
|
||
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: 'draft',
|
||
content: 'Searchable content',
|
||
filePath: '',
|
||
tags: '[]',
|
||
categories: '[]',
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
}),
|
||
all: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
mockExecuteArgs = [];
|
||
await postEngine.publishPost(created.id);
|
||
|
||
const ftsUpdate = mockExecuteArgs.find((q) => q.sql.includes('posts_fts'));
|
||
expect(ftsUpdate).toBeDefined();
|
||
});
|
||
|
||
it('should remove old file when slug changes', async () => {
|
||
const fs = await import('fs/promises');
|
||
const created = await postEngine.createPost({ title: 'Slug Change Publish' });
|
||
const oldFilePath = '/mock/old/path/old-slug.md';
|
||
const newFilePath = `/mock/userData/projects/default/posts/2024/01/${created.slug}.md`;
|
||
|
||
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: 'draft',
|
||
content: 'Content',
|
||
filePath: oldFilePath,
|
||
tags: '[]',
|
||
categories: '[]',
|
||
createdAt: created.createdAt,
|
||
updatedAt: created.updatedAt,
|
||
}),
|
||
all: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
vi.mocked(fs.unlink).mockClear();
|
||
await postEngine.publishPost(created.id);
|
||
|
||
expect(fs.unlink).toHaveBeenCalledWith(oldFilePath);
|
||
});
|
||
});
|
||
|
||
describe('discardChanges', () => {
|
||
it('should return null for non-existent post', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue(null),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.discardChanges('non-existent-id');
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('should return null if 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',
|
||
filePath: '', // No published file
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.discardChanges('draft-only-id');
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('should restore post from filesystem', async () => {
|
||
const publishedFilePath = '/mock/published/post.md';
|
||
mockFiles.set(publishedFilePath, `---
|
||
id: restore-id
|
||
projectId: default
|
||
title: Published Title
|
||
slug: published-slug
|
||
status: published
|
||
createdAt: 2024-01-01T00:00:00.000Z
|
||
updatedAt: 2024-01-01T00:00:00.000Z
|
||
tags:
|
||
- original
|
||
categories: []
|
||
---
|
||
Original published content`);
|
||
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: 'restore-id',
|
||
filePath: publishedFilePath,
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.discardChanges('restore-id');
|
||
|
||
expect(result).not.toBeNull();
|
||
expect(result?.title).toBe('Published Title');
|
||
expect(result?.content).toBe('Original published content');
|
||
expect(result?.status).toBe('published');
|
||
});
|
||
|
||
it('should return null if published file is missing', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: 'missing-file-id',
|
||
filePath: '/mock/missing/file.md',
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.discardChanges('missing-file-id');
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('should emit postUpdated event', async () => {
|
||
const handler = vi.fn();
|
||
postEngine.on('postUpdated', handler);
|
||
|
||
const publishedFilePath = '/mock/discard-event.md';
|
||
mockFiles.set(publishedFilePath, `---
|
||
id: event-discard-id
|
||
projectId: default
|
||
title: Event Discard Test
|
||
slug: event-discard
|
||
status: published
|
||
createdAt: 2024-01-01T00:00:00.000Z
|
||
updatedAt: 2024-01-01T00:00:00.000Z
|
||
tags: []
|
||
categories: []
|
||
---
|
||
Published content`);
|
||
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: 'event-discard-id',
|
||
filePath: publishedFilePath,
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
await postEngine.discardChanges('event-discard-id');
|
||
|
||
expect(handler).toHaveBeenCalledWith(
|
||
expect.objectContaining({ title: 'Event Discard Test' })
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('hasPublishedVersion', () => {
|
||
it('should return false for non-existent post', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue(null),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.hasPublishedVersion('non-existent-id');
|
||
expect(result).toBe(false);
|
||
});
|
||
|
||
it('should return false for draft-only post', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: 'draft-only-id',
|
||
filePath: '',
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.hasPublishedVersion('draft-only-id');
|
||
expect(result).toBe(false);
|
||
});
|
||
|
||
it('should return true for published post', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn().mockResolvedValue({
|
||
id: 'published-id',
|
||
filePath: '/mock/published/file.md',
|
||
}),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.hasPublishedVersion('published-id');
|
||
expect(result).toBe(true);
|
||
});
|
||
});
|
||
|
||
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(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
orderBy: vi.fn().mockReturnThis(),
|
||
limit: vi.fn().mockReturnThis(),
|
||
offset: vi.fn().mockReturnThis(),
|
||
all: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getAllPosts();
|
||
expect(result.items).toEqual([]);
|
||
expect(result.total).toBe(0);
|
||
});
|
||
|
||
it('should return all posts for current project', async () => {
|
||
postEngine.setProjectContext('test-project');
|
||
|
||
// getAllPosts now makes 3 queries: count, drafts, non-drafts
|
||
let selectCallCount = 0;
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
selectCallCount++;
|
||
const chain = createSelectChain();
|
||
const callNum = selectCallCount;
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
orderBy: vi.fn().mockReturnThis(),
|
||
limit: vi.fn().mockReturnThis(),
|
||
offset: vi.fn().mockReturnThis(),
|
||
all: vi.fn().mockResolvedValue(
|
||
callNum === 1
|
||
? [{ id: '1' }, { id: '2' }] // count query: 2 total
|
||
: callNum === 2
|
||
? [{ id: '1', title: 'Draft Post', projectId: 'test-project', status: 'draft', tags: '[]', categories: '[]' }] // drafts
|
||
: [{ id: '2', title: 'Published Post', projectId: 'test-project', status: 'published', tags: '[]', categories: '[]' }] // non-drafts
|
||
),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getAllPosts();
|
||
expect(result.items).toHaveLength(2);
|
||
});
|
||
|
||
it('should parse tags and categories JSON', async () => {
|
||
// getAllPosts makes 3 queries: count, drafts, non-drafts
|
||
let selectCallCount = 0;
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
selectCallCount++;
|
||
const chain = createSelectChain();
|
||
const callNum = selectCallCount;
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
orderBy: vi.fn().mockReturnThis(),
|
||
limit: vi.fn().mockReturnThis(),
|
||
offset: vi.fn().mockReturnThis(),
|
||
all: vi.fn().mockResolvedValue(
|
||
callNum === 1
|
||
? [{ id: '1' }] // count
|
||
: callNum === 2
|
||
? [] // no drafts
|
||
: [{ id: '1', title: 'Tagged Post', tags: '["tag1","tag2"]', categories: '["cat1"]' }] // non-drafts
|
||
),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getAllPosts();
|
||
expect(result.items[0].tags).toEqual(['tag1', 'tag2']);
|
||
expect(result.items[0].categories).toEqual(['cat1']);
|
||
});
|
||
|
||
it('should always include all drafts regardless of pagination limit', async () => {
|
||
postEngine.setProjectContext('test-project');
|
||
|
||
// Simulate: 3 drafts + many published, limit=2
|
||
let selectCallCount = 0;
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
selectCallCount++;
|
||
const chain = createSelectChain();
|
||
const callNum = selectCallCount;
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
orderBy: vi.fn().mockReturnThis(),
|
||
limit: vi.fn().mockReturnThis(),
|
||
offset: vi.fn().mockReturnThis(),
|
||
all: vi.fn().mockResolvedValue(
|
||
callNum === 1
|
||
? [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }, { id: '5' }] // count: 5 total
|
||
: callNum === 2
|
||
? [ // 3 drafts (ALL of them, regardless of limit)
|
||
{ id: '1', title: 'Draft 1', status: 'draft', tags: '[]', categories: '[]' },
|
||
{ id: '2', title: 'Draft 2', status: 'draft', tags: '[]', categories: '[]' },
|
||
{ id: '3', title: 'Draft 3', status: 'draft', tags: '[]', categories: '[]' },
|
||
]
|
||
: [] // no remaining slots for non-drafts (limit=2, 3 drafts > 2)
|
||
),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
// Even with limit=2, all 3 drafts must be returned
|
||
const result = await postEngine.getAllPosts({ limit: 2, offset: 0 });
|
||
expect(result.items).toHaveLength(3);
|
||
expect(result.items.every(p => p.status === 'draft')).toBe(true);
|
||
expect(result.hasMore).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('getPostsFiltered', () => {
|
||
it('should filter by status', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
orderBy: vi.fn().mockReturnThis(),
|
||
all: vi.fn().mockResolvedValue([
|
||
{ id: '1', title: 'Published', status: 'published', tags: '[]', categories: '[]' },
|
||
]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getPostsFiltered({ status: 'published' });
|
||
expect(result).toHaveLength(1);
|
||
expect(result[0].status).toBe('published');
|
||
});
|
||
|
||
it('should apply pagination with limit and offset', async () => {
|
||
const allMock = vi.fn().mockResolvedValue([
|
||
{ id: '1', title: 'Post 1', tags: '[]', categories: '[]' },
|
||
]);
|
||
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
orderBy: vi.fn().mockReturnThis(),
|
||
all: allMock,
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getPostsFiltered({});
|
||
|
||
expect(mockLocalDb.select).toHaveBeenCalled();
|
||
expect(result).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
describe('searchPosts', () => {
|
||
it('should return empty array for empty query', async () => {
|
||
const result = await postEngine.searchPosts('');
|
||
expect(result).toEqual([]);
|
||
});
|
||
|
||
it('should search using FTS', async () => {
|
||
mockLocalClient.execute.mockResolvedValueOnce({
|
||
rows: [
|
||
{ id: 'post-1' },
|
||
{ id: 'post-2' },
|
||
]
|
||
});
|
||
|
||
// searchPosts calls .get() for each result, not .all()
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
get: vi.fn()
|
||
.mockResolvedValueOnce({ id: 'post-1', title: 'Found Post 1', slug: 'found-1', excerpt: 'First result', tags: '[]', categories: '[]' })
|
||
.mockResolvedValueOnce({ id: 'post-2', title: 'Found Post 2', slug: 'found-2', excerpt: 'Second result', tags: '[]', categories: '[]' }),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.searchPosts('search term');
|
||
expect(result).toHaveLength(2);
|
||
expect(result[0]).toEqual({
|
||
id: 'post-1',
|
||
title: 'Found Post 1',
|
||
slug: 'found-1',
|
||
excerpt: 'First result',
|
||
});
|
||
});
|
||
|
||
it('should return empty array when FTS returns no results', async () => {
|
||
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
|
||
|
||
const result = await postEngine.searchPosts('nonexistent');
|
||
expect(result).toEqual([]);
|
||
});
|
||
|
||
it('should cap search results at 500', async () => {
|
||
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
|
||
|
||
await postEngine.searchPosts('term');
|
||
|
||
expect(mockLocalClient.execute).toHaveBeenCalled();
|
||
const call = vi.mocked(mockLocalClient.execute).mock.calls[0]?.[0] as { sql?: string } | undefined;
|
||
expect(call?.sql).toBeDefined();
|
||
const sql = call?.sql?.toLowerCase() ?? '';
|
||
expect(sql).toContain('limit 500');
|
||
expect(sql).not.toMatch(/\blimit\s+50\b/);
|
||
});
|
||
});
|
||
|
||
describe('getTagsWithCounts', () => {
|
||
it('should return empty array when no posts have tags', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
all: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getTagsWithCounts();
|
||
expect(result).toEqual([]);
|
||
});
|
||
|
||
it('should count tag occurrences across posts', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
all: vi.fn().mockResolvedValue([
|
||
{ tags: '["javascript","testing"]' },
|
||
{ tags: '["javascript","react"]' },
|
||
{ tags: '["testing"]' },
|
||
]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getTagsWithCounts();
|
||
|
||
expect(result).toContainEqual({ tag: 'javascript', count: 2 });
|
||
expect(result).toContainEqual({ tag: 'testing', count: 2 });
|
||
expect(result).toContainEqual({ tag: 'react', count: 1 });
|
||
});
|
||
|
||
it('should sort by count descending', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
all: vi.fn().mockResolvedValue([
|
||
{ tags: '["a","b","c"]' },
|
||
{ tags: '["a","b"]' },
|
||
{ tags: '["a"]' },
|
||
]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getTagsWithCounts();
|
||
|
||
expect(result[0].count).toBeGreaterThanOrEqual(result[1].count);
|
||
expect(result[1].count).toBeGreaterThanOrEqual(result[2].count);
|
||
});
|
||
});
|
||
|
||
describe('getCategoriesWithCounts', () => {
|
||
it('should return empty array when no posts have categories', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
all: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getCategoriesWithCounts();
|
||
expect(result).toEqual([]);
|
||
});
|
||
|
||
it('should count category occurrences', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
all: vi.fn().mockResolvedValue([
|
||
{ categories: '["tutorials"]' },
|
||
{ categories: '["tutorials","news"]' },
|
||
{ categories: '["news"]' },
|
||
]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getCategoriesWithCounts();
|
||
|
||
expect(result).toContainEqual({ category: 'tutorials', count: 2 });
|
||
expect(result).toContainEqual({ category: 'news', count: 2 });
|
||
});
|
||
});
|
||
|
||
describe('getDashboardStats', () => {
|
||
it('should return counts for all statuses', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
all: vi.fn().mockResolvedValue([
|
||
{ status: 'draft' },
|
||
{ status: 'draft' },
|
||
{ status: 'published' },
|
||
{ status: 'published' },
|
||
{ status: 'published' },
|
||
{ status: 'archived' },
|
||
]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getDashboardStats();
|
||
|
||
expect(result.draftCount).toBe(2);
|
||
expect(result.publishedCount).toBe(3);
|
||
expect(result.archivedCount).toBe(1);
|
||
expect(result.totalPosts).toBe(6);
|
||
});
|
||
|
||
it('should handle empty project', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
all: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getDashboardStats();
|
||
|
||
expect(result.draftCount).toBe(0);
|
||
expect(result.publishedCount).toBe(0);
|
||
expect(result.archivedCount).toBe(0);
|
||
expect(result.totalPosts).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('getPostsByYearMonth', () => {
|
||
it('should return empty array when no posts exist', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
orderBy: vi.fn().mockReturnThis(),
|
||
all: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getPostsByYearMonth();
|
||
expect(result).toEqual([]);
|
||
});
|
||
|
||
it('should group posts by year and month', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
orderBy: vi.fn().mockReturnThis(),
|
||
all: vi.fn().mockResolvedValue([
|
||
{ createdAt: new Date('2024-01-15'), tags: '[]', categories: '[]' },
|
||
{ createdAt: new Date('2024-01-20'), tags: '[]', categories: '[]' },
|
||
{ createdAt: new Date('2024-02-10'), tags: '[]', categories: '[]' },
|
||
{ createdAt: new Date('2023-12-25'), tags: '[]', categories: '[]' },
|
||
]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getPostsByYearMonth();
|
||
|
||
// Note: getMonth() returns 0-11, so January is 0, February is 1, etc.
|
||
expect(result).toContainEqual({ year: 2024, month: 0, count: 2 }); // January
|
||
expect(result).toContainEqual({ year: 2024, month: 1, count: 1 }); // February
|
||
expect(result).toContainEqual({ year: 2023, month: 11, count: 1 }); // December
|
||
});
|
||
|
||
it('should sort by year and month descending', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
orderBy: vi.fn().mockReturnThis(),
|
||
all: vi.fn().mockResolvedValue([
|
||
{ createdAt: new Date('2023-06-01'), tags: '[]', categories: '[]' },
|
||
{ createdAt: new Date('2024-03-01'), tags: '[]', categories: '[]' },
|
||
{ createdAt: new Date('2024-01-01'), tags: '[]', categories: '[]' },
|
||
]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getPostsByYearMonth();
|
||
|
||
expect(result[0].year).toBe(2024);
|
||
expect(result[0].month).toBe(2); // March (0-indexed)
|
||
expect(result[result.length - 1].year).toBe(2023);
|
||
});
|
||
});
|
||
|
||
describe('extractInternalLinks', () => {
|
||
it('should extract markdown-style internal links', () => {
|
||
const content = 'Check out [my post](/posts/my-post) for more info.';
|
||
const links = postEngine.extractInternalLinks(content);
|
||
|
||
expect(links).toHaveLength(1);
|
||
expect(links[0]).toEqual({ text: 'my post', slug: 'my-post' });
|
||
});
|
||
|
||
it('should extract multiple links', () => {
|
||
const content = 'See [Post A](/posts/post-a) and [Post B](/posts/post-b).';
|
||
const links = postEngine.extractInternalLinks(content);
|
||
|
||
expect(links).toHaveLength(2);
|
||
expect(links).toContainEqual({ text: 'Post A', slug: 'post-a' });
|
||
expect(links).toContainEqual({ text: 'Post B', slug: 'post-b' });
|
||
});
|
||
|
||
it('should extract HTML-style links', () => {
|
||
const content = 'Visit <a href="/posts/html-link">HTML Link</a> for details.';
|
||
const links = postEngine.extractInternalLinks(content);
|
||
|
||
expect(links).toHaveLength(1);
|
||
expect(links[0]).toEqual({ text: 'HTML Link', slug: 'html-link' });
|
||
});
|
||
|
||
it('should handle date-structured paths', () => {
|
||
const content = 'See [Old Post](/posts/2023/05/old-post) from last year.';
|
||
const links = postEngine.extractInternalLinks(content);
|
||
|
||
expect(links).toHaveLength(1);
|
||
expect(links[0].slug).toBe('old-post');
|
||
});
|
||
|
||
it('should return empty array for content without links', () => {
|
||
const content = 'This is plain text with no internal links.';
|
||
const links = postEngine.extractInternalLinks(content);
|
||
|
||
expect(links).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('updatePostLinks', () => {
|
||
it('should delete existing links before inserting new ones', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.from = vi.fn().mockReturnValue({
|
||
...chain,
|
||
where: vi.fn().mockResolvedValue([
|
||
{ id: 'target-id', slug: 'target-post' },
|
||
]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
await postEngine.updatePostLinks('source-id', 'Link to [target](/posts/target-post)');
|
||
|
||
expect(mockLocalDb.delete).toHaveBeenCalled();
|
||
expect(mockLocalDb.insert).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should not insert self-referential links', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.from = vi.fn().mockReturnValue({
|
||
...chain,
|
||
where: vi.fn().mockResolvedValue([
|
||
{ id: 'same-id', slug: 'self-link' },
|
||
]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const insertMock = vi.fn().mockReturnValue({
|
||
values: vi.fn().mockResolvedValue(undefined),
|
||
});
|
||
vi.mocked(mockLocalDb.insert).mockImplementation(insertMock);
|
||
|
||
await postEngine.updatePostLinks('same-id', 'Link to [self](/posts/self-link)');
|
||
|
||
// Insert should be called for other table operations but not for the self-link
|
||
// The function should skip inserting when targetId === postId
|
||
});
|
||
|
||
it('should handle content with no links', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.from = vi.fn().mockReturnValue({
|
||
...chain,
|
||
where: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
await postEngine.updatePostLinks('post-id', 'Content without any links');
|
||
|
||
expect(mockLocalDb.delete).toHaveBeenCalled();
|
||
// No inserts for empty links
|
||
});
|
||
});
|
||
|
||
describe('getLinkedBy', () => {
|
||
it('should return posts that link to the specified post', async () => {
|
||
// Mock the two database queries
|
||
let callCount = 0;
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
callCount++;
|
||
const chain = createSelectChain();
|
||
|
||
if (callCount === 1) {
|
||
// First call: get links from postLinks table
|
||
chain.from = vi.fn().mockReturnValue({
|
||
...chain,
|
||
where: vi.fn().mockResolvedValue([
|
||
{ sourcePostId: 'source-1', linkText: 'Link Text' },
|
||
{ sourcePostId: 'source-2', linkText: 'Another Link' },
|
||
]),
|
||
});
|
||
} else {
|
||
// Second call: get source posts
|
||
chain.from = vi.fn().mockReturnValue({
|
||
...chain,
|
||
where: vi.fn().mockResolvedValue([
|
||
{ id: 'source-1', title: 'Source Post 1', slug: 'source-1' },
|
||
{ id: 'source-2', title: 'Source Post 2', slug: 'source-2' },
|
||
{ id: 'other', title: 'Other Post', slug: 'other' },
|
||
]),
|
||
});
|
||
}
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getLinkedBy('target-id');
|
||
|
||
expect(result).toHaveLength(2);
|
||
});
|
||
|
||
it('should return empty array when no posts link to target', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.from = vi.fn().mockReturnValue({
|
||
...chain,
|
||
where: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getLinkedBy('isolated-post');
|
||
expect(result).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('getLinksTo', () => {
|
||
it('should return posts that the specified post links to', async () => {
|
||
let callCount = 0;
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
callCount++;
|
||
const chain = createSelectChain();
|
||
|
||
if (callCount === 1) {
|
||
// First call: get links from postLinks table
|
||
chain.from = vi.fn().mockReturnValue({
|
||
...chain,
|
||
where: vi.fn().mockResolvedValue([
|
||
{ targetPostId: 'target-1', linkText: 'Link Text' },
|
||
]),
|
||
});
|
||
} else {
|
||
// Second call: get target posts
|
||
chain.from = vi.fn().mockReturnValue({
|
||
...chain,
|
||
where: vi.fn().mockResolvedValue([
|
||
{ id: 'target-1', title: 'Target Post', slug: 'target-1' },
|
||
]),
|
||
});
|
||
}
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getLinksTo('source-id');
|
||
|
||
expect(result).toHaveLength(1);
|
||
});
|
||
|
||
it('should return empty array when post has no outgoing links', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.from = vi.fn().mockReturnValue({
|
||
...chain,
|
||
where: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
const result = await postEngine.getLinksTo('no-links-post');
|
||
expect(result).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('rebuildAllPostLinks', () => {
|
||
it('should clear all existing links', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.from = vi.fn().mockReturnValue({
|
||
...chain,
|
||
where: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
await postEngine.rebuildAllPostLinks();
|
||
|
||
expect(mockLocalDb.delete).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should emit postLinksRebuilt event', async () => {
|
||
const handler = vi.fn();
|
||
postEngine.on('postLinksRebuilt', handler);
|
||
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.from = vi.fn().mockReturnValue({
|
||
...chain,
|
||
where: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
await postEngine.rebuildAllPostLinks();
|
||
|
||
expect(handler).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should process posts with file content', async () => {
|
||
const filePath = '/mock/posts/2024/01/test-post.md';
|
||
mockFiles.set(filePath, `---
|
||
id: file-post-id
|
||
title: File Post
|
||
slug: file-post
|
||
---
|
||
Content with [link](/posts/other-post)`);
|
||
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.from = vi.fn().mockReturnValue({
|
||
...chain,
|
||
where: vi.fn().mockResolvedValue([
|
||
{ id: 'file-post-id', filePath, content: null },
|
||
]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
await postEngine.rebuildAllPostLinks();
|
||
|
||
expect(mockLocalDb.delete).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should process posts with draft content in DB', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.from = vi.fn().mockReturnValue({
|
||
...chain,
|
||
where: vi.fn().mockResolvedValue([
|
||
{ id: 'draft-id', filePath: '', content: 'Draft with [link](/posts/target)' },
|
||
]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
await postEngine.rebuildAllPostLinks();
|
||
|
||
expect(mockLocalDb.delete).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('rebuildFTSIndex', () => {
|
||
it('should call getAllPostsUnpaginated', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
orderBy: vi.fn().mockReturnThis(),
|
||
all: vi.fn().mockResolvedValue([]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
await postEngine.rebuildFTSIndex();
|
||
|
||
expect(mockLocalDb.select).toHaveBeenCalled();
|
||
});
|
||
|
||
it('should update FTS for each post', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
orderBy: vi.fn().mockReturnThis(),
|
||
all: vi.fn().mockResolvedValue([
|
||
{ id: 'post-1', title: 'Post 1', content: 'Content 1', tags: '[]', categories: '[]' },
|
||
{ id: 'post-2', title: 'Post 2', content: 'Content 2', tags: '[]', categories: '[]' },
|
||
]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
mockExecuteArgs = [];
|
||
await postEngine.rebuildFTSIndex();
|
||
|
||
// FTS update is called for each post
|
||
const ftsOperations = mockExecuteArgs.filter((q) => q.sql.includes('posts_fts'));
|
||
expect(ftsOperations.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('should re-index all posts in project', async () => {
|
||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||
const chain = createSelectChain();
|
||
chain.where = vi.fn().mockReturnValue({
|
||
...chain,
|
||
orderBy: vi.fn().mockReturnThis(),
|
||
all: vi.fn().mockResolvedValue([
|
||
{ id: 'post-1', title: 'Post 1', content: 'Content 1', tags: '["a"]', categories: '[]' },
|
||
{ id: 'post-2', title: 'Post 2', content: 'Content 2', tags: '[]', categories: '["b"]' },
|
||
]),
|
||
});
|
||
return chain;
|
||
});
|
||
|
||
mockExecuteArgs = [];
|
||
await postEngine.rebuildFTSIndex();
|
||
|
||
const ftsInserts = mockExecuteArgs.filter((q) =>
|
||
q.sql.includes('INSERT INTO posts_fts')
|
||
);
|
||
expect(ftsInserts.length).toBeGreaterThanOrEqual(2);
|
||
});
|
||
});
|
||
});
|