2736 lines
99 KiB
TypeScript
2736 lines
99 KiB
TypeScript
/**
|
|
* IPC Handlers Unit Tests
|
|
*
|
|
* Tests that IPC handlers correctly pass data between engines and the UI.
|
|
* We verify that:
|
|
* 1. Handlers call the correct engine methods
|
|
* 2. Arguments are passed through correctly
|
|
* 3. Results are returned correctly to the UI
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
import { createMockPost, createMockMedia, createMockProject, resetMockCounters } from '../utils/factories';
|
|
|
|
// Capture registered handlers
|
|
const registeredHandlers = new Map<string, (...args: any[]) => Promise<any>>();
|
|
|
|
// Mock ipcMain to capture handler registrations
|
|
vi.mock('electron', () => ({
|
|
app: {
|
|
quit: vi.fn(),
|
|
},
|
|
BrowserWindow: {
|
|
fromWebContents: vi.fn(),
|
|
},
|
|
ipcMain: {
|
|
handle: vi.fn((channel: string, handler: (...args: any[]) => Promise<any>) => {
|
|
registeredHandlers.set(channel, handler);
|
|
}),
|
|
},
|
|
dialog: {
|
|
showOpenDialog: vi.fn(),
|
|
showSaveDialog: vi.fn(),
|
|
showMessageBox: vi.fn(),
|
|
},
|
|
shell: {
|
|
openPath: vi.fn(),
|
|
openExternal: vi.fn(),
|
|
showItemInFolder: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Create mock engines with EventEmitter-like `on` method
|
|
const mockPostEngine = {
|
|
on: vi.fn(),
|
|
setProjectContext: vi.fn(),
|
|
setSearchLanguage: vi.fn(),
|
|
reconcilePublishedPostsFromGitChanges: vi.fn(),
|
|
createPost: vi.fn(),
|
|
updatePost: vi.fn(),
|
|
deletePost: vi.fn(),
|
|
getPost: vi.fn(),
|
|
getAllPosts: vi.fn(),
|
|
getPostsByStatus: vi.fn(),
|
|
publishPost: vi.fn(),
|
|
discardChanges: vi.fn(),
|
|
hasPublishedVersion: vi.fn(),
|
|
getPublishedVersion: vi.fn(),
|
|
isSlugAvailable: vi.fn(),
|
|
generateUniqueSlug: vi.fn(),
|
|
rebuildDatabaseFromFiles: vi.fn(),
|
|
reindexText: vi.fn(),
|
|
searchPosts: vi.fn(),
|
|
getPostsFiltered: vi.fn(),
|
|
getAvailableTags: vi.fn(),
|
|
getAvailableCategories: vi.fn(),
|
|
getPostsByYearMonth: vi.fn(),
|
|
getLinksTo: vi.fn(),
|
|
getLinkedBy: vi.fn(),
|
|
rebuildLinks: vi.fn(),
|
|
};
|
|
|
|
const mockMediaEngine = {
|
|
on: vi.fn(),
|
|
setProjectContext: vi.fn(),
|
|
setSearchLanguage: vi.fn(),
|
|
importMedia: vi.fn(),
|
|
updateMedia: vi.fn(),
|
|
deleteMedia: vi.fn(),
|
|
getMedia: vi.fn(),
|
|
getAllMedia: vi.fn(),
|
|
getMediaFiltered: vi.fn(),
|
|
searchMedia: vi.fn(),
|
|
getMediaByYearMonth: vi.fn(),
|
|
getAvailableTags: vi.fn(),
|
|
getTagsWithCounts: vi.fn(),
|
|
rebuildDatabaseFromFiles: vi.fn(),
|
|
reindexText: vi.fn(),
|
|
getThumbnailDataUrl: vi.fn(),
|
|
regenerateMissingThumbnails: vi.fn(),
|
|
getRelativePath: vi.fn(),
|
|
};
|
|
|
|
const mockProjectEngine = {
|
|
on: vi.fn(),
|
|
createProject: vi.fn(),
|
|
updateProject: vi.fn(),
|
|
deleteProject: vi.fn(),
|
|
deleteProjectWithData: vi.fn(),
|
|
getProject: vi.fn(),
|
|
getAllProjects: vi.fn(),
|
|
getActiveProject: vi.fn(),
|
|
setActiveProject: vi.fn(),
|
|
getDataDir: vi.fn(),
|
|
getInternalBaseDir: vi.fn(),
|
|
getDefaultProjectBaseDir: vi.fn(),
|
|
getProjectPaths: vi.fn(),
|
|
};
|
|
|
|
const mockMetaEngine = {
|
|
on: vi.fn(),
|
|
setProjectContext: vi.fn(),
|
|
isInitialized: vi.fn(),
|
|
syncOnStartup: vi.fn(),
|
|
getTags: vi.fn(),
|
|
getCategories: vi.fn(),
|
|
addTag: vi.fn(),
|
|
removeTag: vi.fn(),
|
|
addCategory: vi.fn(),
|
|
removeCategory: vi.fn(),
|
|
getProjectMetadata: vi.fn(),
|
|
setProjectMetadata: vi.fn(),
|
|
updateProjectMetadata: vi.fn(),
|
|
};
|
|
|
|
const mockTagEngine = {
|
|
on: vi.fn(),
|
|
setProjectContext: vi.fn(),
|
|
getAllTags: vi.fn(),
|
|
getTag: vi.fn(),
|
|
createTag: vi.fn(),
|
|
updateTag: vi.fn(),
|
|
deleteTag: vi.fn(),
|
|
mergeTags: vi.fn(),
|
|
renameTag: vi.fn(),
|
|
syncTagsFromPosts: vi.fn(),
|
|
getOrphanedTags: vi.fn(),
|
|
cleanupOrphanedTags: vi.fn(),
|
|
searchTags: vi.fn(),
|
|
};
|
|
|
|
const mockMenuEngine = {
|
|
setProjectContext: vi.fn(),
|
|
getMenu: vi.fn(),
|
|
saveMenu: vi.fn(),
|
|
};
|
|
|
|
const mockPostMediaEngine = {
|
|
on: vi.fn(),
|
|
setProjectContext: vi.fn(),
|
|
linkMediaToPost: vi.fn(),
|
|
unlinkMediaFromPost: vi.fn(),
|
|
linkManyToPost: vi.fn(),
|
|
unlinkManyFromPost: vi.fn(),
|
|
getLinkedMediaForPost: vi.fn(),
|
|
getLinkedPostsForMedia: vi.fn(),
|
|
reorderMediaForPost: vi.fn(),
|
|
isMediaLinkedToPost: vi.fn(),
|
|
rebuildFromSidecars: vi.fn(),
|
|
};
|
|
|
|
const mockScriptEngine = {
|
|
on: vi.fn(),
|
|
setProjectContext: vi.fn(),
|
|
createScript: vi.fn(),
|
|
updateScript: vi.fn(),
|
|
deleteScript: vi.fn(),
|
|
getScript: vi.fn(),
|
|
getAllScripts: vi.fn(),
|
|
};
|
|
|
|
const mockGitEngine = {
|
|
checkAvailability: vi.fn(),
|
|
getHeadCommit: vi.fn(),
|
|
getChangedPostFilesBetween: vi.fn(),
|
|
getRepoState: vi.fn(),
|
|
getStatus: vi.fn(),
|
|
getDiff: vi.fn(),
|
|
getDiffContent: vi.fn(),
|
|
getHistory: vi.fn(),
|
|
getRemoteState: vi.fn(),
|
|
fetch: vi.fn(),
|
|
pull: vi.fn(),
|
|
push: vi.fn(),
|
|
commitAll: vi.fn(),
|
|
initializeRepo: vi.fn(),
|
|
ensureGitignore: vi.fn(),
|
|
pruneLfsCache: vi.fn(),
|
|
};
|
|
|
|
const mockTaskManager = {
|
|
getAllTasks: vi.fn(),
|
|
cancelTask: vi.fn(),
|
|
runTask: vi.fn(),
|
|
on: vi.fn(),
|
|
off: vi.fn(),
|
|
};
|
|
|
|
const mockGeneratedFileHashStore = new Map<string, string>();
|
|
|
|
const mockDatabase = {
|
|
getLocal: vi.fn(() => ({
|
|
select: vi.fn(() => ({
|
|
from: vi.fn(() => ({
|
|
where: vi.fn(() => ({
|
|
get: vi.fn(),
|
|
})),
|
|
})),
|
|
})),
|
|
})),
|
|
getLocalClient: vi.fn(() => ({
|
|
execute: vi.fn(async ({ sql, args }: { sql: string; args?: any[] }) => {
|
|
if (sql.includes('CREATE TABLE IF NOT EXISTS generated_file_hashes')) {
|
|
return { rows: [] };
|
|
}
|
|
|
|
if (sql.startsWith('SELECT content_hash FROM generated_file_hashes')) {
|
|
const key = `${String(args?.[0] ?? '')}:${String(args?.[1] ?? '')}`;
|
|
return {
|
|
rows: mockGeneratedFileHashStore.has(key)
|
|
? [{ content_hash: mockGeneratedFileHashStore.get(key) as string }]
|
|
: [],
|
|
};
|
|
}
|
|
|
|
if (sql.includes('INSERT INTO generated_file_hashes')) {
|
|
const key = `${String(args?.[0] ?? '')}:${String(args?.[1] ?? '')}`;
|
|
const value = String(args?.[2] ?? '');
|
|
mockGeneratedFileHashStore.set(key, value);
|
|
return { rowsAffected: 1 };
|
|
}
|
|
|
|
return { rows: [] };
|
|
}),
|
|
})),
|
|
getDataPaths: vi.fn(() => ({
|
|
database: '/mock/data/bds.db',
|
|
posts: '/mock/data/posts',
|
|
media: '/mock/data/media',
|
|
})),
|
|
};
|
|
|
|
// Mock engine modules
|
|
vi.mock('../../src/main/engine/PostEngine', () => ({
|
|
getPostEngine: vi.fn(() => mockPostEngine),
|
|
PostData: {},
|
|
PostFilter: {},
|
|
PaginationOptions: {},
|
|
}));
|
|
|
|
vi.mock('../../src/main/engine/MediaEngine', () => ({
|
|
getMediaEngine: vi.fn(() => mockMediaEngine),
|
|
MediaData: {},
|
|
}));
|
|
|
|
vi.mock('../../src/main/engine/ProjectEngine', () => ({
|
|
getProjectEngine: vi.fn(() => mockProjectEngine),
|
|
ProjectData: {},
|
|
}));
|
|
|
|
vi.mock('../../src/main/engine/MetaEngine', () => ({
|
|
getMetaEngine: vi.fn(() => mockMetaEngine),
|
|
}));
|
|
|
|
vi.mock('../../src/main/engine/TagEngine', () => ({
|
|
getTagEngine: vi.fn(() => mockTagEngine),
|
|
}));
|
|
|
|
vi.mock('../../src/main/engine/MenuEngine', () => ({
|
|
getMenuEngine: vi.fn(() => mockMenuEngine),
|
|
}));
|
|
|
|
vi.mock('../../src/main/engine/PostMediaEngine', () => ({
|
|
getPostMediaEngine: vi.fn(() => mockPostMediaEngine),
|
|
}));
|
|
|
|
vi.mock('../../src/main/engine/ScriptEngine', () => ({
|
|
getScriptEngine: vi.fn(() => mockScriptEngine),
|
|
}));
|
|
|
|
vi.mock('../../src/main/engine/GitEngine', () => ({
|
|
getGitEngine: vi.fn(() => mockGitEngine),
|
|
}));
|
|
|
|
vi.mock('../../src/main/engine/TaskManager', () => ({
|
|
taskManager: mockTaskManager,
|
|
TaskProgress: {},
|
|
}));
|
|
|
|
vi.mock('../../src/main/database', () => ({
|
|
getDatabase: vi.fn(() => mockDatabase),
|
|
}));
|
|
|
|
vi.mock('../../src/main/database/connection', () => ({
|
|
getDatabase: vi.fn(() => mockDatabase),
|
|
}));
|
|
|
|
vi.mock('../../src/main/engine/stemmer', () => ({
|
|
isoToStemmerLanguage: vi.fn((iso: string) => iso === 'en' ? 'english' : 'german'),
|
|
}));
|
|
|
|
vi.mock('fs/promises', () => ({
|
|
readFile: vi.fn(),
|
|
writeFile: vi.fn(),
|
|
mkdir: vi.fn(),
|
|
readdir: vi.fn(),
|
|
stat: vi.fn(),
|
|
unlink: vi.fn(),
|
|
}));
|
|
|
|
// Helper to invoke a registered handler
|
|
async function invokeHandler(channel: string, ...args: any[]): Promise<any> {
|
|
const handler = registeredHandlers.get(channel);
|
|
if (!handler) {
|
|
throw new Error(`No handler registered for channel: ${channel}`);
|
|
}
|
|
// First argument is the IpcMainInvokeEvent, which we mock as empty object
|
|
return handler({}, ...args);
|
|
}
|
|
|
|
async function invokeHandlerWithEvent(event: any, channel: string, ...args: any[]): Promise<any> {
|
|
const handler = registeredHandlers.get(channel);
|
|
if (!handler) {
|
|
throw new Error(`No handler registered for channel: ${channel}`);
|
|
}
|
|
return handler(event, ...args);
|
|
}
|
|
|
|
describe('IPC Handlers', () => {
|
|
beforeEach(async () => {
|
|
// Clear all mocks
|
|
vi.clearAllMocks();
|
|
registeredHandlers.clear();
|
|
mockGeneratedFileHashStore.clear();
|
|
resetMockCounters();
|
|
|
|
// Import and register handlers fresh for each test
|
|
const { registerIpcHandlers } = await import('../../src/main/ipc/handlers');
|
|
registerIpcHandlers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
// ============ Git Handlers ============
|
|
describe('Git Handlers', () => {
|
|
describe('git:checkAvailability', () => {
|
|
it('should return availability from GitEngine', async () => {
|
|
mockGitEngine.checkAvailability.mockResolvedValue({ gitFound: true, version: '2.49.0' });
|
|
|
|
const result = await invokeHandler('git:checkAvailability');
|
|
|
|
expect(mockGitEngine.checkAvailability).toHaveBeenCalled();
|
|
expect(result).toEqual({ gitFound: true, version: '2.49.0' });
|
|
});
|
|
});
|
|
|
|
describe('git:getRepoState', () => {
|
|
it('should pass project path to GitEngine.getRepoState', async () => {
|
|
mockGitEngine.getRepoState.mockResolvedValue({
|
|
isRepo: true,
|
|
rootPath: '/repo',
|
|
currentBranch: 'main',
|
|
hasRemote: true,
|
|
});
|
|
|
|
const result = await invokeHandler('git:getRepoState', '/repo');
|
|
|
|
expect(mockGitEngine.getRepoState).toHaveBeenCalledWith('/repo');
|
|
expect(result).toEqual({
|
|
isRepo: true,
|
|
rootPath: '/repo',
|
|
currentBranch: 'main',
|
|
hasRemote: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('git:status', () => {
|
|
it('should pass project path to GitEngine.getStatus', async () => {
|
|
mockGitEngine.getStatus.mockResolvedValue({
|
|
files: [{ path: 'file.md', status: 'modified' }],
|
|
counts: {
|
|
untracked: 0,
|
|
modified: 1,
|
|
deleted: 0,
|
|
renamed: 0,
|
|
staged: 0,
|
|
total: 1,
|
|
},
|
|
});
|
|
|
|
const result = await invokeHandler('git:status', '/repo');
|
|
|
|
expect(mockGitEngine.getStatus).toHaveBeenCalledWith('/repo');
|
|
expect(result.counts.total).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('git:diff', () => {
|
|
it('should pass project path and file path to GitEngine.getDiff', async () => {
|
|
mockGitEngine.getDiff.mockResolvedValue({
|
|
filePath: 'posts/first.md',
|
|
patch: 'diff --git a/posts/first.md b/posts/first.md',
|
|
});
|
|
|
|
const result = await invokeHandler('git:diff', '/repo', 'posts/first.md');
|
|
|
|
expect(mockGitEngine.getDiff).toHaveBeenCalledWith('/repo', 'posts/first.md');
|
|
expect(result).toEqual({
|
|
filePath: 'posts/first.md',
|
|
patch: 'diff --git a/posts/first.md b/posts/first.md',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('git:history', () => {
|
|
it('should pass project path and limit to GitEngine.getHistory', async () => {
|
|
mockGitEngine.getHistory.mockResolvedValue([
|
|
{
|
|
hash: 'abc123',
|
|
shortHash: 'abc123',
|
|
date: '2026-02-16T10:00:00.000Z',
|
|
subject: 'feat: add git sidebar',
|
|
author: 'Dev One',
|
|
},
|
|
]);
|
|
|
|
const result = await invokeHandler('git:history', '/repo', 20);
|
|
|
|
expect(mockGitEngine.getHistory).toHaveBeenCalledWith('/repo', 20);
|
|
expect(result).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('git:remoteState', () => {
|
|
it('should pass project path to GitEngine.getRemoteState', async () => {
|
|
mockGitEngine.getRemoteState.mockResolvedValue({
|
|
localBranch: 'main',
|
|
upstreamBranch: 'origin/main',
|
|
hasUpstream: true,
|
|
ahead: 2,
|
|
behind: 1,
|
|
});
|
|
|
|
const result = await invokeHandler('git:remoteState', '/repo');
|
|
|
|
expect(mockGitEngine.getRemoteState).toHaveBeenCalledWith('/repo');
|
|
expect(result).toEqual({
|
|
localBranch: 'main',
|
|
upstreamBranch: 'origin/main',
|
|
hasUpstream: true,
|
|
ahead: 2,
|
|
behind: 1,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('git:diffContent', () => {
|
|
it('should pass project path and file path to GitEngine.getDiffContent', async () => {
|
|
mockGitEngine.getDiffContent.mockResolvedValue({
|
|
filePath: 'posts/first.md',
|
|
original: '# old content',
|
|
modified: '# new content',
|
|
});
|
|
|
|
const result = await invokeHandler('git:diffContent', '/repo', 'posts/first.md');
|
|
|
|
expect(mockGitEngine.getDiffContent).toHaveBeenCalledWith('/repo', 'posts/first.md');
|
|
expect(result).toEqual({
|
|
filePath: 'posts/first.md',
|
|
original: '# old content',
|
|
modified: '# new content',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('git:init', () => {
|
|
it('should pass project path to GitEngine.initializeRepo', async () => {
|
|
mockGitEngine.initializeRepo.mockResolvedValue({ success: true });
|
|
|
|
const result = await invokeHandler('git:init', '/repo');
|
|
|
|
expect(mockGitEngine.initializeRepo).toHaveBeenCalledWith('/repo', undefined, expect.any(Function));
|
|
expect(result).toEqual({ success: true });
|
|
});
|
|
|
|
it('should pass optional remote url to GitEngine.initializeRepo', async () => {
|
|
mockGitEngine.initializeRepo.mockResolvedValue({ success: true });
|
|
|
|
await invokeHandler('git:init', '/repo', 'https://github.com/example/repo.git');
|
|
|
|
expect(mockGitEngine.initializeRepo).toHaveBeenCalledWith('/repo', 'https://github.com/example/repo.git', expect.any(Function));
|
|
});
|
|
|
|
it('should forward init progress updates to renderer via event sender', async () => {
|
|
mockGitEngine.initializeRepo.mockImplementation(async (_projectPath: string, _remoteUrl: string | undefined, onProgress: (payload: unknown) => void) => {
|
|
onProgress({ phase: 'initializing-repo', progress: 20, message: 'Initializing repository...' });
|
|
onProgress({ phase: 'completed', progress: 100, message: 'Repository initialized.' });
|
|
return { success: true };
|
|
});
|
|
|
|
const send = vi.fn();
|
|
const event = { sender: { send } };
|
|
|
|
const result = await invokeHandlerWithEvent(event, 'git:init', '/repo');
|
|
|
|
expect(result).toEqual({ success: true });
|
|
expect(send).toHaveBeenCalledWith('git:initProgress', {
|
|
phase: 'initializing-repo',
|
|
progress: 20,
|
|
message: 'Initializing repository...',
|
|
});
|
|
expect(send).toHaveBeenCalledWith('git:initProgress', {
|
|
phase: 'completed',
|
|
progress: 100,
|
|
message: 'Repository initialized.',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('git:ensureGitignore', () => {
|
|
it('should pass project path to GitEngine.ensureGitignore', async () => {
|
|
mockGitEngine.ensureGitignore.mockResolvedValue({
|
|
updated: true,
|
|
created: false,
|
|
addedEntries: ['Thumbs.db'],
|
|
});
|
|
|
|
const result = await invokeHandler('git:ensureGitignore', '/repo');
|
|
|
|
expect(mockGitEngine.ensureGitignore).toHaveBeenCalledWith('/repo');
|
|
expect(result).toEqual({
|
|
updated: true,
|
|
created: false,
|
|
addedEntries: ['Thumbs.db'],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('git:pruneLfs', () => {
|
|
it('should pass project path and options to GitEngine.pruneLfsCache', async () => {
|
|
mockGitEngine.pruneLfsCache.mockResolvedValue({
|
|
success: true,
|
|
dryRun: true,
|
|
verifyRemote: true,
|
|
output: 'would prune',
|
|
});
|
|
|
|
const result = await invokeHandler('git:pruneLfs', '/repo', { dryRun: true, verifyRemote: true });
|
|
|
|
expect(mockGitEngine.pruneLfsCache).toHaveBeenCalledWith('/repo', { dryRun: true, verifyRemote: true });
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('git:fetch', () => {
|
|
it('should pass project path to GitEngine.fetch', async () => {
|
|
mockGitEngine.fetch.mockResolvedValue({ success: true });
|
|
|
|
const result = await invokeHandler('git:fetch', '/repo');
|
|
|
|
expect(mockGitEngine.fetch).toHaveBeenCalledWith('/repo');
|
|
expect(result).toEqual({ success: true });
|
|
});
|
|
});
|
|
|
|
describe('git:pull', () => {
|
|
it('should reconcile published posts from pulled post file changes when pull succeeds', async () => {
|
|
mockGitEngine.getHeadCommit
|
|
.mockResolvedValueOnce('before-head')
|
|
.mockResolvedValueOnce('after-head');
|
|
mockGitEngine.pull.mockResolvedValue({ success: true });
|
|
mockGitEngine.getChangedPostFilesBetween.mockResolvedValue([
|
|
{ status: 'modified', path: 'posts/2026/02/existing.md' },
|
|
{ status: 'added', path: 'posts/2026/02/new-post.md' },
|
|
]);
|
|
mockPostEngine.reconcilePublishedPostsFromGitChanges.mockResolvedValue({
|
|
created: 1,
|
|
updated: 1,
|
|
deleted: 0,
|
|
processedFiles: 2,
|
|
});
|
|
|
|
const result = await invokeHandler('git:pull', '/repo');
|
|
|
|
expect(mockGitEngine.getHeadCommit).toHaveBeenNthCalledWith(1, '/repo');
|
|
expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo');
|
|
expect(mockGitEngine.getHeadCommit).toHaveBeenNthCalledWith(2, '/repo');
|
|
expect(mockGitEngine.getChangedPostFilesBetween).toHaveBeenCalledWith('/repo', 'before-head', 'after-head');
|
|
expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).toHaveBeenCalledWith('/repo', [
|
|
{ status: 'modified', path: 'posts/2026/02/existing.md' },
|
|
{ status: 'added', path: 'posts/2026/02/new-post.md' },
|
|
]);
|
|
expect(result).toEqual({ success: true });
|
|
});
|
|
|
|
it('should skip reconciliation when pull fails', async () => {
|
|
mockGitEngine.getHeadCommit.mockResolvedValue('before-head');
|
|
mockGitEngine.pull.mockResolvedValue({ success: false, code: 'conflict' });
|
|
|
|
const result = await invokeHandler('git:pull', '/repo');
|
|
|
|
expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo');
|
|
expect(mockGitEngine.getChangedPostFilesBetween).not.toHaveBeenCalled();
|
|
expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).not.toHaveBeenCalled();
|
|
expect(result).toEqual({ success: false, code: 'conflict' });
|
|
});
|
|
|
|
it('should skip reconciliation when pull does not change HEAD', async () => {
|
|
mockGitEngine.getHeadCommit
|
|
.mockResolvedValueOnce('same-head')
|
|
.mockResolvedValueOnce('same-head');
|
|
mockGitEngine.pull.mockResolvedValue({ success: true });
|
|
|
|
const result = await invokeHandler('git:pull', '/repo');
|
|
|
|
expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo');
|
|
expect(mockGitEngine.getChangedPostFilesBetween).not.toHaveBeenCalled();
|
|
expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).not.toHaveBeenCalled();
|
|
expect(result).toEqual({ success: true });
|
|
});
|
|
});
|
|
|
|
describe('git:push', () => {
|
|
it('should pass project path to GitEngine.push', async () => {
|
|
mockGitEngine.push.mockResolvedValue({ success: true });
|
|
|
|
const result = await invokeHandler('git:push', '/repo');
|
|
|
|
expect(mockGitEngine.push).toHaveBeenCalledWith('/repo');
|
|
expect(result).toEqual({ success: true });
|
|
});
|
|
});
|
|
|
|
describe('git:commitAll', () => {
|
|
it('should pass project path and message to GitEngine.commitAll', async () => {
|
|
mockGitEngine.commitAll.mockResolvedValue({ success: true });
|
|
|
|
const result = await invokeHandler('git:commitAll', '/repo', 'feat: commit');
|
|
|
|
expect(mockGitEngine.commitAll).toHaveBeenCalledWith('/repo', 'feat: commit');
|
|
expect(result).toEqual({ success: true });
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============ Project Handlers ============
|
|
describe('Project Handlers', () => {
|
|
describe('projects:create', () => {
|
|
it('should pass data to ProjectEngine.createProject and return result', async () => {
|
|
const mockProject = createMockProject({ name: 'New Blog' });
|
|
mockProjectEngine.createProject.mockResolvedValue(mockProject);
|
|
|
|
const result = await invokeHandler('projects:create', {
|
|
name: 'New Blog',
|
|
description: 'A test blog',
|
|
});
|
|
|
|
expect(mockProjectEngine.createProject).toHaveBeenCalledWith({
|
|
name: 'New Blog',
|
|
description: 'A test blog',
|
|
});
|
|
expect(result).toEqual(mockProject);
|
|
});
|
|
});
|
|
|
|
describe('projects:update', () => {
|
|
it('should pass id and data to ProjectEngine.updateProject', async () => {
|
|
const mockProject = createMockProject({ name: 'Updated Blog' });
|
|
mockProjectEngine.updateProject.mockResolvedValue(mockProject);
|
|
|
|
const result = await invokeHandler('projects:update', 'proj-1', { name: 'Updated Blog' });
|
|
|
|
expect(mockProjectEngine.updateProject).toHaveBeenCalledWith('proj-1', { name: 'Updated Blog' });
|
|
expect(result).toEqual(mockProject);
|
|
});
|
|
});
|
|
|
|
describe('projects:delete', () => {
|
|
it('should pass id to ProjectEngine.deleteProject', async () => {
|
|
mockProjectEngine.deleteProject.mockResolvedValue(undefined);
|
|
|
|
await invokeHandler('projects:delete', 'proj-1');
|
|
|
|
expect(mockProjectEngine.deleteProject).toHaveBeenCalledWith('proj-1');
|
|
});
|
|
});
|
|
|
|
describe('projects:getAll', () => {
|
|
it('should return all projects from ProjectEngine', async () => {
|
|
const mockProjects = [
|
|
createMockProject({ name: 'Blog 1' }),
|
|
createMockProject({ name: 'Blog 2' }),
|
|
];
|
|
mockProjectEngine.getAllProjects.mockResolvedValue(mockProjects);
|
|
|
|
const result = await invokeHandler('projects:getAll');
|
|
|
|
expect(mockProjectEngine.getAllProjects).toHaveBeenCalled();
|
|
expect(result).toEqual(mockProjects);
|
|
});
|
|
});
|
|
|
|
describe('projects:getActive', () => {
|
|
it('should return active project and set engine contexts', async () => {
|
|
const mockProject = createMockProject({ id: 'active-proj', dataPath: '/custom/path' });
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/custom/path');
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue({ mainLanguage: 'en' });
|
|
|
|
const result = await invokeHandler('projects:getActive');
|
|
|
|
expect(mockProjectEngine.getActiveProject).toHaveBeenCalled();
|
|
expect(mockPostEngine.setProjectContext).toHaveBeenCalledWith('active-proj', '/custom/path');
|
|
expect(mockMediaEngine.setProjectContext).toHaveBeenCalledWith('active-proj', '/custom/path', '/custom/path');
|
|
expect(mockMetaEngine.setProjectContext).toHaveBeenCalledWith('active-proj', '/custom/path');
|
|
expect(mockMetaEngine.syncOnStartup).toHaveBeenCalled();
|
|
expect(result).toEqual(mockProject);
|
|
});
|
|
|
|
it('should return null when no active project', async () => {
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(null);
|
|
|
|
const result = await invokeHandler('projects:getActive');
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockPostEngine.setProjectContext).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should set search language from project metadata', async () => {
|
|
const mockProject = createMockProject({ id: 'proj-1' });
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/data');
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue({ mainLanguage: 'de' });
|
|
|
|
await invokeHandler('projects:getActive');
|
|
|
|
expect(mockPostEngine.setSearchLanguage).toHaveBeenCalledWith('german');
|
|
expect(mockMediaEngine.setSearchLanguage).toHaveBeenCalledWith('german');
|
|
});
|
|
});
|
|
|
|
describe('projects:setActive', () => {
|
|
it('should set active project and update all engine contexts', async () => {
|
|
const mockProject = createMockProject({ id: 'new-active', dataPath: '/new/path' });
|
|
mockProjectEngine.setActiveProject.mockResolvedValue(mockProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/new/path');
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue(null);
|
|
|
|
const result = await invokeHandler('projects:setActive', 'new-active');
|
|
|
|
expect(mockProjectEngine.setActiveProject).toHaveBeenCalledWith('new-active');
|
|
expect(mockPostEngine.setProjectContext).toHaveBeenCalledWith('new-active', '/new/path');
|
|
expect(mockMediaEngine.setProjectContext).toHaveBeenCalledWith('new-active', '/new/path', '/new/path');
|
|
expect(mockPostMediaEngine.setProjectContext).toHaveBeenCalledWith('new-active');
|
|
expect(result).toEqual(mockProject);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============ Post Handlers ============
|
|
describe('Post Handlers', () => {
|
|
describe('posts:create', () => {
|
|
it('should pass data to PostEngine.createPost and return the created post', async () => {
|
|
const inputData = { title: 'My New Post', content: '# Hello' };
|
|
const mockPost = createMockPost({ ...inputData, id: 'post-123' });
|
|
mockPostEngine.createPost.mockResolvedValue(mockPost);
|
|
|
|
const result = await invokeHandler('posts:create', inputData);
|
|
|
|
expect(mockPostEngine.createPost).toHaveBeenCalledWith(inputData);
|
|
expect(result).toEqual(mockPost);
|
|
expect(result.title).toBe('My New Post');
|
|
});
|
|
});
|
|
|
|
describe('posts:update', () => {
|
|
it('should pass id and data to PostEngine.updatePost', async () => {
|
|
const updateData = { title: 'Updated Title' };
|
|
const mockPost = createMockPost({ id: 'post-1', title: 'Updated Title' });
|
|
mockPostEngine.updatePost.mockResolvedValue(mockPost);
|
|
|
|
const result = await invokeHandler('posts:update', 'post-1', updateData);
|
|
|
|
expect(mockPostEngine.updatePost).toHaveBeenCalledWith('post-1', updateData);
|
|
expect(result.title).toBe('Updated Title');
|
|
});
|
|
});
|
|
|
|
describe('posts:delete', () => {
|
|
it('should pass id to PostEngine.deletePost', async () => {
|
|
mockPostEngine.deletePost.mockResolvedValue(undefined);
|
|
|
|
await invokeHandler('posts:delete', 'post-to-delete');
|
|
|
|
expect(mockPostEngine.deletePost).toHaveBeenCalledWith('post-to-delete');
|
|
});
|
|
});
|
|
|
|
describe('posts:get', () => {
|
|
it('should return post from PostEngine.getPost', async () => {
|
|
const mockPost = createMockPost({ id: 'post-1', title: 'Fetched Post' });
|
|
mockPostEngine.getPost.mockResolvedValue(mockPost);
|
|
|
|
const result = await invokeHandler('posts:get', 'post-1');
|
|
|
|
expect(mockPostEngine.getPost).toHaveBeenCalledWith('post-1');
|
|
expect(result).toEqual(mockPost);
|
|
});
|
|
|
|
it('should return null for non-existent post', async () => {
|
|
mockPostEngine.getPost.mockResolvedValue(null);
|
|
|
|
const result = await invokeHandler('posts:get', 'non-existent');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('posts:getPreviewUrl', () => {
|
|
it('should return canonical preview URL for an existing post', async () => {
|
|
mockPostEngine.getPost.mockResolvedValue(createMockPost({
|
|
id: 'post-1',
|
|
slug: 'my-post',
|
|
createdAt: new Date('2026-02-16T12:00:00.000Z'),
|
|
}));
|
|
|
|
const result = await invokeHandler('posts:getPreviewUrl', 'post-1');
|
|
|
|
expect(mockPostEngine.getPost).toHaveBeenCalledWith('post-1');
|
|
expect(result).toBe('http://127.0.0.1:4123/2026/02/16/my-post');
|
|
});
|
|
|
|
it('should return null when post does not exist', async () => {
|
|
mockPostEngine.getPost.mockResolvedValue(null);
|
|
|
|
const result = await invokeHandler('posts:getPreviewUrl', 'missing-post');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return draft preview URL when draft option is enabled', async () => {
|
|
mockPostEngine.getPost.mockResolvedValue(createMockPost({
|
|
id: 'post-1',
|
|
slug: 'my-post',
|
|
createdAt: new Date('2026-02-16T12:00:00.000Z'),
|
|
}));
|
|
|
|
const result = await invokeHandler('posts:getPreviewUrl', 'post-1', { draft: true });
|
|
|
|
expect(mockPostEngine.getPost).toHaveBeenCalledWith('post-1');
|
|
expect(result).toBe('http://127.0.0.1:4123/2026/02/16/my-post?draft=true&postId=post-1');
|
|
});
|
|
});
|
|
|
|
describe('posts:getAll', () => {
|
|
it('should return paginated posts from PostEngine', async () => {
|
|
const mockPosts = [
|
|
createMockPost({ title: 'Post 1' }),
|
|
createMockPost({ title: 'Post 2' }),
|
|
];
|
|
const paginatedResult = { items: mockPosts, hasMore: false, total: 2 };
|
|
mockPostEngine.getAllPosts.mockResolvedValue(paginatedResult);
|
|
|
|
const result = await invokeHandler('posts:getAll', { limit: 10, offset: 0 });
|
|
|
|
expect(mockPostEngine.getAllPosts).toHaveBeenCalledWith({ limit: 10, offset: 0 });
|
|
expect(result.items).toHaveLength(2);
|
|
expect(result.total).toBe(2);
|
|
});
|
|
|
|
it('should work without pagination options', async () => {
|
|
mockPostEngine.getAllPosts.mockResolvedValue({ items: [], hasMore: false, total: 0 });
|
|
|
|
await invokeHandler('posts:getAll');
|
|
|
|
expect(mockPostEngine.getAllPosts).toHaveBeenCalledWith(undefined);
|
|
});
|
|
});
|
|
|
|
describe('posts:getByStatus', () => {
|
|
it('should filter posts by status', async () => {
|
|
const draftPosts = [createMockPost({ status: 'draft' })];
|
|
mockPostEngine.getPostsByStatus.mockResolvedValue(draftPosts);
|
|
|
|
const result = await invokeHandler('posts:getByStatus', 'draft');
|
|
|
|
expect(mockPostEngine.getPostsByStatus).toHaveBeenCalledWith('draft');
|
|
expect(result).toEqual(draftPosts);
|
|
});
|
|
});
|
|
|
|
describe('posts:publish', () => {
|
|
it('should publish a post and return updated post', async () => {
|
|
const publishedPost = createMockPost({ id: 'post-1', status: 'published' });
|
|
mockPostEngine.publishPost.mockResolvedValue(publishedPost);
|
|
|
|
const result = await invokeHandler('posts:publish', 'post-1');
|
|
|
|
expect(mockPostEngine.publishPost).toHaveBeenCalledWith('post-1');
|
|
expect(result.status).toBe('published');
|
|
});
|
|
});
|
|
|
|
describe('posts:isSlugAvailable', () => {
|
|
it('should check slug availability', async () => {
|
|
mockPostEngine.isSlugAvailable.mockResolvedValue(true);
|
|
|
|
const result = await invokeHandler('posts:isSlugAvailable', 'my-slug');
|
|
|
|
expect(mockPostEngine.isSlugAvailable).toHaveBeenCalledWith('my-slug', undefined);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should exclude a post when checking slug', async () => {
|
|
mockPostEngine.isSlugAvailable.mockResolvedValue(true);
|
|
|
|
await invokeHandler('posts:isSlugAvailable', 'my-slug', 'exclude-post-id');
|
|
|
|
expect(mockPostEngine.isSlugAvailable).toHaveBeenCalledWith('my-slug', 'exclude-post-id');
|
|
});
|
|
});
|
|
|
|
describe('posts:generateUniqueSlug', () => {
|
|
it('should generate unique slug from title', async () => {
|
|
mockPostEngine.generateUniqueSlug.mockResolvedValue('my-awesome-post');
|
|
|
|
const result = await invokeHandler('posts:generateUniqueSlug', 'My Awesome Post');
|
|
|
|
expect(mockPostEngine.generateUniqueSlug).toHaveBeenCalledWith('My Awesome Post', undefined);
|
|
expect(result).toBe('my-awesome-post');
|
|
});
|
|
});
|
|
|
|
describe('posts:search', () => {
|
|
it('should search posts and return results', async () => {
|
|
const searchResults = [
|
|
{ post: createMockPost({ title: 'Test Post' }), score: 0.9, highlights: ['test'] },
|
|
];
|
|
mockPostEngine.searchPosts.mockResolvedValue(searchResults);
|
|
|
|
const result = await invokeHandler('posts:search', 'test query');
|
|
|
|
expect(mockPostEngine.searchPosts).toHaveBeenCalledWith('test query');
|
|
expect(result).toEqual(searchResults);
|
|
});
|
|
});
|
|
|
|
describe('posts:filter', () => {
|
|
it('should filter posts by criteria', async () => {
|
|
const filteredPosts = [createMockPost({ tags: ['javascript'] })];
|
|
mockPostEngine.getPostsFiltered.mockResolvedValue(filteredPosts);
|
|
|
|
const filter = { tags: ['javascript'], status: 'published' };
|
|
const result = await invokeHandler('posts:filter', filter);
|
|
|
|
expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith(filter);
|
|
expect(result).toEqual(filteredPosts);
|
|
});
|
|
});
|
|
|
|
describe('posts:getLinksTo', () => {
|
|
it('should return posts linking to a given post', async () => {
|
|
const linkingPosts = [createMockPost({ title: 'Linking Post' })];
|
|
mockPostEngine.getLinksTo.mockResolvedValue(linkingPosts);
|
|
|
|
const result = await invokeHandler('posts:getLinksTo', 'target-post-id');
|
|
|
|
expect(mockPostEngine.getLinksTo).toHaveBeenCalledWith('target-post-id');
|
|
expect(result).toEqual(linkingPosts);
|
|
});
|
|
});
|
|
|
|
describe('posts:getLinkedBy', () => {
|
|
it('should return posts linked by a given post', async () => {
|
|
const linkedPosts = [createMockPost({ title: 'Linked Post' })];
|
|
mockPostEngine.getLinkedBy.mockResolvedValue(linkedPosts);
|
|
|
|
const result = await invokeHandler('posts:getLinkedBy', 'source-post-id');
|
|
|
|
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('source-post-id');
|
|
expect(result).toEqual(linkedPosts);
|
|
});
|
|
});
|
|
|
|
describe('posts:rebuildFromFiles', () => {
|
|
it('should propagate rebuild errors to the caller', async () => {
|
|
const rebuildError = new Error('rebuild failed');
|
|
mockPostEngine.rebuildDatabaseFromFiles.mockRejectedValue(rebuildError);
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(null);
|
|
|
|
await expect(invokeHandler('posts:rebuildFromFiles')).rejects.toThrow('rebuild failed');
|
|
expect(mockPostEngine.rebuildDatabaseFromFiles).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('posts:reindexText', () => {
|
|
it('should propagate reindex errors to the caller', async () => {
|
|
const reindexError = new Error('post reindex failed');
|
|
mockPostEngine.reindexText.mockRejectedValue(reindexError);
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(null);
|
|
|
|
await expect(invokeHandler('posts:reindexText')).rejects.toThrow('post reindex failed');
|
|
expect(mockPostEngine.reindexText).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============ Media Handlers ============
|
|
describe('Media Handlers', () => {
|
|
describe('media:import', () => {
|
|
it('should import media from source path', async () => {
|
|
const mockMedia = createMockMedia({ filename: 'photo.jpg' });
|
|
mockMediaEngine.importMedia.mockResolvedValue(mockMedia);
|
|
|
|
const result = await invokeHandler('media:import', '/path/to/photo.jpg', { alt: 'A photo' });
|
|
|
|
expect(mockMediaEngine.importMedia).toHaveBeenCalledWith('/path/to/photo.jpg', { alt: 'A photo' });
|
|
expect(result).toEqual(mockMedia);
|
|
});
|
|
});
|
|
|
|
describe('media:update', () => {
|
|
it('should update media metadata', async () => {
|
|
const updatedMedia = createMockMedia({ id: 'media-1', alt: 'Updated alt' });
|
|
mockMediaEngine.updateMedia.mockResolvedValue(updatedMedia);
|
|
|
|
const result = await invokeHandler('media:update', 'media-1', { alt: 'Updated alt' });
|
|
|
|
expect(mockMediaEngine.updateMedia).toHaveBeenCalledWith('media-1', { alt: 'Updated alt' });
|
|
expect(result.alt).toBe('Updated alt');
|
|
});
|
|
});
|
|
|
|
describe('media:delete', () => {
|
|
it('should delete media by id', async () => {
|
|
mockMediaEngine.deleteMedia.mockResolvedValue(undefined);
|
|
|
|
await invokeHandler('media:delete', 'media-to-delete');
|
|
|
|
expect(mockMediaEngine.deleteMedia).toHaveBeenCalledWith('media-to-delete');
|
|
});
|
|
});
|
|
|
|
describe('media:get', () => {
|
|
it('should return media by id', async () => {
|
|
const mockMedia = createMockMedia({ id: 'media-1' });
|
|
mockMediaEngine.getMedia.mockResolvedValue(mockMedia);
|
|
|
|
const result = await invokeHandler('media:get', 'media-1');
|
|
|
|
expect(mockMediaEngine.getMedia).toHaveBeenCalledWith('media-1');
|
|
expect(result).toEqual(mockMedia);
|
|
});
|
|
});
|
|
|
|
describe('media:getAll', () => {
|
|
it('should return all media items', async () => {
|
|
const mockMediaList = [
|
|
createMockMedia({ filename: 'img1.jpg' }),
|
|
createMockMedia({ filename: 'img2.png' }),
|
|
];
|
|
mockMediaEngine.getAllMedia.mockResolvedValue(mockMediaList);
|
|
|
|
const result = await invokeHandler('media:getAll');
|
|
|
|
expect(mockMediaEngine.getAllMedia).toHaveBeenCalled();
|
|
expect(result).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('media:getUrl', () => {
|
|
it('should return absolute media path', async () => {
|
|
mockMediaEngine.getRelativePath.mockResolvedValue('media/2025/01/media-123.jpg');
|
|
|
|
const result = await invokeHandler('media:getUrl', 'media-123');
|
|
|
|
expect(mockMediaEngine.getRelativePath).toHaveBeenCalledWith('media-123');
|
|
expect(result).toBe('/media/2025/01/media-123.jpg');
|
|
});
|
|
|
|
it('should fall back to /media/{id} when relative path is not found', async () => {
|
|
mockMediaEngine.getRelativePath.mockResolvedValue(null);
|
|
|
|
const result = await invokeHandler('media:getUrl', 'media-unknown');
|
|
|
|
expect(result).toBe('/media/media-unknown');
|
|
});
|
|
});
|
|
|
|
describe('media:filter', () => {
|
|
it('should filter media by criteria', async () => {
|
|
const filteredMedia = [createMockMedia({ mimeType: 'image/jpeg' })];
|
|
mockMediaEngine.getMediaFiltered.mockResolvedValue(filteredMedia);
|
|
|
|
const filter = { mimeTypes: ['image/jpeg'] };
|
|
const result = await invokeHandler('media:filter', filter);
|
|
|
|
expect(mockMediaEngine.getMediaFiltered).toHaveBeenCalledWith(filter);
|
|
expect(result).toEqual(filteredMedia);
|
|
});
|
|
});
|
|
|
|
describe('media:search', () => {
|
|
it('should search media by query', async () => {
|
|
const searchResults = [createMockMedia({ alt: 'sunset photo' })];
|
|
mockMediaEngine.searchMedia.mockResolvedValue(searchResults);
|
|
|
|
const result = await invokeHandler('media:search', 'sunset');
|
|
|
|
expect(mockMediaEngine.searchMedia).toHaveBeenCalledWith('sunset');
|
|
expect(result).toEqual(searchResults);
|
|
});
|
|
});
|
|
|
|
describe('media:getByYearMonth', () => {
|
|
it('should return media grouped by year/month', async () => {
|
|
const groupedMedia = {
|
|
'2024': { '01': [createMockMedia()], '02': [createMockMedia()] },
|
|
};
|
|
mockMediaEngine.getMediaByYearMonth.mockResolvedValue(groupedMedia);
|
|
|
|
const result = await invokeHandler('media:getByYearMonth');
|
|
|
|
expect(mockMediaEngine.getMediaByYearMonth).toHaveBeenCalled();
|
|
expect(result).toEqual(groupedMedia);
|
|
});
|
|
});
|
|
|
|
describe('media:getTags', () => {
|
|
it('should return available media tags', async () => {
|
|
const tags = ['landscape', 'portrait', 'macro'];
|
|
mockMediaEngine.getAvailableTags.mockResolvedValue(tags);
|
|
|
|
const result = await invokeHandler('media:getTags');
|
|
|
|
expect(mockMediaEngine.getAvailableTags).toHaveBeenCalled();
|
|
expect(result).toEqual(tags);
|
|
});
|
|
});
|
|
|
|
describe('media:getTagsWithCounts', () => {
|
|
it('should return tags with usage counts', async () => {
|
|
const tagsWithCounts = [
|
|
{ tag: 'landscape', count: 10 },
|
|
{ tag: 'portrait', count: 5 },
|
|
];
|
|
mockMediaEngine.getTagsWithCounts.mockResolvedValue(tagsWithCounts);
|
|
|
|
const result = await invokeHandler('media:getTagsWithCounts');
|
|
|
|
expect(mockMediaEngine.getTagsWithCounts).toHaveBeenCalled();
|
|
expect(result).toEqual(tagsWithCounts);
|
|
});
|
|
});
|
|
|
|
describe('media:getThumbnail', () => {
|
|
it('should return thumbnail data URL for media', async () => {
|
|
const thumbnailDataUrl = 'data:image/jpeg;base64,/9j/4AAQ...';
|
|
mockMediaEngine.getThumbnailDataUrl.mockResolvedValue(thumbnailDataUrl);
|
|
|
|
const result = await invokeHandler('media:getThumbnail', 'media-1', 'small');
|
|
|
|
expect(mockMediaEngine.getThumbnailDataUrl).toHaveBeenCalledWith('media-1', 'small');
|
|
expect(result).toEqual(thumbnailDataUrl);
|
|
});
|
|
});
|
|
|
|
describe('media:rebuildFromFiles', () => {
|
|
it('should propagate rebuild errors to the caller', async () => {
|
|
const rebuildError = new Error('media rebuild failed');
|
|
mockMediaEngine.rebuildDatabaseFromFiles.mockRejectedValue(rebuildError);
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(null);
|
|
|
|
await expect(invokeHandler('media:rebuildFromFiles')).rejects.toThrow('media rebuild failed');
|
|
expect(mockMediaEngine.rebuildDatabaseFromFiles).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('media:reindexText', () => {
|
|
it('should propagate reindex errors to the caller', async () => {
|
|
const reindexError = new Error('media reindex failed');
|
|
mockMediaEngine.reindexText.mockRejectedValue(reindexError);
|
|
|
|
await expect(invokeHandler('media:reindexText')).rejects.toThrow('media reindex failed');
|
|
expect(mockMediaEngine.reindexText).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============ Meta Handlers ============
|
|
describe('Meta Handlers', () => {
|
|
describe('meta:getTags', () => {
|
|
it('should return all tags from MetaEngine', async () => {
|
|
const tags = ['javascript', 'typescript', 'react'];
|
|
mockMetaEngine.getTags.mockResolvedValue(tags);
|
|
|
|
const result = await invokeHandler('meta:getTags');
|
|
|
|
expect(mockMetaEngine.getTags).toHaveBeenCalled();
|
|
expect(result).toEqual(tags);
|
|
});
|
|
});
|
|
|
|
describe('meta:getCategories', () => {
|
|
it('should return all categories from MetaEngine', async () => {
|
|
const categories = ['Tutorial', 'News', 'Opinion'];
|
|
mockMetaEngine.getCategories.mockResolvedValue(categories);
|
|
|
|
const result = await invokeHandler('meta:getCategories');
|
|
|
|
expect(mockMetaEngine.getCategories).toHaveBeenCalled();
|
|
expect(result).toEqual(categories);
|
|
});
|
|
});
|
|
|
|
describe('meta:addTag', () => {
|
|
it('should add tag and return updated tags list', async () => {
|
|
const updatedTags = ['existing', 'new-tag'];
|
|
mockMetaEngine.addTag.mockResolvedValue(undefined);
|
|
mockMetaEngine.getTags.mockResolvedValue(updatedTags);
|
|
|
|
const result = await invokeHandler('meta:addTag', 'new-tag');
|
|
|
|
expect(mockMetaEngine.addTag).toHaveBeenCalledWith('new-tag');
|
|
expect(mockMetaEngine.getTags).toHaveBeenCalled();
|
|
expect(result).toEqual(updatedTags);
|
|
});
|
|
});
|
|
|
|
describe('meta:removeTag', () => {
|
|
it('should remove tag and return updated tags list', async () => {
|
|
const remainingTags = ['remaining'];
|
|
mockMetaEngine.removeTag.mockResolvedValue(undefined);
|
|
mockMetaEngine.getTags.mockResolvedValue(remainingTags);
|
|
|
|
const result = await invokeHandler('meta:removeTag', 'to-remove');
|
|
|
|
expect(mockMetaEngine.removeTag).toHaveBeenCalledWith('to-remove');
|
|
expect(result).toEqual(remainingTags);
|
|
});
|
|
});
|
|
|
|
describe('meta:addCategory', () => {
|
|
it('should add category and return updated categories list', async () => {
|
|
const updatedCategories = ['Existing', 'New Category'];
|
|
mockMetaEngine.addCategory.mockResolvedValue(undefined);
|
|
mockMetaEngine.getCategories.mockResolvedValue(updatedCategories);
|
|
|
|
const result = await invokeHandler('meta:addCategory', 'New Category');
|
|
|
|
expect(mockMetaEngine.addCategory).toHaveBeenCalledWith('New Category');
|
|
expect(result).toEqual(updatedCategories);
|
|
});
|
|
});
|
|
|
|
describe('meta:removeCategory', () => {
|
|
it('should remove category and return updated categories list', async () => {
|
|
const remainingCategories = ['Remaining'];
|
|
mockMetaEngine.removeCategory.mockResolvedValue(undefined);
|
|
mockMetaEngine.getCategories.mockResolvedValue(remainingCategories);
|
|
|
|
const result = await invokeHandler('meta:removeCategory', 'To Remove');
|
|
|
|
expect(mockMetaEngine.removeCategory).toHaveBeenCalledWith('To Remove');
|
|
expect(result).toEqual(remainingCategories);
|
|
});
|
|
});
|
|
|
|
describe('meta:syncOnStartup', () => {
|
|
it('should sync metadata and return tags, categories, and project metadata', async () => {
|
|
const tags = ['tag1', 'tag2'];
|
|
const categories = ['Cat1', 'Cat2'];
|
|
const metadata = { name: 'My Blog', mainLanguage: 'en' };
|
|
|
|
mockMetaEngine.syncOnStartup.mockResolvedValue(undefined);
|
|
mockMetaEngine.getTags.mockResolvedValue(tags);
|
|
mockMetaEngine.getCategories.mockResolvedValue(categories);
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue(metadata);
|
|
|
|
const result = await invokeHandler('meta:syncOnStartup');
|
|
|
|
expect(mockMetaEngine.syncOnStartup).toHaveBeenCalled();
|
|
expect(result).toEqual({ tags, categories, projectMetadata: metadata });
|
|
});
|
|
});
|
|
|
|
describe('meta:getCategories', () => {
|
|
it('should set context and sync before returning categories when uninitialized', async () => {
|
|
const activeProject = createMockProject({ id: 'project-cats', dataPath: '/cats/data' });
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(activeProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/resolved/cats-data');
|
|
mockMetaEngine.isInitialized.mockReturnValue(false);
|
|
mockMetaEngine.syncOnStartup.mockResolvedValue(undefined);
|
|
mockMetaEngine.getCategories.mockResolvedValue(['article', 'news', 'travel']);
|
|
|
|
const result = await invokeHandler('meta:getCategories');
|
|
|
|
expect(mockMetaEngine.setProjectContext).toHaveBeenCalledWith('project-cats', '/resolved/cats-data');
|
|
expect(mockMetaEngine.syncOnStartup).toHaveBeenCalled();
|
|
expect(result).toEqual(['article', 'news', 'travel']);
|
|
});
|
|
});
|
|
|
|
describe('meta:getProjectMetadata', () => {
|
|
it('should return project metadata', async () => {
|
|
const metadata = { name: 'Test Blog', description: 'A test blog', mainLanguage: 'de' };
|
|
mockMetaEngine.isInitialized.mockReturnValue(true);
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue(metadata);
|
|
|
|
const result = await invokeHandler('meta:getProjectMetadata');
|
|
|
|
expect(mockMetaEngine.getProjectMetadata).toHaveBeenCalled();
|
|
expect(result).toEqual(metadata);
|
|
});
|
|
|
|
it('should set meta engine context from active project before reading metadata', async () => {
|
|
const activeProject = createMockProject({ id: 'project-ctx', dataPath: '/ctx/data' });
|
|
const metadata = { name: 'Ctx Blog' };
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(activeProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/resolved/ctx-data');
|
|
mockMetaEngine.isInitialized.mockReturnValue(true);
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue(metadata);
|
|
|
|
const result = await invokeHandler('meta:getProjectMetadata');
|
|
|
|
expect(mockMetaEngine.setProjectContext).toHaveBeenCalledWith('project-ctx', '/resolved/ctx-data');
|
|
expect(result).toEqual(metadata);
|
|
});
|
|
|
|
it('should sync metadata before reading when engine is not initialized', async () => {
|
|
const metadata = { name: 'Test Blog', mainLanguage: 'de', defaultAuthor: 'Max' };
|
|
mockMetaEngine.isInitialized.mockReturnValue(false);
|
|
mockMetaEngine.syncOnStartup.mockResolvedValue(undefined);
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue(metadata);
|
|
|
|
const result = await invokeHandler('meta:getProjectMetadata');
|
|
|
|
expect(mockMetaEngine.syncOnStartup).toHaveBeenCalled();
|
|
expect(mockMetaEngine.getProjectMetadata).toHaveBeenCalled();
|
|
expect(result).toEqual(metadata);
|
|
});
|
|
});
|
|
|
|
describe('meta:setProjectMetadata', () => {
|
|
it('should set project metadata and return updated metadata', async () => {
|
|
const newMetadata = { name: 'Updated Blog', description: 'Updated description' };
|
|
mockMetaEngine.setProjectMetadata.mockResolvedValue(undefined);
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue(newMetadata);
|
|
|
|
const result = await invokeHandler('meta:setProjectMetadata', newMetadata);
|
|
|
|
expect(mockMetaEngine.setProjectMetadata).toHaveBeenCalledWith(newMetadata);
|
|
expect(result).toEqual(newMetadata);
|
|
});
|
|
});
|
|
|
|
describe('meta:updateProjectMetadata', () => {
|
|
it('should set meta engine context from active project before updating metadata', async () => {
|
|
const activeProject = createMockProject({ id: 'project-update', dataPath: '/update/data' });
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(activeProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/resolved/update-data');
|
|
mockMetaEngine.updateProjectMetadata.mockResolvedValue(undefined);
|
|
const updatedMetadata = { name: 'Updated' };
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue(updatedMetadata);
|
|
|
|
const updates = { defaultAuthor: 'Author Name' };
|
|
const result = await invokeHandler('meta:updateProjectMetadata', updates);
|
|
|
|
expect(mockMetaEngine.setProjectContext).toHaveBeenCalledWith('project-update', '/resolved/update-data');
|
|
expect(mockMetaEngine.updateProjectMetadata).toHaveBeenCalledWith(updates);
|
|
expect(result).toEqual(updatedMetadata);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============ Menu Handlers ============
|
|
describe('Menu Handlers', () => {
|
|
describe('menu:get', () => {
|
|
it('loads menu for active project context', async () => {
|
|
const activeProject = createMockProject({ id: 'project-42', dataPath: '/custom/data' });
|
|
const menuDocument = {
|
|
items: [
|
|
{ id: 'home', title: 'Home', kind: 'page', pageSlug: 'home', children: [] },
|
|
],
|
|
};
|
|
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(activeProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/resolved/project-data');
|
|
mockMenuEngine.getMenu.mockResolvedValue(menuDocument);
|
|
|
|
const result = await invokeHandler('menu:get');
|
|
|
|
expect(mockMenuEngine.setProjectContext).toHaveBeenCalledWith('project-42', '/resolved/project-data');
|
|
expect(mockMenuEngine.getMenu).toHaveBeenCalled();
|
|
expect(result).toEqual(menuDocument);
|
|
});
|
|
});
|
|
|
|
describe('menu:save', () => {
|
|
it('saves menu for active project context', async () => {
|
|
const activeProject = createMockProject({ id: 'project-24', dataPath: '/custom/data' });
|
|
const menuDocument = {
|
|
items: [
|
|
{ id: 'docs', title: 'Docs', kind: 'submenu', children: [] },
|
|
],
|
|
};
|
|
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(activeProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/resolved/project-data');
|
|
mockMenuEngine.saveMenu.mockResolvedValue(menuDocument);
|
|
|
|
const result = await invokeHandler('menu:save', menuDocument);
|
|
|
|
expect(mockMenuEngine.setProjectContext).toHaveBeenCalledWith('project-24', '/resolved/project-data');
|
|
expect(mockMenuEngine.saveMenu).toHaveBeenCalledWith(menuDocument);
|
|
expect(result).toEqual(menuDocument);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============ Task Handlers ============
|
|
describe('Task Handlers', () => {
|
|
describe('tasks:getAll', () => {
|
|
it('should return all tasks from TaskManager', async () => {
|
|
const tasks = [
|
|
{ id: 'task-1', name: 'Import Media', progress: 50, status: 'running' },
|
|
{ id: 'task-2', name: 'Sync', progress: 100, status: 'completed' },
|
|
];
|
|
mockTaskManager.getAllTasks.mockReturnValue(tasks);
|
|
|
|
const result = await invokeHandler('tasks:getAll');
|
|
|
|
expect(mockTaskManager.getAllTasks).toHaveBeenCalled();
|
|
expect(result).toEqual(tasks);
|
|
});
|
|
});
|
|
|
|
describe('tasks:cancel', () => {
|
|
it('should cancel a task by id', async () => {
|
|
mockTaskManager.cancelTask.mockReturnValue(true);
|
|
|
|
const result = await invokeHandler('tasks:cancel', 'task-to-cancel');
|
|
|
|
expect(mockTaskManager.cancelTask).toHaveBeenCalledWith('task-to-cancel');
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============ Post-Media Handlers ============
|
|
describe('Post-Media Handlers', () => {
|
|
describe('postMedia:link', () => {
|
|
it('should link media to post', async () => {
|
|
mockPostMediaEngine.linkMediaToPost.mockResolvedValue(undefined);
|
|
|
|
await invokeHandler('postMedia:link', 'post-1', 'media-1');
|
|
|
|
expect(mockPostMediaEngine.linkMediaToPost).toHaveBeenCalledWith('post-1', 'media-1');
|
|
});
|
|
});
|
|
|
|
describe('postMedia:unlink', () => {
|
|
it('should unlink media from post', async () => {
|
|
mockPostMediaEngine.unlinkMediaFromPost.mockResolvedValue(undefined);
|
|
|
|
await invokeHandler('postMedia:unlink', 'post-1', 'media-1');
|
|
|
|
expect(mockPostMediaEngine.unlinkMediaFromPost).toHaveBeenCalledWith('post-1', 'media-1');
|
|
});
|
|
});
|
|
|
|
describe('postMedia:linkMany', () => {
|
|
it('should batch link multiple media to post', async () => {
|
|
const batchResult = { linked: ['media-1', 'media-2'], skipped: [] };
|
|
mockPostMediaEngine.linkManyToPost.mockResolvedValue(batchResult);
|
|
const mediaIds = ['media-1', 'media-2'];
|
|
|
|
const result = await invokeHandler('postMedia:linkMany', 'post-1', mediaIds);
|
|
|
|
expect(mockPostMediaEngine.linkManyToPost).toHaveBeenCalledWith('post-1', mediaIds);
|
|
expect(result).toEqual(batchResult);
|
|
});
|
|
});
|
|
|
|
describe('postMedia:unlinkMany', () => {
|
|
it('should batch unlink multiple media from post', async () => {
|
|
const batchResult = { unlinked: ['media-1', 'media-2'] };
|
|
mockPostMediaEngine.unlinkManyFromPost.mockResolvedValue(batchResult);
|
|
const mediaIds = ['media-1', 'media-2'];
|
|
|
|
const result = await invokeHandler('postMedia:unlinkMany', 'post-1', mediaIds);
|
|
|
|
expect(mockPostMediaEngine.unlinkManyFromPost).toHaveBeenCalledWith('post-1', mediaIds);
|
|
expect(result).toEqual(batchResult);
|
|
});
|
|
});
|
|
|
|
describe('postMedia:getForPost', () => {
|
|
it('should return media linked to a post', async () => {
|
|
const linkedMedia = [createMockMedia(), createMockMedia()];
|
|
mockPostMediaEngine.getLinkedMediaForPost.mockResolvedValue(linkedMedia);
|
|
|
|
const result = await invokeHandler('postMedia:getForPost', 'post-1');
|
|
|
|
expect(mockPostMediaEngine.getLinkedMediaForPost).toHaveBeenCalledWith('post-1');
|
|
expect(result).toEqual(linkedMedia);
|
|
});
|
|
});
|
|
|
|
describe('postMedia:getForMedia', () => {
|
|
it('should return posts linked to a media item', async () => {
|
|
const linkedPosts = [createMockPost(), createMockPost()];
|
|
mockPostMediaEngine.getLinkedPostsForMedia.mockResolvedValue(linkedPosts);
|
|
|
|
const result = await invokeHandler('postMedia:getForMedia', 'media-1');
|
|
|
|
expect(mockPostMediaEngine.getLinkedPostsForMedia).toHaveBeenCalledWith('media-1');
|
|
expect(result).toEqual(linkedPosts);
|
|
});
|
|
});
|
|
|
|
describe('postMedia:reorder', () => {
|
|
it('should reorder media for a post', async () => {
|
|
mockPostMediaEngine.reorderMediaForPost.mockResolvedValue(undefined);
|
|
const newOrder = ['media-2', 'media-1', 'media-3'];
|
|
|
|
await invokeHandler('postMedia:reorder', 'post-1', newOrder);
|
|
|
|
expect(mockPostMediaEngine.reorderMediaForPost).toHaveBeenCalledWith('post-1', newOrder);
|
|
});
|
|
});
|
|
|
|
describe('postMedia:isLinked', () => {
|
|
it('should check if media is linked to post', async () => {
|
|
mockPostMediaEngine.isMediaLinkedToPost.mockResolvedValue(true);
|
|
|
|
const result = await invokeHandler('postMedia:isLinked', 'post-1', 'media-1');
|
|
|
|
expect(mockPostMediaEngine.isMediaLinkedToPost).toHaveBeenCalledWith('post-1', 'media-1');
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============ App Handlers ============
|
|
describe('App Handlers', () => {
|
|
describe('app:getDataPaths', () => {
|
|
it('should return data paths from database and project', async () => {
|
|
const mockProject = createMockProject({ id: 'active-proj' });
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
|
mockProjectEngine.getProjectPaths.mockReturnValue({
|
|
posts: '/mock/data/posts',
|
|
media: '/mock/data/media',
|
|
});
|
|
|
|
const result = await invokeHandler('app:getDataPaths');
|
|
|
|
expect(mockProjectEngine.getActiveProject).toHaveBeenCalled();
|
|
expect(mockProjectEngine.getProjectPaths).toHaveBeenCalledWith('active-proj', undefined);
|
|
expect(result).toEqual({
|
|
database: '/mock/data/bds.db',
|
|
posts: '/mock/data/posts',
|
|
media: '/mock/data/media',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('app:getDefaultProjectPath', () => {
|
|
it('should return default project base directory', async () => {
|
|
mockProjectEngine.getDefaultProjectBaseDir.mockReturnValue('/Users/test/bds/project-1');
|
|
|
|
const result = await invokeHandler('app:getDefaultProjectPath', 'project-1');
|
|
|
|
expect(mockProjectEngine.getDefaultProjectBaseDir).toHaveBeenCalledWith('project-1');
|
|
expect(result).toBe('/Users/test/bds/project-1');
|
|
});
|
|
});
|
|
|
|
describe('app:getTitleBarMetrics', () => {
|
|
it('should return dynamic macOS title bar left inset from native window button position', async () => {
|
|
const { BrowserWindow } = await import('electron');
|
|
const sender = {};
|
|
const event = { sender };
|
|
|
|
vi.mocked(BrowserWindow.fromWebContents).mockReturnValue({
|
|
getWindowButtonPosition: vi.fn(() => ({ x: 14, y: 14 })),
|
|
} as unknown as ReturnType<typeof BrowserWindow.fromWebContents>);
|
|
|
|
const result = await invokeHandlerWithEvent(event, 'app:getTitleBarMetrics');
|
|
|
|
expect(BrowserWindow.fromWebContents).toHaveBeenCalledWith(sender);
|
|
expect(result).toEqual({
|
|
macosLeftInset: 78,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('app:triggerMenuAction', () => {
|
|
it('should forward custom titlebar action to renderer menu channel', async () => {
|
|
const send = vi.fn();
|
|
const event = { sender: { send } };
|
|
|
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'newPost');
|
|
|
|
expect(send).toHaveBeenCalledWith('menu:newPost');
|
|
});
|
|
|
|
it('should execute default edit actions on webContents sender', async () => {
|
|
const undo = vi.fn();
|
|
const send = vi.fn();
|
|
const event = { sender: { undo, send } };
|
|
|
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'undo');
|
|
|
|
expect(undo).toHaveBeenCalled();
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should execute toggleDevTools on sender when action is toggleDevTools', async () => {
|
|
const toggleDevTools = vi.fn();
|
|
const send = vi.fn();
|
|
const event = { sender: { toggleDevTools, send } };
|
|
|
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'toggleDevTools');
|
|
|
|
expect(toggleDevTools).toHaveBeenCalled();
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should quit the application when action is quit', async () => {
|
|
const { app } = await import('electron');
|
|
const send = vi.fn();
|
|
const event = { sender: { send } };
|
|
|
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'quit');
|
|
|
|
expect(app.quit).toHaveBeenCalled();
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should open repository URL when action is viewOnGitHub', async () => {
|
|
const { shell } = await import('electron');
|
|
const send = vi.fn();
|
|
const event = { sender: { send } };
|
|
|
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'viewOnGitHub');
|
|
|
|
expect(shell.openExternal).toHaveBeenCalledWith('https://github.com/rfc1437/bDS');
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should open preview root URL when action is openInBrowser', async () => {
|
|
const { shell } = await import('electron');
|
|
const send = vi.fn();
|
|
const event = { sender: { send } };
|
|
|
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'openInBrowser');
|
|
|
|
expect(shell.openExternal).toHaveBeenCalledWith('http://localhost:4123/');
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should open the data folder when action is openDataFolder', async () => {
|
|
const { shell } = await import('electron');
|
|
const send = vi.fn();
|
|
const event = { sender: { send } };
|
|
|
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'openDataFolder');
|
|
|
|
expect(shell.openPath).toHaveBeenCalledWith('/mock/data');
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should forward previewPost to renderer menu channel', async () => {
|
|
const send = vi.fn();
|
|
const event = { sender: { send } };
|
|
|
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'previewPost');
|
|
|
|
expect(send).toHaveBeenCalledWith('menu:previewPost');
|
|
});
|
|
|
|
it('should reload sender when action is reload', async () => {
|
|
const reload = vi.fn();
|
|
const send = vi.fn();
|
|
const event = { sender: { reload, send } };
|
|
|
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'reload');
|
|
|
|
expect(reload).toHaveBeenCalled();
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should force reload sender when action is forceReload', async () => {
|
|
const reloadIgnoringCache = vi.fn();
|
|
const send = vi.fn();
|
|
const event = { sender: { reloadIgnoringCache, send } };
|
|
|
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'forceReload');
|
|
|
|
expect(reloadIgnoringCache).toHaveBeenCalled();
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should reset zoom level when action is resetZoom', async () => {
|
|
const setZoomLevel = vi.fn();
|
|
const send = vi.fn();
|
|
const event = { sender: { setZoomLevel, send } };
|
|
|
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'resetZoom');
|
|
|
|
expect(setZoomLevel).toHaveBeenCalledWith(0);
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should zoom in when action is zoomIn', async () => {
|
|
const getZoomLevel = vi.fn(() => 0);
|
|
const setZoomLevel = vi.fn();
|
|
const send = vi.fn();
|
|
const event = { sender: { getZoomLevel, setZoomLevel, send } };
|
|
|
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'zoomIn');
|
|
|
|
expect(setZoomLevel).toHaveBeenCalledWith(0.5);
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should zoom out when action is zoomOut', async () => {
|
|
const getZoomLevel = vi.fn(() => 0.5);
|
|
const setZoomLevel = vi.fn();
|
|
const send = vi.fn();
|
|
const event = { sender: { getZoomLevel, setZoomLevel, send } };
|
|
|
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'zoomOut');
|
|
|
|
expect(setZoomLevel).toHaveBeenCalledWith(0);
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should toggle fullscreen on owner window when action is toggleFullScreen', async () => {
|
|
const { BrowserWindow } = await import('electron');
|
|
const sender = { send: vi.fn() };
|
|
const ownerWindow = {
|
|
isFullScreen: vi.fn(() => false),
|
|
setFullScreen: vi.fn(),
|
|
};
|
|
|
|
vi.mocked(BrowserWindow.fromWebContents).mockReturnValue(ownerWindow as unknown as ReturnType<typeof BrowserWindow.fromWebContents>);
|
|
|
|
await invokeHandlerWithEvent({ sender }, 'app:triggerMenuAction', 'toggleFullScreen');
|
|
|
|
expect(BrowserWindow.fromWebContents).toHaveBeenCalledWith(sender);
|
|
expect(ownerWindow.setFullScreen).toHaveBeenCalledWith(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============ Blog Handlers ============
|
|
describe('Blog Handlers', () => {
|
|
describe('blog:generateSitemap', () => {
|
|
it('should start section tasks without waiting for core completion', async () => {
|
|
const mockProject = createMockProject({
|
|
id: 'test-project',
|
|
dataPath: '/mock/data',
|
|
});
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
|
name: 'Test Project',
|
|
publicUrl: 'https://blog.example.com',
|
|
});
|
|
|
|
let resolveCoreTask: ((value: any) => void) | null = null;
|
|
const startedTaskNames: string[] = [];
|
|
const completedResult = {
|
|
path: '/mock/data/dir/html/sitemap.xml',
|
|
urlCount: 1,
|
|
postCount: 0,
|
|
feedPostCount: 0,
|
|
tagCount: 0,
|
|
categoryCount: 0,
|
|
archiveCount: 0,
|
|
pagesGenerated: 1,
|
|
feeds: {
|
|
rssPath: '/mock/data/dir/html/rss.xml',
|
|
atomPath: '/mock/data/dir/html/atom.xml',
|
|
},
|
|
changed: {
|
|
sitemap: true,
|
|
rss: true,
|
|
atom: true,
|
|
},
|
|
};
|
|
|
|
mockTaskManager.runTask.mockImplementation((task: any) => {
|
|
startedTaskNames.push(task.name);
|
|
if (task.name === 'Render Site Core') {
|
|
return new Promise((resolve) => {
|
|
resolveCoreTask = resolve;
|
|
});
|
|
}
|
|
return Promise.resolve(completedResult);
|
|
});
|
|
|
|
const generationPromise = invokeHandler('blog:generateSitemap');
|
|
|
|
for (let index = 0; index < 20 && startedTaskNames.length === 0; index += 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
expect(startedTaskNames).toContain('Render Site Core');
|
|
expect(startedTaskNames).toContain('Render Single Posts');
|
|
expect(startedTaskNames).toContain('Render Category Archives');
|
|
expect(startedTaskNames).toContain('Render Tag Archives');
|
|
expect(startedTaskNames).toContain('Render Date Archives');
|
|
|
|
expect(resolveCoreTask).toBeTruthy();
|
|
resolveCoreTask?.(completedResult);
|
|
|
|
await generationPromise;
|
|
});
|
|
|
|
it('should create separate background tasks for single, category, tag, and date rendering', async () => {
|
|
const mockProject = createMockProject({
|
|
id: 'test-project',
|
|
dataPath: '/mock/data',
|
|
});
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
|
name: 'Test Project',
|
|
publicUrl: 'https://blog.example.com',
|
|
});
|
|
|
|
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
|
if (filter.status === 'published') {
|
|
return [
|
|
{
|
|
id: 'post-1',
|
|
projectId: 'test-project',
|
|
title: 'Test Post',
|
|
slug: 'test-post',
|
|
excerpt: '',
|
|
content: '# Test',
|
|
status: 'published',
|
|
createdAt: new Date('2024-01-15T10:00:00Z'),
|
|
updatedAt: new Date('2024-01-20T15:00:00Z'),
|
|
publishedAt: new Date('2024-01-15T10:00:00Z'),
|
|
tags: ['tag1'],
|
|
categories: ['category1'],
|
|
},
|
|
];
|
|
}
|
|
if (filter.status === 'draft') {
|
|
return [];
|
|
}
|
|
return [];
|
|
});
|
|
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
|
|
|
|
const { writeFile, mkdir } = await import('fs/promises');
|
|
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
|
|
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
|
return task.execute(vi.fn());
|
|
});
|
|
|
|
await invokeHandler('blog:generateSitemap');
|
|
|
|
const names = mockTaskManager.runTask.mock.calls.map((call: any[]) => call[0]?.name);
|
|
expect(names).toContain('Render Site Core');
|
|
expect(names).toContain('Render Single Posts');
|
|
expect(names).toContain('Render Category Archives');
|
|
expect(names).toContain('Render Tag Archives');
|
|
expect(names).toContain('Render Date Archives');
|
|
});
|
|
|
|
it('should call taskManager.runTask with sitemap generation task', async () => {
|
|
const mockProject = createMockProject({
|
|
id: 'test-project',
|
|
dataPath: '/mock/data'
|
|
});
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
|
name: 'Test Project',
|
|
publicUrl: 'https://blog.example.com',
|
|
});
|
|
|
|
// Mock post engine to return published posts and drafts
|
|
const mockPublishedPosts = [
|
|
{
|
|
id: 'post-1',
|
|
projectId: 'test-project',
|
|
slug: 'test-post',
|
|
status: 'published',
|
|
createdAt: new Date('2024-01-15T10:00:00Z'),
|
|
updatedAt: new Date('2024-01-20T15:00:00Z'),
|
|
tags: ['tag1', 'tag2'],
|
|
categories: ['category1'],
|
|
},
|
|
{
|
|
id: 'post-2',
|
|
projectId: 'test-project',
|
|
slug: 'another-post',
|
|
status: 'published',
|
|
createdAt: new Date('2024-02-10T12:00:00Z'),
|
|
updatedAt: new Date('2024-02-12T09:00:00Z'),
|
|
tags: ['tag2', 'tag3'],
|
|
categories: ['category2'],
|
|
},
|
|
];
|
|
|
|
const mockDraftPosts = [
|
|
{
|
|
id: 'post-3',
|
|
projectId: 'test-project',
|
|
slug: 'draft-post',
|
|
status: 'draft',
|
|
createdAt: new Date('2024-03-01T08:00:00Z'),
|
|
updatedAt: new Date('2024-03-01T08:00:00Z'),
|
|
tags: [],
|
|
categories: [],
|
|
},
|
|
];
|
|
|
|
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
|
if (filter.status === 'published') {
|
|
return mockPublishedPosts;
|
|
}
|
|
if (filter.status === 'draft') {
|
|
return mockDraftPosts;
|
|
}
|
|
return [];
|
|
});
|
|
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
|
|
|
|
// Mock fs.writeFile
|
|
const { writeFile, mkdir } = await import('fs/promises');
|
|
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
|
|
// Mock taskManager.runTask to execute the task immediately
|
|
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
|
const onProgress = vi.fn();
|
|
return await task.execute(onProgress);
|
|
});
|
|
|
|
const result = await invokeHandler('blog:generateSitemap');
|
|
|
|
// Verify taskManager.runTask was called for core task orchestration
|
|
expect(mockTaskManager.runTask).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: expect.stringMatching(/^site-render-core-\d+$/),
|
|
name: 'Render Site Core',
|
|
execute: expect.any(Function),
|
|
})
|
|
);
|
|
|
|
// Verify result contains expected data
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
path: expect.stringContaining('sitemap.xml'),
|
|
postCount: 2, // Only published posts, not drafts
|
|
tagCount: 3, // tag1, tag2, tag3
|
|
categoryCount: 2, // category1, category2
|
|
})
|
|
);
|
|
|
|
// Verify fs operations
|
|
expect(mkdir).toHaveBeenCalledWith('/mock/data/dir/html', { recursive: true });
|
|
expect(writeFile).toHaveBeenCalledWith(
|
|
expect.stringContaining('sitemap.xml'),
|
|
expect.stringContaining('<?xml version="1.0" encoding="UTF-8"?>'),
|
|
'utf-8'
|
|
);
|
|
});
|
|
|
|
it('should generate rss and atom feeds with newest maxPostsPerPage published snapshots', async () => {
|
|
const mockProject = createMockProject({
|
|
id: 'test-project',
|
|
dataPath: '/mock/data',
|
|
});
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
|
name: 'Test Project',
|
|
description: 'Test Description',
|
|
publicUrl: 'https://blog.example.com',
|
|
maxPostsPerPage: 1,
|
|
});
|
|
|
|
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
|
if (filter.status === 'published') {
|
|
return [
|
|
{
|
|
id: 'post-new',
|
|
projectId: 'test-project',
|
|
title: 'Newest <Post>',
|
|
slug: 'newest-post',
|
|
excerpt: '',
|
|
content: '',
|
|
status: 'published',
|
|
createdAt: new Date('2024-03-05T10:00:00Z'),
|
|
updatedAt: new Date('2024-03-05T11:00:00Z'),
|
|
publishedAt: new Date('2024-03-05T10:00:00Z'),
|
|
tags: ['tag-one'],
|
|
categories: ['category-one'],
|
|
},
|
|
{
|
|
id: 'post-old',
|
|
projectId: 'test-project',
|
|
title: 'Old Post',
|
|
slug: 'old-post',
|
|
excerpt: '',
|
|
content: '',
|
|
status: 'published',
|
|
createdAt: new Date('2024-02-01T10:00:00Z'),
|
|
updatedAt: new Date('2024-02-01T11:00:00Z'),
|
|
publishedAt: new Date('2024-02-01T10:00:00Z'),
|
|
tags: ['tag-two'],
|
|
categories: ['category-two'],
|
|
},
|
|
];
|
|
}
|
|
if (filter.status === 'draft') {
|
|
return [];
|
|
}
|
|
return [];
|
|
});
|
|
mockPostEngine.getPublishedVersion.mockImplementation(async (id: string) => {
|
|
if (id !== 'post-new') return null;
|
|
return {
|
|
id: 'post-new',
|
|
projectId: 'test-project',
|
|
title: 'Newest <Post>',
|
|
slug: 'newest-post',
|
|
excerpt: undefined,
|
|
content: 'First paragraph with <tag> & symbol.\n\nSecond paragraph.',
|
|
status: 'published',
|
|
author: 'Author A',
|
|
createdAt: new Date('2024-03-05T10:00:00Z'),
|
|
updatedAt: new Date('2024-03-05T11:00:00Z'),
|
|
publishedAt: new Date('2024-03-05T10:00:00Z'),
|
|
tags: ['tag-one'],
|
|
categories: ['category-one'],
|
|
};
|
|
});
|
|
|
|
const { writeFile, mkdir } = await import('fs/promises');
|
|
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
|
|
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
|
const onProgress = vi.fn();
|
|
return await task.execute(onProgress);
|
|
});
|
|
|
|
await invokeHandler('blog:generateSitemap');
|
|
|
|
const writtenFiles = vi.mocked(writeFile).mock.calls.map(([filePath, body]) => ({
|
|
filePath: filePath as string,
|
|
body: body as string,
|
|
}));
|
|
const rss = writtenFiles.find((entry) => entry.filePath.endsWith('/rss.xml'))?.body;
|
|
const atom = writtenFiles.find((entry) => entry.filePath.endsWith('/atom.xml'))?.body;
|
|
|
|
expect(rss).toBeTruthy();
|
|
expect(atom).toBeTruthy();
|
|
expect(rss).toContain('newest-post');
|
|
expect(rss).not.toContain('old-post');
|
|
expect(atom).toContain('newest-post');
|
|
expect(atom).not.toContain('old-post');
|
|
expect(rss).toContain('Newest <Post>');
|
|
expect(rss).toContain('First paragraph with <tag> & symbol.');
|
|
expect(atom).toContain('<category term="tag-one"');
|
|
expect(atom).toContain('<category term="category-one"');
|
|
});
|
|
|
|
it('should skip rewriting sitemap and feeds when content hash is unchanged', async () => {
|
|
const mockProject = createMockProject({
|
|
id: 'test-project',
|
|
dataPath: '/mock/data',
|
|
});
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
|
name: 'Test Project',
|
|
publicUrl: 'https://blog.example.com',
|
|
maxPostsPerPage: 5,
|
|
});
|
|
|
|
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
|
if (filter.status === 'published') {
|
|
return [
|
|
{
|
|
id: 'post-1',
|
|
projectId: 'test-project',
|
|
title: 'Hash test',
|
|
slug: 'hash-test',
|
|
excerpt: 'Hash excerpt',
|
|
content: '',
|
|
status: 'published',
|
|
createdAt: new Date('2024-01-15T10:00:00Z'),
|
|
updatedAt: new Date('2024-01-20T15:00:00Z'),
|
|
publishedAt: new Date('2024-01-15T10:00:00Z'),
|
|
tags: [],
|
|
categories: [],
|
|
},
|
|
];
|
|
}
|
|
if (filter.status === 'draft') {
|
|
return [];
|
|
}
|
|
return [];
|
|
});
|
|
mockPostEngine.getPublishedVersion.mockImplementation(async () => ({
|
|
id: 'post-1',
|
|
projectId: 'test-project',
|
|
title: 'Hash test',
|
|
slug: 'hash-test',
|
|
excerpt: 'Hash excerpt',
|
|
content: 'Hash content',
|
|
status: 'published',
|
|
createdAt: new Date('2024-01-15T10:00:00Z'),
|
|
updatedAt: new Date('2024-01-20T15:00:00Z'),
|
|
publishedAt: new Date('2024-01-15T10:00:00Z'),
|
|
tags: [],
|
|
categories: [],
|
|
}));
|
|
|
|
const { writeFile, mkdir } = await import('fs/promises');
|
|
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
|
|
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
|
const onProgress = vi.fn();
|
|
return await task.execute(onProgress);
|
|
});
|
|
|
|
await invokeHandler('blog:generateSitemap');
|
|
vi.mocked(writeFile).mockClear();
|
|
await invokeHandler('blog:generateSitemap');
|
|
|
|
// Assets are always copied, but sitemap/feeds/pages should not be rewritten
|
|
const xmlWrites = vi.mocked(writeFile).mock.calls.filter(
|
|
([filePath]) => typeof filePath === 'string' && (filePath.endsWith('.xml') || filePath.endsWith('index.html')),
|
|
);
|
|
expect(xmlWrites).toHaveLength(0);
|
|
});
|
|
|
|
it('should throw error when no active project', async () => {
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(null);
|
|
|
|
await expect(invokeHandler('blog:generateSitemap')).rejects.toThrow('No active project');
|
|
|
|
expect(mockTaskManager.runTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should filter out draft and archived posts from sitemap', async () => {
|
|
const mockProject = createMockProject({
|
|
id: 'test-project',
|
|
dataPath: '/mock/data'
|
|
});
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
|
name: 'Test Project',
|
|
publicUrl: 'https://blog.example.com',
|
|
});
|
|
|
|
const mockPublishedPosts = [
|
|
{
|
|
id: 'post-1',
|
|
projectId: 'test-project',
|
|
slug: 'published-post',
|
|
status: 'published',
|
|
createdAt: new Date('2024-01-15T10:00:00Z'),
|
|
updatedAt: new Date('2024-01-20T15:00:00Z'),
|
|
tags: [],
|
|
categories: [],
|
|
},
|
|
];
|
|
|
|
const mockDraftPosts = [
|
|
{
|
|
id: 'post-2',
|
|
projectId: 'test-project',
|
|
slug: 'draft-post',
|
|
status: 'draft',
|
|
createdAt: new Date('2024-02-10T12:00:00Z'),
|
|
updatedAt: new Date('2024-02-12T09:00:00Z'),
|
|
tags: [],
|
|
categories: [],
|
|
},
|
|
];
|
|
|
|
const mockArchivedPosts = [
|
|
{
|
|
id: 'post-3',
|
|
projectId: 'test-project',
|
|
slug: 'archived-post',
|
|
status: 'archived',
|
|
createdAt: new Date('2024-03-01T08:00:00Z'),
|
|
updatedAt: new Date('2024-03-01T08:00:00Z'),
|
|
tags: [],
|
|
categories: [],
|
|
},
|
|
];
|
|
|
|
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
|
if (filter.status === 'published') {
|
|
return mockPublishedPosts;
|
|
}
|
|
if (filter.status === 'draft') {
|
|
return mockDraftPosts;
|
|
}
|
|
if (filter.status === 'archived') {
|
|
return mockArchivedPosts;
|
|
}
|
|
return [];
|
|
});
|
|
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
|
|
|
|
const { writeFile, mkdir } = await import('fs/promises');
|
|
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
|
|
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
|
const onProgress = vi.fn();
|
|
return await task.execute(onProgress);
|
|
});
|
|
|
|
const result = await invokeHandler('blog:generateSitemap');
|
|
|
|
// Verify only published posts are included
|
|
expect(result.postCount).toBe(1);
|
|
|
|
// Verify the sitemap XML only contains the published post
|
|
const writeFileCall = vi.mocked(writeFile).mock.calls[0];
|
|
const sitemapXml = writeFileCall[1] as string;
|
|
|
|
expect(sitemapXml).toContain('published-post');
|
|
expect(sitemapXml).not.toContain('draft-post');
|
|
expect(sitemapXml).not.toContain('archived-post');
|
|
});
|
|
|
|
it('should include published snapshot for drafts with a former published version', async () => {
|
|
const mockProject = createMockProject({
|
|
id: 'test-project',
|
|
dataPath: '/mock/data',
|
|
});
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
|
name: 'Test Project',
|
|
publicUrl: 'https://blog.example.com',
|
|
});
|
|
|
|
const publishedPost = {
|
|
id: 'post-published',
|
|
projectId: 'test-project',
|
|
slug: 'published-post',
|
|
status: 'published',
|
|
createdAt: new Date('2024-01-15T10:00:00Z'),
|
|
updatedAt: new Date('2024-01-20T15:00:00Z'),
|
|
tags: [],
|
|
categories: [],
|
|
};
|
|
|
|
const neverPublishedDraft = {
|
|
id: 'post-draft-new',
|
|
projectId: 'test-project',
|
|
slug: 'draft-no-published-version',
|
|
status: 'draft',
|
|
createdAt: new Date('2024-02-10T12:00:00Z'),
|
|
updatedAt: new Date('2024-02-12T09:00:00Z'),
|
|
tags: [],
|
|
categories: [],
|
|
};
|
|
|
|
const draftWithPublishedVersion = {
|
|
id: 'post-draft-with-published',
|
|
projectId: 'test-project',
|
|
slug: 'draft-current-slug',
|
|
status: 'draft',
|
|
createdAt: new Date('2024-03-01T08:00:00Z'),
|
|
updatedAt: new Date('2024-03-03T08:00:00Z'),
|
|
tags: [],
|
|
categories: [],
|
|
};
|
|
|
|
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
|
if (filter.status === 'published') {
|
|
return [publishedPost];
|
|
}
|
|
if (filter.status === 'draft') {
|
|
return [neverPublishedDraft, draftWithPublishedVersion];
|
|
}
|
|
return [];
|
|
});
|
|
|
|
mockPostEngine.getPublishedVersion.mockImplementation(async (id: string) => {
|
|
if (id !== 'post-draft-with-published') {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id,
|
|
projectId: 'test-project',
|
|
slug: 'published-snapshot-slug',
|
|
status: 'published',
|
|
createdAt: new Date('2023-10-05T07:00:00Z'),
|
|
updatedAt: new Date('2023-10-20T09:00:00Z'),
|
|
tags: [],
|
|
categories: [],
|
|
};
|
|
});
|
|
|
|
const { writeFile, mkdir } = await import('fs/promises');
|
|
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
|
|
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
|
const onProgress = vi.fn();
|
|
return await task.execute(onProgress);
|
|
});
|
|
|
|
const result = await invokeHandler('blog:generateSitemap');
|
|
|
|
expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith({ status: 'published' });
|
|
expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith({ status: 'draft' });
|
|
expect(mockPostEngine.getPublishedVersion).toHaveBeenCalledWith('post-draft-new');
|
|
expect(mockPostEngine.getPublishedVersion).toHaveBeenCalledWith('post-draft-with-published');
|
|
|
|
expect(result.postCount).toBe(2);
|
|
|
|
const writeFileCall = vi.mocked(writeFile).mock.calls[0];
|
|
const sitemapXml = writeFileCall[1] as string;
|
|
|
|
expect(sitemapXml).toContain('published-post');
|
|
expect(sitemapXml).toContain('published-snapshot-slug');
|
|
expect(sitemapXml).not.toContain('draft-no-published-version');
|
|
expect(sitemapXml).not.toContain('draft-current-slug');
|
|
});
|
|
|
|
it('should use canonical path helpers for post URLs', async () => {
|
|
const mockProject = createMockProject({
|
|
id: 'test-project',
|
|
dataPath: '/mock/data'
|
|
});
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
|
name: 'Test Project',
|
|
publicUrl: 'https://blog.example.com',
|
|
});
|
|
|
|
const mockPublishedPosts = [
|
|
{
|
|
id: 'post-1',
|
|
projectId: 'test-project',
|
|
slug: 'my-test-post',
|
|
status: 'published',
|
|
createdAt: new Date('2024-03-25T10:00:00Z'),
|
|
updatedAt: new Date('2024-03-26T15:00:00Z'),
|
|
tags: [],
|
|
categories: [],
|
|
},
|
|
];
|
|
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
|
if (filter.status === 'published') {
|
|
return mockPublishedPosts;
|
|
}
|
|
if (filter.status === 'draft') {
|
|
return [];
|
|
}
|
|
return [];
|
|
});
|
|
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
|
|
|
|
const { writeFile, mkdir } = await import('fs/promises');
|
|
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
|
|
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
|
const onProgress = vi.fn();
|
|
return await task.execute(onProgress);
|
|
});
|
|
|
|
await invokeHandler('blog:generateSitemap');
|
|
|
|
const writeFileCall = vi.mocked(writeFile).mock.calls[0];
|
|
const sitemapXml = writeFileCall[1] as string;
|
|
|
|
// Verify canonical URL format: /YYYY/MM/DD/slug
|
|
expect(sitemapXml).toContain('https://blog.example.com/2024/03/25/my-test-post');
|
|
});
|
|
|
|
it('should show setup dialog and abort when project public URL is missing', async () => {
|
|
const mockProject = createMockProject({
|
|
id: 'test-project',
|
|
dataPath: '/mock/data',
|
|
});
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
|
name: 'Test Project',
|
|
});
|
|
|
|
const { dialog } = await import('electron');
|
|
|
|
await expect(invokeHandler('blog:generateSitemap')).rejects.toThrow('Project public URL is not configured');
|
|
|
|
expect(dialog.showMessageBox).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
type: 'warning',
|
|
title: 'Public URL Required',
|
|
}),
|
|
);
|
|
expect(mockTaskManager.runTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should use project public URL from metadata as sitemap base URL', async () => {
|
|
const mockProject = createMockProject({
|
|
id: 'test-project',
|
|
dataPath: '/mock/data',
|
|
});
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
|
name: 'Test Project',
|
|
publicUrl: 'https://blog.example.com/',
|
|
});
|
|
|
|
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
|
if (filter.status === 'published') {
|
|
return [
|
|
{
|
|
id: 'post-1',
|
|
projectId: 'test-project',
|
|
slug: 'public-url-test-post',
|
|
status: 'published',
|
|
createdAt: new Date('2024-03-25T10:00:00Z'),
|
|
updatedAt: new Date('2024-03-26T15:00:00Z'),
|
|
tags: [],
|
|
categories: [],
|
|
},
|
|
];
|
|
}
|
|
if (filter.status === 'draft') {
|
|
return [];
|
|
}
|
|
return [];
|
|
});
|
|
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
|
|
|
|
const { writeFile, mkdir } = await import('fs/promises');
|
|
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
|
|
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
|
const onProgress = vi.fn();
|
|
return await task.execute(onProgress);
|
|
});
|
|
|
|
await invokeHandler('blog:generateSitemap');
|
|
|
|
const writeFileCall = vi.mocked(writeFile).mock.calls[0];
|
|
const sitemapXml = writeFileCall[1] as string;
|
|
|
|
expect(sitemapXml).toContain('https://blog.example.com/2024/03/25/public-url-test-post');
|
|
expect(sitemapXml).not.toContain('http://127.0.0.1:4123/2024/03/25/public-url-test-post');
|
|
});
|
|
});
|
|
|
|
describe('blog:validateSite', () => {
|
|
it('should generate sitemap-only validation report against html folder', async () => {
|
|
const mockProject = createMockProject({
|
|
id: 'test-project',
|
|
dataPath: '/mock/data',
|
|
});
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
|
name: 'Test Project',
|
|
publicUrl: 'https://blog.example.com',
|
|
});
|
|
|
|
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
|
if (filter.status === 'published') {
|
|
return [
|
|
{
|
|
id: 'post-1',
|
|
projectId: 'test-project',
|
|
title: 'Test Post',
|
|
slug: 'test-post',
|
|
excerpt: '',
|
|
content: '# Test',
|
|
status: 'published',
|
|
createdAt: new Date('2024-01-15T10:00:00Z'),
|
|
updatedAt: new Date('2024-01-20T15:00:00Z'),
|
|
publishedAt: new Date('2024-01-15T10:00:00Z'),
|
|
tags: ['tag1'],
|
|
categories: ['category1'],
|
|
},
|
|
];
|
|
}
|
|
if (filter.status === 'draft') {
|
|
return [];
|
|
}
|
|
return [];
|
|
});
|
|
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
|
|
|
|
const { writeFile, mkdir, readdir } = await import('fs/promises');
|
|
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
vi.mocked(readdir).mockResolvedValue([] as never);
|
|
|
|
const result = await invokeHandler('blog:validateSite');
|
|
|
|
expect(result).toEqual(expect.objectContaining({
|
|
missingUrlPaths: expect.any(Array),
|
|
extraUrlPaths: expect.any(Array),
|
|
}));
|
|
expect(writeFile).toHaveBeenCalledWith(
|
|
expect.stringContaining('sitemap.xml'),
|
|
expect.stringContaining('<?xml version="1.0" encoding="UTF-8"?>'),
|
|
'utf-8',
|
|
);
|
|
});
|
|
|
|
it('should run validation via taskManager.runTask', async () => {
|
|
const mockProject = createMockProject({ id: 'test-project', dataPath: '/mock/data' });
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
|
name: 'Test Project',
|
|
publicUrl: 'https://blog.example.com',
|
|
});
|
|
|
|
mockPostEngine.getPostsFiltered.mockResolvedValue([]);
|
|
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
|
|
|
|
const { mkdir, writeFile, readdir } = await import('fs/promises');
|
|
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
vi.mocked(readdir).mockResolvedValue([] as never);
|
|
|
|
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
|
return task.execute(vi.fn());
|
|
});
|
|
|
|
await invokeHandler('blog:validateSite');
|
|
|
|
expect(mockTaskManager.runTask).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: 'Validate Site',
|
|
execute: expect.any(Function),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('blog:applyValidation', () => {
|
|
it('should run apply via taskManager.runTask', async () => {
|
|
const mockProject = createMockProject({ id: 'test-project', dataPath: '/mock/data' });
|
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
|
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
|
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
|
name: 'Test Project',
|
|
publicUrl: 'https://blog.example.com',
|
|
});
|
|
|
|
mockPostEngine.getPostsFiltered.mockResolvedValue([]);
|
|
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
|
|
|
|
const { mkdir, writeFile, readdir } = await import('fs/promises');
|
|
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
vi.mocked(readdir).mockResolvedValue([] as never);
|
|
|
|
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
|
return task.execute(vi.fn());
|
|
});
|
|
|
|
await invokeHandler('blog:applyValidation', {
|
|
sitemapPath: '/mock/data/dir/html/sitemap.xml',
|
|
sitemapChanged: false,
|
|
missingUrlPaths: ['/category/news'],
|
|
extraUrlPaths: ['/stale'],
|
|
expectedUrlCount: 1,
|
|
existingHtmlUrlCount: 1,
|
|
});
|
|
|
|
expect(mockTaskManager.runTask).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: 'Apply Site Validation',
|
|
execute: expect.any(Function),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============ Script Handlers ============
|
|
describe('Script Handlers', () => {
|
|
describe('scripts:create', () => {
|
|
it('should call ScriptEngine.createScript with payload', async () => {
|
|
const payload = {
|
|
title: 'Render Hero',
|
|
kind: 'macro',
|
|
content: 'def render(context):\n return {"html":"<h1>Hi</h1>"}',
|
|
};
|
|
const expected = {
|
|
id: 'script-1',
|
|
projectId: 'default',
|
|
...payload,
|
|
slug: 'render-hero',
|
|
entrypoint: 'render',
|
|
enabled: true,
|
|
version: 1,
|
|
filePath: '/mock/userData/projects/default/scripts/render-hero.py',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
mockScriptEngine.createScript.mockResolvedValue(expected);
|
|
|
|
const result = await invokeHandler('scripts:create', payload);
|
|
|
|
expect(mockScriptEngine.createScript).toHaveBeenCalledWith(payload);
|
|
expect(result).toEqual(expected);
|
|
});
|
|
});
|
|
|
|
describe('scripts:update', () => {
|
|
it('should call ScriptEngine.updateScript with id and updates', async () => {
|
|
const updates = { title: 'Updated Script', content: 'print("updated")' };
|
|
const expected = {
|
|
id: 'script-1',
|
|
projectId: 'default',
|
|
slug: 'updated-script',
|
|
title: 'Updated Script',
|
|
kind: 'utility',
|
|
entrypoint: 'render',
|
|
enabled: true,
|
|
version: 2,
|
|
filePath: '/mock/userData/projects/default/scripts/updated-script.py',
|
|
content: 'print("updated")',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
mockScriptEngine.updateScript.mockResolvedValue(expected);
|
|
|
|
const result = await invokeHandler('scripts:update', 'script-1', updates);
|
|
|
|
expect(mockScriptEngine.updateScript).toHaveBeenCalledWith('script-1', updates);
|
|
expect(result).toEqual(expected);
|
|
});
|
|
});
|
|
|
|
describe('scripts:delete', () => {
|
|
it('should call ScriptEngine.deleteScript with id', async () => {
|
|
mockScriptEngine.deleteScript.mockResolvedValue(true);
|
|
|
|
const result = await invokeHandler('scripts:delete', 'script-1');
|
|
|
|
expect(mockScriptEngine.deleteScript).toHaveBeenCalledWith('script-1');
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('scripts:get', () => {
|
|
it('should call ScriptEngine.getScript with id', async () => {
|
|
const expected = {
|
|
id: 'script-1',
|
|
projectId: 'default',
|
|
slug: 'render-hero',
|
|
title: 'Render Hero',
|
|
kind: 'macro',
|
|
entrypoint: 'render',
|
|
enabled: true,
|
|
version: 1,
|
|
filePath: '/mock/userData/projects/default/scripts/render-hero.py',
|
|
content: 'def render(context):\n return {"html":"<h1>Hi</h1>"}',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
mockScriptEngine.getScript.mockResolvedValue(expected);
|
|
|
|
const result = await invokeHandler('scripts:get', 'script-1');
|
|
|
|
expect(mockScriptEngine.getScript).toHaveBeenCalledWith('script-1');
|
|
expect(result).toEqual(expected);
|
|
});
|
|
});
|
|
|
|
describe('scripts:getAll', () => {
|
|
it('should call ScriptEngine.getAllScripts', async () => {
|
|
const expected = [{ id: 'script-1' }, { id: 'script-2' }];
|
|
mockScriptEngine.getAllScripts.mockResolvedValue(expected);
|
|
|
|
const result = await invokeHandler('scripts:getAll');
|
|
|
|
expect(mockScriptEngine.getAllScripts).toHaveBeenCalled();
|
|
expect(result).toEqual(expected);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============ Error Handling ============
|
|
describe('Error Handling', () => {
|
|
it('should silently handle "Database is closing" errors', async () => {
|
|
const dbClosingError = new Error('Database is closing');
|
|
mockProjectEngine.getAllProjects.mockRejectedValue(dbClosingError);
|
|
|
|
const result = await invokeHandler('projects:getAll');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should re-throw other errors', async () => {
|
|
const otherError = new Error('Something went wrong');
|
|
mockProjectEngine.getAllProjects.mockRejectedValue(otherError);
|
|
|
|
await expect(invokeHandler('projects:getAll')).rejects.toThrow('Something went wrong');
|
|
});
|
|
});
|
|
});
|