From ca258ea14df9b2abb2afbfd3fff51d2ad7ed3b04 Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 28 Feb 2026 22:37:07 +0100 Subject: [PATCH] chore: added missing tests --- tests/cli/platform.test.ts | 55 +++++++++++ tests/engine/CliNotifier.test.ts | 71 ++++++++++++++ tests/engine/MCPServer.test.ts | 23 +---- tests/engine/ScriptEngine.test.ts | 138 ++++++++++++++++++++++++++++ tests/engine/TemplateEngine.test.ts | 138 ++++++++++++++++++++++++++++ 5 files changed, 403 insertions(+), 22 deletions(-) create mode 100644 tests/cli/platform.test.ts create mode 100644 tests/engine/CliNotifier.test.ts diff --git a/tests/cli/platform.test.ts b/tests/cli/platform.test.ts new file mode 100644 index 0000000..f557819 --- /dev/null +++ b/tests/cli/platform.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as os from 'os'; +import * as path from 'path'; +import { platformConfigPath } from '../../src/cli/platform'; + +const APP = 'Blogging Desktop Server'; + +describe('platformConfigPath', () => { + const originalPlatform = process.platform; + const originalEnv = { ...process.env }; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + process.env = { ...originalEnv }; + }); + + it('returns ~/Library/Application Support/ on macOS', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + const home = os.homedir(); + expect(platformConfigPath()).toBe(path.join(home, 'Library', 'Application Support', APP)); + }); + + it('returns %APPDATA%/ on Windows when APPDATA is set', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + process.env['APPDATA'] = '/mock/AppData/Roaming'; + expect(platformConfigPath()).toBe(path.join('/mock/AppData/Roaming', APP)); + }); + + it('falls back to ~/AppData/Roaming/ on Windows when APPDATA is unset', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + delete process.env['APPDATA']; + const home = os.homedir(); + expect(platformConfigPath()).toBe(path.join(home, 'AppData', 'Roaming', APP)); + }); + + it('returns ~/.config/ on Linux', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + delete process.env['XDG_CONFIG_HOME']; + const home = os.homedir(); + expect(platformConfigPath()).toBe(path.join(home, '.config', APP)); + }); + + it('honours XDG_CONFIG_HOME on Linux', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + process.env['XDG_CONFIG_HOME'] = '/custom/config'; + expect(platformConfigPath()).toBe(path.join('/custom/config', APP)); + }); + + it('treats unknown platforms the same as Linux', () => { + Object.defineProperty(process, 'platform', { value: 'freebsd' }); + delete process.env['XDG_CONFIG_HOME']; + const home = os.homedir(); + expect(platformConfigPath()).toBe(path.join(home, '.config', APP)); + }); +}); diff --git a/tests/engine/CliNotifier.test.ts b/tests/engine/CliNotifier.test.ts new file mode 100644 index 0000000..3eb7110 --- /dev/null +++ b/tests/engine/CliNotifier.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NoopNotifier, DbNotifier, type NotifyEntity, type NotifyAction } from '../../src/main/engine/CliNotifier'; +import { dbNotifications } from '../../src/main/database/schema'; + +describe('NoopNotifier', () => { + it('resolves without side effects', async () => { + const notifier = new NoopNotifier(); + await expect(notifier.notify('post', 'p-1', 'created')).resolves.toBeUndefined(); + }); + + it('accepts every valid entity and action combination', async () => { + const notifier = new NoopNotifier(); + const entities: NotifyEntity[] = ['post', 'media', 'script', 'template']; + const actions: NotifyAction[] = ['created', 'updated', 'deleted']; + + for (const entity of entities) { + for (const action of actions) { + await expect(notifier.notify(entity, 'id-1', action)).resolves.toBeUndefined(); + } + } + }); +}); + +describe('DbNotifier', () => { + let valuesFn: ReturnType; + let insertFn: ReturnType; + let mockDb: { insert: ReturnType }; + + beforeEach(() => { + valuesFn = vi.fn().mockResolvedValue(undefined); + insertFn = vi.fn().mockReturnValue({ values: valuesFn }); + mockDb = { insert: insertFn }; + }); + + it('inserts a notification row into db_notifications', async () => { + const notifier = new DbNotifier(mockDb); + const beforeMs = Date.now(); + + await notifier.notify('post', 'post-42', 'created'); + + expect(insertFn).toHaveBeenCalledWith(dbNotifications); + expect(valuesFn).toHaveBeenCalledTimes(1); + + const row = valuesFn.mock.calls[0][0]; + expect(row.entity).toBe('post'); + expect(row.entityId).toBe('post-42'); + expect(row.action).toBe('created'); + expect(row.fromCli).toBe(1); + expect(row.seenAt).toBeNull(); + expect(row.createdAt).toBeGreaterThanOrEqual(beforeMs); + expect(row.createdAt).toBeLessThanOrEqual(Date.now()); + }); + + it('inserts distinct rows for consecutive calls', async () => { + const notifier = new DbNotifier(mockDb); + + await notifier.notify('media', 'm-1', 'updated'); + await notifier.notify('script', 's-1', 'deleted'); + + expect(valuesFn).toHaveBeenCalledTimes(2); + expect(valuesFn.mock.calls[0][0].entity).toBe('media'); + expect(valuesFn.mock.calls[1][0].entity).toBe('script'); + }); + + it('propagates database insertion errors', async () => { + valuesFn.mockRejectedValueOnce(new Error('SQLITE_BUSY')); + const notifier = new DbNotifier(mockDb); + + await expect(notifier.notify('template', 't-1', 'created')).rejects.toThrow('SQLITE_BUSY'); + }); +}); diff --git a/tests/engine/MCPServer.test.ts b/tests/engine/MCPServer.test.ts index 4d8a26f..af3fba8 100644 --- a/tests/engine/MCPServer.test.ts +++ b/tests/engine/MCPServer.test.ts @@ -1,28 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { MCPServer, type MCPServerDependencies, DEFAULT_PAGE_SIZE, encodeCursor, decodeCursor } from '../../src/main/engine/MCPServer'; -// Mock all engine singletons -vi.mock('../../src/main/engine/PostEngine', () => ({ - getPostEngine: vi.fn(), -})); -vi.mock('../../src/main/engine/MediaEngine', () => ({ - getMediaEngine: vi.fn(), -})); -vi.mock('../../src/main/engine/ScriptEngine', () => ({ - getScriptEngine: vi.fn(), -})); -vi.mock('../../src/main/engine/TemplateEngine', () => ({ - getTemplateEngine: vi.fn(), -})); -vi.mock('../../src/main/engine/MetaEngine', () => ({ - getMetaEngine: vi.fn(), -})); -vi.mock('../../src/main/engine/PostMediaEngine', () => ({ - getPostMediaEngine: vi.fn(), -})); -vi.mock('../../src/main/engine/TagEngine', () => ({ - getTagEngine: vi.fn(), -})); + function createMockPostEngine() { return { diff --git a/tests/engine/ScriptEngine.test.ts b/tests/engine/ScriptEngine.test.ts index 36d6f86..d59f7df 100644 --- a/tests/engine/ScriptEngine.test.ts +++ b/tests/engine/ScriptEngine.test.ts @@ -445,4 +445,142 @@ describe('ScriptEngine', () => { expect(endFn).toHaveBeenCalled(); }); }); + + describe('draft lifecycle', () => { + it('createDraftScript inserts a row with status draft and content in DB', async () => { + const created = await scriptEngine.createDraftScript({ + title: 'Draft Macro', + kind: 'macro', + content: 'def render(ctx): return {"html": "

draft

"}', + }); + + expect(created.status).toBe('draft'); + expect(created.content).toBe('def render(ctx): return {"html": "

draft

"}'); + expect(created.slug).toBe('draft_macro'); + expect(mockScripts.has(created.id)).toBe(true); + + const row = mockScripts.get(created.id); + expect(row.status).toBe('draft'); + expect(row.content).toBe('def render(ctx): return {"html": "

draft

"}'); + }); + + it('createDraftScript does not write a file to disk', async () => { + const fsModule = await import('fs/promises'); + vi.mocked(fsModule.writeFile).mockClear(); + + await scriptEngine.createDraftScript({ + title: 'No File Draft', + kind: 'utility', + content: 'print("no file")', + }); + + expect(fsModule.writeFile).not.toHaveBeenCalled(); + }); + + it('createDraftScript generates a unique slug', async () => { + await scriptEngine.createDraftScript({ + title: 'Unique Slug', + kind: 'macro', + content: 'pass', + }); + + vi.mocked((await import('uuid')).v4).mockReturnValueOnce('mock-script-id-2'); + + const second = await scriptEngine.createDraftScript({ + title: 'Unique Slug', + kind: 'macro', + content: 'pass', + }); + + expect(second.slug).toBe('unique_slug_2'); + }); + + it('publishScript writes file and sets status to published', async () => { + const draft = await scriptEngine.createDraftScript({ + title: 'Publish Me', + kind: 'macro', + content: 'def render(ctx): return {"html": "

publish

"}', + }); + + const published = await scriptEngine.publishScript(draft.id); + + expect(published).not.toBeNull(); + expect(published!.status).toBe('published'); + + const row = mockScripts.get(draft.id); + expect(row.status).toBe('published'); + expect(row.content).toBeNull(); + + const fileContent = mockFiles.get(draft.filePath); + expect(fileContent).toBeDefined(); + expect(fileContent).toContain('title: "Publish Me"'); + expect(fileContent).toContain('def render'); + }); + + it('publishScript returns null for non-existent script', async () => { + const result = await scriptEngine.publishScript('nonexistent-id'); + expect(result).toBeNull(); + }); + + it('publishScript calls notifier with updated action', async () => { + const mockNotifier = { notify: vi.fn().mockResolvedValue(undefined) }; + const notifiedEngine = new ScriptEngine(mockNotifier); + notifiedEngine.setProjectContext('default', '/mock/userData/projects/default'); + + const draft = await notifiedEngine.createDraftScript({ + title: 'Notified Publish', + kind: 'macro', + content: 'def render(ctx): pass', + }); + + await notifiedEngine.publishScript(draft.id); + + expect(mockNotifier.notify).toHaveBeenCalledWith('script', draft.id, 'updated'); + }); + + it('deleteDraftScript removes a draft row from the database', async () => { + const draft = await scriptEngine.createDraftScript({ + title: 'Delete Draft', + kind: 'utility', + content: 'pass', + }); + + const deleted = await scriptEngine.deleteDraftScript(draft.id); + + expect(deleted).toBe(true); + expect(mockScripts.size).toBe(0); + }); + + it('deleteDraftScript returns false for non-existent script', async () => { + const result = await scriptEngine.deleteDraftScript('no-such-id'); + expect(result).toBe(false); + }); + + it('deleteDraftScript returns false for published scripts', async () => { + const created = await scriptEngine.createScript({ + title: 'Published Script', + kind: 'macro', + content: 'def render(ctx): pass', + }); + + const result = await scriptEngine.deleteDraftScript(created.id); + expect(result).toBe(false); + }); + + it('deleteDraftScript calls notifier with deleted action', async () => { + const mockNotifier = { notify: vi.fn().mockResolvedValue(undefined) }; + const notifiedEngine = new ScriptEngine(mockNotifier); + notifiedEngine.setProjectContext('default', '/mock/userData/projects/default'); + + const draft = await notifiedEngine.createDraftScript({ + title: 'Notified Delete', + kind: 'utility', + content: 'pass', + }); + + await notifiedEngine.deleteDraftScript(draft.id); + + expect(mockNotifier.notify).toHaveBeenCalledWith('script', draft.id, 'deleted'); + }); + }); }); diff --git a/tests/engine/TemplateEngine.test.ts b/tests/engine/TemplateEngine.test.ts index c4ca99f..736d594 100644 --- a/tests/engine/TemplateEngine.test.ts +++ b/tests/engine/TemplateEngine.test.ts @@ -517,4 +517,142 @@ describe('TemplateEngine', () => { expect(row.title).toBe(originalTitle); }); }); + + describe('draft lifecycle', () => { + it('createDraftTemplate inserts a row with status draft and content in DB', async () => { + const created = await templateEngine.createDraftTemplate({ + title: 'Draft Post Layout', + kind: 'post', + content: '
{{ post.title }}
', + }); + + expect(created.status).toBe('draft'); + expect(created.content).toBe('
{{ post.title }}
'); + expect(created.slug).toBe('draft_post_layout'); + expect(mockTemplates.has(created.id)).toBe(true); + + const row = mockTemplates.get(created.id); + expect(row.status).toBe('draft'); + expect(row.content).toBe('
{{ post.title }}
'); + }); + + it('createDraftTemplate does not write a file to disk', async () => { + const fsModule = await import('fs/promises'); + vi.mocked(fsModule.writeFile).mockClear(); + + await templateEngine.createDraftTemplate({ + title: 'No File Draft', + kind: 'list', + content: '
{{ day_blocks }}
', + }); + + expect(fsModule.writeFile).not.toHaveBeenCalled(); + }); + + it('createDraftTemplate generates a unique slug', async () => { + await templateEngine.createDraftTemplate({ + title: 'Unique Slug', + kind: 'post', + content: '
first
', + }); + + vi.mocked((await import('uuid')).v4).mockReturnValueOnce('mock-template-id-2'); + + const second = await templateEngine.createDraftTemplate({ + title: 'Unique Slug', + kind: 'post', + content: '
second
', + }); + + expect(second.slug).toBe('unique_slug_2'); + }); + + it('publishTemplate writes file and sets status to published', async () => { + const draft = await templateEngine.createDraftTemplate({ + title: 'Publish Me', + kind: 'post', + content: '
{{ post.content }}
', + }); + + const published = await templateEngine.publishTemplate(draft.id); + + expect(published).not.toBeNull(); + expect(published!.status).toBe('published'); + + const row = mockTemplates.get(draft.id); + expect(row.status).toBe('published'); + expect(row.content).toBeNull(); + + const fileContent = mockFiles.get(draft.filePath); + expect(fileContent).toBeDefined(); + expect(fileContent).toContain('title: "Publish Me"'); + expect(fileContent).toContain('{{ post.content }}'); + }); + + it('publishTemplate returns null for non-existent template', async () => { + const result = await templateEngine.publishTemplate('nonexistent-id'); + expect(result).toBeNull(); + }); + + it('publishTemplate calls notifier with updated action', async () => { + const mockNotifier = { notify: vi.fn().mockResolvedValue(undefined) }; + const notifiedEngine = new TemplateEngine(mockNotifier); + notifiedEngine.setProjectContext('default', '/mock/userData/projects/default'); + + const draft = await notifiedEngine.createDraftTemplate({ + title: 'Notified Publish', + kind: 'post', + content: '
notify
', + }); + + await notifiedEngine.publishTemplate(draft.id); + + expect(mockNotifier.notify).toHaveBeenCalledWith('template', draft.id, 'updated'); + }); + + it('deleteDraftTemplate removes a draft row from the database', async () => { + const draft = await templateEngine.createDraftTemplate({ + title: 'Delete Draft', + kind: 'partial', + content: '
draft
', + }); + + const deleted = await templateEngine.deleteDraftTemplate(draft.id); + + expect(deleted).toBe(true); + expect(mockTemplates.size).toBe(0); + }); + + it('deleteDraftTemplate returns false for non-existent template', async () => { + const result = await templateEngine.deleteDraftTemplate('no-such-id'); + expect(result).toBe(false); + }); + + it('deleteDraftTemplate returns false for published templates', async () => { + const created = await templateEngine.createTemplate({ + title: 'Published Template', + kind: 'post', + content: '
published
', + }); + + const result = await templateEngine.deleteDraftTemplate(created.id); + expect(result).toBe(false); + }); + + it('deleteDraftTemplate calls notifier with deleted action', async () => { + const mockNotifier = { notify: vi.fn().mockResolvedValue(undefined) }; + const notifiedEngine = new TemplateEngine(mockNotifier); + notifiedEngine.setProjectContext('default', '/mock/userData/projects/default'); + + const draft = await notifiedEngine.createDraftTemplate({ + title: 'Notified Delete', + kind: 'partial', + content: '', + }); + + await notifiedEngine.deleteDraftTemplate(draft.id); + + expect(mockNotifier.notify).toHaveBeenCalledWith('template', draft.id, 'deleted'); + }); + }); });