fix: better test base

This commit is contained in:
2026-02-10 12:36:20 +01:00
parent 78b2847bad
commit 9683fb8b9e
9 changed files with 1575 additions and 1205 deletions

View File

@@ -12,6 +12,26 @@ This document provides context and best practices for GitHub Copilot when workin
- **@libsql/client** for SQLite (local) and Turso (cloud sync) - **@libsql/client** for SQLite (local) and Turso (cloud sync)
- **Zustand** for React state management - **Zustand** for React state management
---
## ⚠️ MANDATORY: Test-First Development
**STOP!** Before writing ANY implementation code, you MUST:
1. **Write a failing test first** that describes the expected behavior
2. **Run the test** to confirm it fails (Red)
3. **Write minimal code** to make the test pass (Green)
4. **Refactor** while keeping tests green
> **No code without tests. No exceptions.**
>
> Tests must import and exercise the REAL implementation classes, not inline helper functions.
> Mock only external dependencies (database, filesystem), never the class under test.
See the [TDD Requirements](#test-driven-development-tdd-requirements) section for detailed guidelines.
---
## Architecture Principles ## Architecture Principles
### Separation of Concerns ### Separation of Concerns
@@ -414,20 +434,58 @@ src/
## Test-Driven Development (TDD) Requirements ## Test-Driven Development (TDD) Requirements
**This project follows strict TDD practices. All new features MUST have tests written BEFORE implementation.** > **⚠️ CRITICAL: This project follows STRICT Test-Driven Development.**
>
> Writing implementation code before tests is NOT acceptable.
> Pull requests without corresponding tests will be rejected.
### TDD Workflow (Red-Green-Refactor) **All new features and bug fixes MUST have tests written BEFORE implementation.**
### The Golden Rule: Test Real Implementations
```typescript ```typescript
// 1. RED: Write a failing test first // ✅ CORRECT: Import and test the REAL class
describe('PostEngine.createPost', () => { import { PostEngine } from '../../src/main/engine/PostEngine';
describe('PostEngine', () => {
let postEngine: PostEngine;
beforeEach(() => {
// Mock only external dependencies, NOT the class under test
postEngine = new PostEngine(mockDatabase, mockFileSystem);
});
it('should create a post with generated slug from title', async () => { it('should create a post with generated slug from title', async () => {
const result = await postEngine.createPost({ title: 'Hello World' }); const result = await postEngine.createPost({ title: 'Hello World' });
expect(result.slug).toBe('hello-world'); expect(result.slug).toBe('hello-world');
}); });
}); });
// 2. GREEN: Write minimal code to pass the test // ❌ WRONG: Testing inline helper functions instead of real implementations
describe('PostEngine', () => {
it('should generate slug', () => {
// This tests a local function, not the actual PostEngine class!
const generateSlug = (title: string) => title.toLowerCase().replace(/ /g, '-');
expect(generateSlug('Hello World')).toBe('hello-world');
});
});
```
### TDD Workflow (Red-Green-Refactor)
```typescript
// 1. RED: Write a failing test first that uses the REAL implementation
import { PostEngine } from '../../src/main/engine/PostEngine';
describe('PostEngine.createPost', () => {
it('should create a post with generated slug from title', async () => {
const postEngine = new PostEngine(mockDb, mockFs);
const result = await postEngine.createPost({ title: 'Hello World' });
expect(result.slug).toBe('hello-world');
});
});
// 2. GREEN: Write minimal code in PostEngine to pass the test
// 3. REFACTOR: Improve the code while keeping tests green // 3. REFACTOR: Improve the code while keeping tests green
``` ```

View File

@@ -80,7 +80,12 @@ in a compact form in the overview pages. More on rendering in the publishing pip
## Migrating ## Migrating
Prepare a proper mass-data importer that can read wordpress backup files, so the user can bring in old wordpress blogs easily. That importer should run asynchronously and properly communicate progress to the user while it is running in the background. The import has to rebuild all metadata properly, so check if we have all the metadata in our model set up in a similar way as Wordpress handles it, so that we have a seamless integration. Posts in Wordpress backups are html, but should be interpreted and transformed into proper markdown in the import. Prepare a proper mass-data importer that can read wordpress backup files, so the user can bring in old
wordpress blogs easily. That importer should run asynchronously and properly communicate progress to the
user while it is running in the background. The import has to rebuild all metadata properly, so check if
we have all the metadata in our model set up in a similar way as Wordpress handles it, so that we have a
seamless integration. Posts in Wordpress backups are html, but should be interpreted and transformed into
proper markdown in the import.
Additionally we need another importer to traverse a full website and deduct post structure from that website Additionally we need another importer to traverse a full website and deduct post structure from that website
and rebuild posts in the database based on such a web traversal. To be able to do that, use copilot SDK and rebuild posts in the database based on such a web traversal. To be able to do that, use copilot SDK
@@ -96,6 +101,10 @@ The AI importing agent must discover the language of a post and put that in an a
the database structure of translations, so that a post that is discovered as being german can be automatically the database structure of translations, so that a post that is discovered as being german can be automatically
translated to english and vice-versa. After import, all posts are available in two languages. translated to english and vice-versa. After import, all posts are available in two languages.
Another thing the AI importing agent must do is create summaries of posts to put into a summary attribute
on posts in the database, so that those can be used in overview pages if needed (usually that is the
case for article-type posts).
Import runs get an ID that has a generated name based on a sequence of two random words (like session plans Import runs get an ID that has a generated name based on a sequence of two random words (like session plans
of claude code) based on random adjective and animal name and a date. This identifier is then used as a tag of claude code) based on random adjective and animal name and a date. This identifier is then used as a tag
(ravenous-wombat-2026-02-20 for example), so posts and media from one import can be recognized easily. (ravenous-wombat-2026-02-20 for example), so posts and media from one import can be recognized easily.

View File

@@ -4,6 +4,7 @@ import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { app } from 'electron';
import { getDatabase } from '../database'; import { getDatabase } from '../database';
import { media, Media, NewMedia } from '../database/schema'; import { media, Media, NewMedia } from '../database/schema';
import { taskManager, Task } from './TaskManager'; import { taskManager, Task } from './TaskManager';
@@ -45,8 +46,6 @@ export class MediaEngine extends EventEmitter {
} }
private getMediaDir(): string { private getMediaDir(): string {
const { app } = require('electron');
const path = require('path');
const userDataPath = app.getPath('userData'); const userDataPath = app.getPath('userData');
return path.join(userDataPath, 'projects', this.currentProjectId, 'media'); return path.join(userDataPath, 'projects', this.currentProjectId, 'media');
} }

View File

@@ -92,21 +92,23 @@ export class PostEngine extends EventEmitter {
} }
private async writePostFile(post: PostData): Promise<string> { private async writePostFile(post: PostData): Promise<string> {
const metadata: PostMetadata = { const metadata: Record<string, unknown> = {
id: post.id, id: post.id,
projectId: post.projectId, projectId: post.projectId,
title: post.title, title: post.title,
slug: post.slug, slug: post.slug,
excerpt: post.excerpt,
status: post.status, status: post.status,
author: post.author,
createdAt: post.createdAt.toISOString(), createdAt: post.createdAt.toISOString(),
updatedAt: post.updatedAt.toISOString(), updatedAt: post.updatedAt.toISOString(),
publishedAt: post.publishedAt?.toISOString(),
tags: post.tags, tags: post.tags,
categories: post.categories, categories: post.categories,
}; };
// Only add optional fields if they have values (gray-matter can't serialize undefined)
if (post.excerpt) metadata.excerpt = post.excerpt;
if (post.author) metadata.author = post.author;
if (post.publishedAt) metadata.publishedAt = post.publishedAt.toISOString();
const postsDir = this.getPostsDir(); const postsDir = this.getPostsDir();
await fs.mkdir(postsDir, { recursive: true }); await fs.mkdir(postsDir, { recursive: true });

View File

@@ -1,419 +1,451 @@
/** /**
* MediaEngine Unit Tests * MediaEngine Unit Tests
* *
* Tests for media file management including: * Tests the REAL MediaEngine class with mocked dependencies.
* - Media import and storage * Following TDD best practices: mock external dependencies, test real implementation.
* - Sidecar metadata file handling
* - MIME type detection
* - Checksum calculation
* - Filename generation
*/ */
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createMockMedia, createMockPdfMedia, resetMockCounters } from '../utils/factories'; import { resetMockCounters } from '../utils/factories';
// Mock dependencies // Mock electron BEFORE importing MediaEngine (it uses require('electron') internally)
vi.mock('../../src/main/database', () => ({ vi.mock('electron', () => ({
getDatabase: vi.fn(() => ({ app: {
getLocal: vi.fn(() => ({ getPath: vi.fn((name: string) => {
select: vi.fn(() => ({ const paths: Record<string, string> = {
from: vi.fn(() => ({ userData: '/mock/userData',
where: vi.fn(() => Promise.resolve([])), appData: '/mock/appData',
orderBy: vi.fn(() => Promise.resolve([])), temp: '/mock/temp',
})), };
})), return paths[name] || '/mock/unknown';
insert: vi.fn(() => ({ }),
values: vi.fn(() => Promise.resolve()), },
})), }));
update: vi.fn(() => ({
set: vi.fn(() => ({ // Import MediaEngine after mocks are set up
where: vi.fn(() => Promise.resolve()), import { MediaEngine, MediaData } from '../../src/main/engine/MediaEngine';
})),
})), // Create mock data stores
delete: vi.fn(() => ({ const mockMedia = new Map<string, any>();
const mockFiles = new Map<string, Buffer | string>();
// Create chainable mock for Drizzle ORM
function createSelectChain() {
return {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockImplementation(function (this: any) {
return this;
}),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
all: vi.fn().mockImplementation(() => Promise.resolve(Array.from(mockMedia.values()))),
get: vi.fn().mockImplementation(() => Promise.resolve(undefined)),
};
}
function createDrizzleMock() {
return {
select: vi.fn(() => createSelectChain()),
insert: vi.fn(() => ({
values: vi.fn((data: any) => {
if (data && data.id) {
mockMedia.set(data.id, data);
}
return Promise.resolve();
}),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()), where: vi.fn(() => Promise.resolve()),
})), })),
})), })),
delete: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
};
}
const mockLocalDb = createDrizzleMock();
// Mock the database module
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => mockLocalDb),
getLocalClient: vi.fn(() => null),
getRemote: vi.fn(() => null), getRemote: vi.fn(() => null),
getDataPaths: vi.fn(() => ({ getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db', database: '/mock/userData/bds.db',
posts: '/mock/userData/posts', posts: '/mock/userData/posts',
media: '/mock/userData/media', media: '/mock/userData/media',
})), })),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(),
close: vi.fn(),
})), })),
})); }));
vi.mock('fs/promises'); // Mock fs/promises
vi.mock('fs/promises', () => ({
readFile: vi.fn(async (path: string) => {
const content = mockFiles.get(path);
if (!content) {
const error = new Error(`ENOENT: no such file or directory, open '${path}'`);
(error as any).code = 'ENOENT';
throw error;
}
return content;
}),
writeFile: vi.fn(async (path: string, content: Buffer | string) => {
mockFiles.set(path, content);
}),
unlink: vi.fn(async (path: string) => {
mockFiles.delete(path);
}),
mkdir: vi.fn(async () => {}),
readdir: vi.fn(async () => []),
stat: vi.fn(async (path: string) => ({
isFile: () => mockFiles.has(path),
isDirectory: () => !mockFiles.has(path),
size: mockFiles.get(path)?.length || 0,
})),
access: vi.fn(async (path: string) => {
if (!mockFiles.has(path)) {
const error = new Error(`ENOENT`);
(error as any).code = 'ENOENT';
throw error;
}
}),
copyFile: vi.fn(async (src: string, dest: string) => {
const content = mockFiles.get(src);
if (content) {
mockFiles.set(dest, content);
}
}),
}));
// Mock uuid
vi.mock('uuid', () => ({
v4: vi.fn(() => 'mock-media-uuid-' + Math.random().toString(36).substr(2, 9)),
}));
describe('MediaEngine', () => { describe('MediaEngine', () => {
let mediaEngine: MediaEngine;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockMedia.clear();
mockFiles.clear();
resetMockCounters(); resetMockCounters();
// Reset the mock implementations
vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain());
mediaEngine = new MediaEngine();
}); });
describe('Media Data Validation', () => { describe('Constructor and Initialization', () => {
it('should create valid media with required fields', () => { it('should create a MediaEngine instance', () => {
const media = createMockMedia(); expect(mediaEngine).toBeInstanceOf(MediaEngine);
});
it('should extend EventEmitter', () => {
expect(typeof mediaEngine.on).toBe('function');
expect(typeof mediaEngine.emit).toBe('function');
});
it('should have default project context', () => {
expect(mediaEngine.getProjectContext()).toBe('default');
});
});
describe('Project Context', () => {
it('should set project context', () => {
mediaEngine.setProjectContext('my-blog');
expect(mediaEngine.getProjectContext()).toBe('my-blog');
});
it('should allow changing project context multiple times', () => {
mediaEngine.setProjectContext('blog-1');
expect(mediaEngine.getProjectContext()).toBe('blog-1');
mediaEngine.setProjectContext('blog-2');
expect(mediaEngine.getProjectContext()).toBe('blog-2');
});
});
describe('Media Import', () => {
beforeEach(() => {
// Setup a source file for import
const imageBuffer = Buffer.from('fake-image-data');
mockFiles.set('/source/image.jpg', imageBuffer);
});
it('should import media from source path', async () => {
const media = await mediaEngine.importMedia('/source/image.jpg');
expect(media).toBeDefined();
expect(media.id).toBeDefined(); expect(media.id).toBeDefined();
expect(media.filename).toBeDefined(); expect(media.originalName).toBe('image.jpg');
expect(media.originalName).toBeDefined();
expect(media.mimeType).toBeDefined();
expect(media.size).toBeGreaterThan(0);
expect(media.createdAt).toBeInstanceOf(Date);
expect(media.updatedAt).toBeInstanceOf(Date);
}); });
it('should allow optional dimension fields', () => { it('should detect mime type from file extension', async () => {
const imageMedia = createMockMedia({ width: 1920, height: 1080 }); const jpgMedia = await mediaEngine.importMedia('/source/image.jpg');
const pdfMedia = createMockPdfMedia(); expect(jpgMedia.mimeType).toBe('image/jpeg');
expect(imageMedia.width).toBe(1920);
expect(imageMedia.height).toBe(1080);
expect(pdfMedia.width).toBeUndefined();
expect(pdfMedia.height).toBeUndefined();
}); });
it('should allow optional alt and caption', () => { it('should set file size from source', async () => {
const withAlt = createMockMedia({ alt: 'Description', caption: 'A caption' }); const media = await mediaEngine.importMedia('/source/image.jpg');
const withoutAlt = createMockMedia({ alt: undefined, caption: undefined }); expect(media.size).toBe(Buffer.from('fake-image-data').length);
expect(withAlt.alt).toBe('Description');
expect(withAlt.caption).toBe('A caption');
expect(withoutAlt.alt).toBeUndefined();
expect(withoutAlt.caption).toBeUndefined();
}); });
});
describe('MIME Type Handling', () => { it('should use provided metadata', async () => {
it('should recognize common image MIME types', () => { const media = await mediaEngine.importMedia('/source/image.jpg', {
const imageMimeTypes = [ alt: 'A beautiful sunset',
'image/jpeg', caption: 'Sunset over the ocean',
'image/png', tags: ['nature', 'sunset'],
'image/gif',
'image/webp',
'image/svg+xml',
];
imageMimeTypes.forEach(mimeType => {
const media = createMockMedia({ mimeType });
expect(media.mimeType).toBe(mimeType);
expect(media.mimeType.startsWith('image/')).toBe(true);
}); });
expect(media.alt).toBe('A beautiful sunset');
expect(media.caption).toBe('Sunset over the ocean');
expect(media.tags).toEqual(['nature', 'sunset']);
}); });
it('should recognize document MIME types', () => { it('should set createdAt and updatedAt timestamps', async () => {
const docMimeTypes = [ const before = new Date();
'application/pdf', const media = await mediaEngine.importMedia('/source/image.jpg');
'application/msword', const after = new Date();
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
docMimeTypes.forEach(mimeType => { expect(media.createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
const media = createMockMedia({ mimeType }); expect(media.createdAt.getTime()).toBeLessThanOrEqual(after.getTime());
expect(media.mimeType).toBe(mimeType); expect(media.updatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
}); expect(media.updatedAt.getTime()).toBeLessThanOrEqual(after.getTime());
}); });
it('should identify image types by MIME prefix', () => { it('should emit mediaImported event', async () => {
const isImage = (mimeType: string) => mimeType.startsWith('image/'); const handler = vi.fn();
mediaEngine.on('mediaImported', handler);
expect(isImage('image/jpeg')).toBe(true);
expect(isImage('image/png')).toBe(true); const media = await mediaEngine.importMedia('/source/image.jpg');
expect(isImage('application/pdf')).toBe(false);
expect(isImage('video/mp4')).toBe(false); expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
originalName: 'image.jpg',
})
);
});
it('should create media directory if it does not exist', async () => {
const fs = await import('fs/promises');
await mediaEngine.importMedia('/source/image.jpg');
expect(fs.mkdir).toHaveBeenCalled();
});
it('should write media file to destination', async () => {
const fs = await import('fs/promises');
await mediaEngine.importMedia('/source/image.jpg');
expect(fs.writeFile).toHaveBeenCalled();
});
it('should insert media record into database', async () => {
await mediaEngine.importMedia('/source/image.jpg');
expect(mockLocalDb.insert).toHaveBeenCalled();
});
it('should write sidecar metadata file', async () => {
const fs = await import('fs/promises');
await mediaEngine.importMedia('/source/image.jpg');
// Should write both the media file and the sidecar file
expect(vi.mocked(fs.writeFile).mock.calls.length).toBeGreaterThanOrEqual(2);
}); });
}); });
describe('Filename Generation', () => { describe('MIME Type Detection', () => {
it('should generate unique filenames', () => { beforeEach(() => {
const generateFilename = (originalName: string, id: string): string => { // Setup various file types
const ext = originalName.split('.').pop() || ''; mockFiles.set('/source/photo.jpg', Buffer.from('jpg-data'));
return `${id}.${ext}`; mockFiles.set('/source/photo.jpeg', Buffer.from('jpeg-data'));
}; mockFiles.set('/source/image.png', Buffer.from('png-data'));
mockFiles.set('/source/animation.gif', Buffer.from('gif-data'));
const filename1 = generateFilename('photo.jpg', 'media-1'); mockFiles.set('/source/modern.webp', Buffer.from('webp-data'));
const filename2 = generateFilename('photo.jpg', 'media-2'); mockFiles.set('/source/vector.svg', Buffer.from('svg-data'));
mockFiles.set('/source/unknown.xyz', Buffer.from('unknown-data'));
expect(filename1).toBe('media-1.jpg');
expect(filename2).toBe('media-2.jpg');
expect(filename1).not.toBe(filename2);
}); });
it('should preserve file extension', () => { it('should detect image/jpeg for .jpg files', async () => {
const getExtension = (filename: string): string => { const media = await mediaEngine.importMedia('/source/photo.jpg');
const parts = filename.split('.'); expect(media.mimeType).toBe('image/jpeg');
return parts.length > 1 ? parts.pop()!.toLowerCase() : '';
};
expect(getExtension('photo.jpg')).toBe('jpg');
expect(getExtension('document.PDF')).toBe('pdf');
expect(getExtension('archive.tar.gz')).toBe('gz');
expect(getExtension('noextension')).toBe('');
}); });
it('should sanitize original filename', () => { it('should detect image/jpeg for .jpeg files', async () => {
const sanitizeFilename = (filename: string): string => { const media = await mediaEngine.importMedia('/source/photo.jpeg');
return filename.replace(/[^a-zA-Z0-9.-]/g, '_'); expect(media.mimeType).toBe('image/jpeg');
}; });
expect(sanitizeFilename('my file (1).jpg')).toBe('my_file__1_.jpg'); it('should detect image/png for .png files', async () => {
expect(sanitizeFilename('résumé.pdf')).toBe('r_sum_.pdf'); const media = await mediaEngine.importMedia('/source/image.png');
expect(sanitizeFilename('normal-file.png')).toBe('normal-file.png'); expect(media.mimeType).toBe('image/png');
});
it('should detect image/gif for .gif files', async () => {
const media = await mediaEngine.importMedia('/source/animation.gif');
expect(media.mimeType).toBe('image/gif');
});
it('should detect image/webp for .webp files', async () => {
const media = await mediaEngine.importMedia('/source/modern.webp');
expect(media.mimeType).toBe('image/webp');
});
it('should detect image/svg+xml for .svg files', async () => {
const media = await mediaEngine.importMedia('/source/vector.svg');
expect(media.mimeType).toBe('image/svg+xml');
});
it('should fallback to application/octet-stream for unknown types', async () => {
const media = await mediaEngine.importMedia('/source/unknown.xyz');
expect(media.mimeType).toBe('application/octet-stream');
}); });
}); });
describe('Sidecar Metadata', () => { describe('Media with Image Dimensions', () => {
it('should generate sidecar path from media path', () => { beforeEach(() => {
const getSidecarPath = (mediaPath: string): string => `${mediaPath}.meta`; mockFiles.set('/source/image.jpg', Buffer.from('image-data'));
expect(getSidecarPath('/media/photo.jpg')).toBe('/media/photo.jpg.meta');
expect(getSidecarPath('/media/doc.pdf')).toBe('/media/doc.pdf.meta');
}); });
it('should create correct sidecar content structure', () => { it('should store width and height when provided', async () => {
const media = createMockMedia({ const media = await mediaEngine.importMedia('/source/image.jpg', {
id: 'media-123',
originalName: 'vacation-photo.jpg',
mimeType: 'image/jpeg',
size: 1024000,
width: 1920, width: 1920,
height: 1080, height: 1080,
alt: 'Vacation photo',
caption: 'Summer vacation 2024',
tags: ['vacation', 'summer'],
}); });
const sidecarContent = { expect(media.width).toBe(1920);
id: media.id, expect(media.height).toBe(1080);
originalName: media.originalName,
mimeType: media.mimeType,
size: media.size,
width: media.width,
height: media.height,
alt: media.alt,
caption: media.caption,
createdAt: media.createdAt.toISOString(),
updatedAt: media.updatedAt.toISOString(),
tags: media.tags,
};
expect(sidecarContent.id).toBe('media-123');
expect(sidecarContent.width).toBe(1920);
expect(sidecarContent.tags).toEqual(['vacation', 'summer']);
}); });
it('should handle missing optional fields in sidecar', () => { it('should handle media without dimensions', async () => {
const media = createMockPdfMedia({ const media = await mediaEngine.importMedia('/source/image.jpg');
alt: undefined,
caption: undefined,
});
const sidecarContent = { expect(media.width).toBeUndefined();
id: media.id, expect(media.height).toBeUndefined();
originalName: media.originalName,
mimeType: media.mimeType,
size: media.size,
width: media.width,
height: media.height,
alt: media.alt,
caption: media.caption,
createdAt: media.createdAt.toISOString(),
updatedAt: media.updatedAt.toISOString(),
tags: media.tags,
};
expect(sidecarContent.width).toBeUndefined();
expect(sidecarContent.height).toBeUndefined();
expect(sidecarContent.alt).toBeUndefined();
expect(sidecarContent.caption).toBeUndefined();
});
});
describe('Checksum Calculation', () => {
it('should calculate consistent checksum for same content', () => {
const crypto = require('crypto');
const calculateChecksum = (buffer: Buffer): string => {
return crypto.createHash('md5').update(buffer).digest('hex');
};
const buffer = Buffer.from('test content');
const checksum1 = calculateChecksum(buffer);
const checksum2 = calculateChecksum(buffer);
expect(checksum1).toBe(checksum2);
});
it('should calculate different checksums for different content', () => {
const crypto = require('crypto');
const calculateChecksum = (buffer: Buffer): string => {
return crypto.createHash('md5').update(buffer).digest('hex');
};
const buffer1 = Buffer.from('content A');
const buffer2 = Buffer.from('content B');
expect(calculateChecksum(buffer1)).not.toBe(calculateChecksum(buffer2));
});
});
describe('File Size Formatting', () => {
it('should format bytes correctly', () => {
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
expect(formatFileSize(0)).toBe('0 B');
expect(formatFileSize(500)).toBe('500 B');
expect(formatFileSize(1024)).toBe('1 KB');
expect(formatFileSize(1536)).toBe('1.5 KB');
expect(formatFileSize(1048576)).toBe('1 MB');
expect(formatFileSize(1073741824)).toBe('1 GB');
}); });
}); });
describe('Media Tags', () => { describe('Media Tags', () => {
it('should handle empty tags array', () => { beforeEach(() => {
const media = createMockMedia({ tags: [] }); mockFiles.set('/source/image.jpg', Buffer.from('image-data'));
});
it('should store tags', async () => {
const media = await mediaEngine.importMedia('/source/image.jpg', {
tags: ['landscape', 'outdoor', 'nature'],
});
expect(media.tags).toEqual(['landscape', 'outdoor', 'nature']);
});
it('should default to empty tags array', async () => {
const media = await mediaEngine.importMedia('/source/image.jpg');
expect(media.tags).toEqual([]); expect(media.tags).toEqual([]);
}); });
});
it('should preserve tag order', () => { describe('Event Emission', () => {
const tags = ['first', 'second', 'third']; it('should be an EventEmitter', () => {
const media = createMockMedia({ tags }); expect(mediaEngine.on).toBeDefined();
expect(media.tags).toEqual(tags); expect(mediaEngine.emit).toBeDefined();
expect(mediaEngine.removeListener).toBeDefined();
}); });
it('should serialize tags to JSON', () => { it('should allow adding event listeners', () => {
const tags = ['photo', 'landscape', '2024']; const listener = vi.fn();
const serialized = JSON.stringify(tags); mediaEngine.on('testEvent', listener);
const deserialized = JSON.parse(serialized); mediaEngine.emit('testEvent', { data: 'test' });
expect(deserialized).toEqual(tags); expect(listener).toHaveBeenCalledWith({ data: 'test' });
});
it('should allow removing event listeners', () => {
const listener = vi.fn();
mediaEngine.on('testEvent', listener);
mediaEngine.removeListener('testEvent', listener);
mediaEngine.emit('testEvent', { data: 'test' });
expect(listener).not.toHaveBeenCalled();
}); });
}); });
describe('Image Dimensions', () => { describe('getMediaPath', () => {
it('should store width and height for images', () => { it('should return path in media directory', () => {
const media = createMockMedia({ const path = mediaEngine.getMediaPath('test-id');
mimeType: 'image/jpeg', expect(path).toContain('test-id');
width: 3840, expect(path).toContain('media');
height: 2160, });
});
describe('Multiple Media Import', () => {
beforeEach(() => {
mockFiles.set('/source/image1.jpg', Buffer.from('image1-data'));
mockFiles.set('/source/image2.jpg', Buffer.from('image2-data'));
mockFiles.set('/source/image3.png', Buffer.from('image3-data'));
});
it('should import multiple media with unique IDs', async () => {
const media1 = await mediaEngine.importMedia('/source/image1.jpg');
const media2 = await mediaEngine.importMedia('/source/image2.jpg');
expect(media1.id).toBeDefined();
expect(media2.id).toBeDefined();
expect(media1.id).not.toBe(media2.id);
});
it('should handle different file types', async () => {
const jpg = await mediaEngine.importMedia('/source/image1.jpg');
const png = await mediaEngine.importMedia('/source/image3.png');
expect(jpg.mimeType).toBe('image/jpeg');
expect(png.mimeType).toBe('image/png');
});
});
describe('Error Handling', () => {
it('should throw when source file does not exist', async () => {
await expect(mediaEngine.importMedia('/source/nonexistent.jpg')).rejects.toThrow('ENOENT');
});
});
describe('Alt Text and Caption', () => {
beforeEach(() => {
mockFiles.set('/source/image.jpg', Buffer.from('image-data'));
});
it('should store alt text for accessibility', async () => {
const media = await mediaEngine.importMedia('/source/image.jpg', {
alt: 'A scenic mountain view',
}); });
expect(media.width).toBe(3840); expect(media.alt).toBe('A scenic mountain view');
expect(media.height).toBe(2160);
}); });
it('should calculate aspect ratio', () => { it('should store caption for display', async () => {
const getAspectRatio = (width: number, height: number): string => { const media = await mediaEngine.importMedia('/source/image.jpg', {
const gcd = (a: number, b: number): number => b === 0 ? a : gcd(b, a % b); caption: 'Photo taken at Mt. Rainier, 2024',
const divisor = gcd(width, height); });
return `${width / divisor}:${height / divisor}`;
};
expect(getAspectRatio(1920, 1080)).toBe('16:9'); expect(media.caption).toBe('Photo taken at Mt. Rainier, 2024');
expect(getAspectRatio(1024, 768)).toBe('4:3');
expect(getAspectRatio(1080, 1080)).toBe('1:1');
}); });
it('should not require dimensions for non-images', () => { it('should handle media without alt or caption', async () => {
const pdf = createMockPdfMedia(); const media = await mediaEngine.importMedia('/source/image.jpg');
expect(pdf.width).toBeUndefined();
expect(pdf.height).toBeUndefined(); expect(media.alt).toBeUndefined();
}); expect(media.caption).toBeUndefined();
});
});
describe('MediaEngine Integration Helpers', () => {
describe('Database Record Conversion', () => {
it('should convert MediaData to database record', () => {
const media = createMockMedia({
tags: ['tag1', 'tag2'],
});
const dbRecord = {
id: media.id,
filename: media.filename,
originalName: media.originalName,
mimeType: media.mimeType,
size: media.size,
width: media.width,
height: media.height,
alt: media.alt,
caption: media.caption,
filePath: `/mock/userData/media/${media.filename}`,
sidecarPath: `/mock/userData/media/${media.filename}.meta`,
createdAt: media.createdAt,
updatedAt: media.updatedAt,
syncStatus: 'pending' as const,
syncedAt: null,
checksum: 'abc123',
tags: JSON.stringify(media.tags),
};
expect(dbRecord.tags).toBe('["tag1","tag2"]');
expect(dbRecord.sidecarPath).toContain('.meta');
});
it('should convert database record to MediaData', () => {
const dbRecord = {
id: 'media-1',
filename: 'photo.jpg',
originalName: 'original.jpg',
mimeType: 'image/jpeg',
size: 102400,
width: 800,
height: 600,
alt: 'A photo',
caption: 'Caption text',
filePath: '/mock/path/photo.jpg',
sidecarPath: '/mock/path/photo.jpg.meta',
createdAt: new Date(),
updatedAt: new Date(),
syncStatus: 'pending' as const,
syncedAt: null,
checksum: 'abc123',
tags: '["tag1","tag2"]',
};
const mediaData = {
id: dbRecord.id,
filename: dbRecord.filename,
originalName: dbRecord.originalName,
mimeType: dbRecord.mimeType,
size: dbRecord.size,
width: dbRecord.width,
height: dbRecord.height,
alt: dbRecord.alt,
caption: dbRecord.caption,
createdAt: dbRecord.createdAt,
updatedAt: dbRecord.updatedAt,
tags: JSON.parse(dbRecord.tags) as string[],
};
expect(mediaData.tags).toEqual(['tag1', 'tag2']);
});
});
describe('File Path Generation', () => {
it('should generate correct media file path', () => {
const mediaDir = '/mock/userData/media';
const filename = 'media-123.jpg';
const filePath = `${mediaDir}/${filename}`;
expect(filePath).toBe('/mock/userData/media/media-123.jpg');
});
it('should generate correct sidecar path', () => {
const filePath = '/mock/userData/media/media-123.jpg';
const sidecarPath = `${filePath}.meta`;
expect(sidecarPath).toBe('/mock/userData/media/media-123.jpg.meta');
}); });
}); });
}); });

View File

@@ -1,39 +1,69 @@
/** /**
* PostEngine Unit Tests * PostEngine Unit Tests
* *
* Tests for blog post management including: * Tests the REAL PostEngine class with mocked dependencies.
* - Post CRUD operations * Following TDD best practices: mock external dependencies, test real implementation.
* - Slug generation
* - Markdown with YAML frontmatter handling
* - Checksum calculation
* - Event emissions
*/ */
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createMockPost, createMockFileSystem, createMockDatabase, resetMockCounters } from '../utils/factories'; import { PostEngine, PostData } from '../../src/main/engine/PostEngine';
import { resetMockCounters } from '../utils/factories';
// Mock the database module before importing PostEngine // Create mock data stores
vi.mock('../../src/main/database', () => { const mockPosts = new Map<string, any>();
const mockDb = { const mockFiles = new Map<string, string>();
getLocal: vi.fn(() => ({ let mockExecuteArgs: any[] = [];
select: vi.fn(() => ({
from: vi.fn(() => ({ // Create chainable mock for Drizzle ORM
where: vi.fn(() => Promise.resolve([])), function createSelectChain() {
orderBy: vi.fn(() => Promise.resolve([])), return {
})), from: vi.fn().mockReturnThis(),
})), where: vi.fn().mockImplementation(function(this: any) {
insert: vi.fn(() => ({ return this;
values: vi.fn(() => Promise.resolve()), }),
})), orderBy: vi.fn().mockReturnThis(),
update: vi.fn(() => ({ limit: vi.fn().mockReturnThis(),
set: vi.fn(() => ({ offset: vi.fn().mockReturnThis(),
where: vi.fn(() => Promise.resolve()), all: vi.fn().mockImplementation(() => Promise.resolve(Array.from(mockPosts.values()))),
})), get: vi.fn().mockImplementation(() => Promise.resolve(undefined)),
})), };
delete: vi.fn(() => ({ }
function createDrizzleMock() {
return {
select: vi.fn(() => createSelectChain()),
insert: vi.fn(() => ({
values: vi.fn((data: any) => {
if (data && data.id) {
mockPosts.set(data.id, data);
}
return Promise.resolve();
}),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()), where: vi.fn(() => Promise.resolve()),
})), })),
})), })),
delete: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
};
}
const mockLocalDb = createDrizzleMock();
const mockLocalClient = {
execute: vi.fn(async (query: { sql: string; args: any[] }) => {
mockExecuteArgs.push(query);
return { rows: [] };
}),
};
// Mock the database module
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => mockLocalDb),
getLocalClient: vi.fn(() => mockLocalClient),
getRemote: vi.fn(() => null), getRemote: vi.fn(() => null),
getDataPaths: vi.fn(() => ({ getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db', database: '/mock/userData/bds.db',
@@ -43,394 +73,372 @@ vi.mock('../../src/main/database', () => {
initializeLocal: vi.fn(), initializeLocal: vi.fn(),
initializeRemote: vi.fn(), initializeRemote: vi.fn(),
close: vi.fn(), close: vi.fn(),
}; })),
}));
return {
getDatabase: vi.fn(() => mockDb),
};
});
// Mock fs/promises // Mock fs/promises
vi.mock('fs/promises', () => createMockFileSystem()); vi.mock('fs/promises', () => ({
readFile: vi.fn(async (path: string) => {
const content = mockFiles.get(path);
if (!content) {
const error = new Error(`ENOENT: no such file or directory, open '${path}'`);
(error as any).code = 'ENOENT';
throw error;
}
return content;
}),
writeFile: vi.fn(async (path: string, content: string) => {
mockFiles.set(path, content);
}),
unlink: vi.fn(async (path: string) => {
mockFiles.delete(path);
}),
mkdir: vi.fn(async () => {}),
readdir: vi.fn(async () => []),
stat: vi.fn(async (path: string) => ({
isFile: () => mockFiles.has(path),
isDirectory: () => !mockFiles.has(path),
size: mockFiles.get(path)?.length || 0,
})),
access: vi.fn(async (path: string) => {
if (!mockFiles.has(path)) {
const error = new Error(`ENOENT`);
(error as any).code = 'ENOENT';
throw error;
}
}),
}));
// Mock uuid
vi.mock('uuid', () => ({
v4: vi.fn(() => 'mock-uuid-' + Math.random().toString(36).substr(2, 9)),
}));
describe('PostEngine', () => { describe('PostEngine', () => {
let postEngine: PostEngine;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockPosts.clear();
mockFiles.clear();
mockExecuteArgs = [];
resetMockCounters(); resetMockCounters();
// Reset the mock implementations
vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain());
postEngine = new PostEngine();
}); });
describe('Slug Generation', () => { describe('Constructor and Initialization', () => {
it('should generate slug from title with lowercase', () => { it('should create a PostEngine instance', () => {
// Test the slug generation logic expect(postEngine).toBeInstanceOf(PostEngine);
const generateSlug = (title: string): string => {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
};
expect(generateSlug('Hello World')).toBe('hello-world');
}); });
it('should replace special characters with hyphens', () => { it('should extend EventEmitter', () => {
const generateSlug = (title: string): string => { expect(typeof postEngine.on).toBe('function');
return title expect(typeof postEngine.emit).toBe('function');
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
};
expect(generateSlug('Hello, World!')).toBe('hello-world');
expect(generateSlug('Test @ Post #1')).toBe('test-post-1');
}); });
it('should remove leading and trailing hyphens', () => { it('should have default project context', () => {
const generateSlug = (title: string): string => { expect(postEngine.getProjectContext()).toBe('default');
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
};
expect(generateSlug('---Hello World---')).toBe('hello-world');
expect(generateSlug(' Spaces Around ')).toBe('spaces-around');
});
it('should handle unicode characters', () => {
const generateSlug = (title: string): string => {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
};
expect(generateSlug('Café & Résumé')).toBe('caf-r-sum');
});
it('should handle empty string', () => {
const generateSlug = (title: string): string => {
if (!title) return 'untitled';
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'untitled';
};
expect(generateSlug('')).toBe('untitled');
}); });
}); });
describe('Checksum Calculation', () => { describe('Project Context', () => {
it('should generate consistent checksums', () => { it('should set project context', () => {
const crypto = require('crypto'); postEngine.setProjectContext('my-blog');
const calculateChecksum = (content: string): string => { expect(postEngine.getProjectContext()).toBe('my-blog');
return crypto.createHash('md5').update(content).digest('hex');
};
const content = 'Hello, World!';
const checksum1 = calculateChecksum(content);
const checksum2 = calculateChecksum(content);
expect(checksum1).toBe(checksum2);
expect(checksum1).toHaveLength(32); // MD5 hex length
}); });
it('should generate different checksums for different content', () => { it('should allow changing project context multiple times', () => {
const crypto = require('crypto'); postEngine.setProjectContext('blog-1');
const calculateChecksum = (content: string): string => { expect(postEngine.getProjectContext()).toBe('blog-1');
return crypto.createHash('md5').update(content).digest('hex');
};
const checksum1 = calculateChecksum('Content A'); postEngine.setProjectContext('blog-2');
const checksum2 = calculateChecksum('Content B'); expect(postEngine.getProjectContext()).toBe('blog-2');
expect(checksum1).not.toBe(checksum2);
});
it('should handle empty content', () => {
const crypto = require('crypto');
const calculateChecksum = (content: string): string => {
return crypto.createHash('md5').update(content).digest('hex');
};
const checksum = calculateChecksum('');
expect(checksum).toHaveLength(32);
}); });
}); });
describe('Post Data Validation', () => { describe('Slug Generation via createPost', () => {
it('should create a valid post with default values', () => { it('should generate slug from title with lowercase', async () => {
const createPostData = (data: Partial<ReturnType<typeof createMockPost>>) => { const post = await postEngine.createPost({ title: 'Hello World' });
const now = new Date(); expect(post.slug).toBe('hello-world');
const id = data.id || 'generated-id'; });
const slug = data.slug || 'untitled';
return { it('should replace special characters with hyphens', async () => {
id, const post = await postEngine.createPost({ title: 'Hello, World! How are you?' });
title: data.title || 'Untitled', expect(post.slug).toBe('hello-world-how-are-you');
slug, });
excerpt: data.excerpt,
content: data.content || '',
status: data.status || 'draft',
author: data.author,
createdAt: data.createdAt || now,
updatedAt: data.updatedAt || now,
publishedAt: data.publishedAt,
tags: data.tags || [],
categories: data.categories || [],
};
};
const post = createPostData({}); it('should remove leading and trailing hyphens', async () => {
const post = await postEngine.createPost({ title: '---Test---' });
expect(post.title).toBe('Untitled'); expect(post.slug).toBe('test');
});
it('should handle numbers in titles', async () => {
const post = await postEngine.createPost({ title: '10 Tips for Testing' });
expect(post.slug).toBe('10-tips-for-testing');
});
it('should convert multiple spaces to single hyphen', async () => {
const post = await postEngine.createPost({ title: 'Multiple Spaces Here' });
expect(post.slug).toBe('multiple-spaces-here');
});
it('should handle unicode characters by removing them', async () => {
const post = await postEngine.createPost({ title: 'Café Test' });
expect(post.slug).toBe('caf-test');
});
});
describe('Post Creation', () => {
it('should create a post with default values', async () => {
const post = await postEngine.createPost({ title: 'My Test Post' });
expect(post.id).toBeDefined();
expect(post.title).toBe('My Test Post');
expect(post.slug).toBe('my-test-post');
expect(post.status).toBe('draft'); expect(post.status).toBe('draft');
expect(post.content).toBe('');
expect(post.tags).toEqual([]); expect(post.tags).toEqual([]);
expect(post.categories).toEqual([]); expect(post.categories).toEqual([]);
}); });
it('should preserve provided values', () => { it('should create a post with provided content', async () => {
const post = createMockPost({ const post = await postEngine.createPost({
title: 'My Custom Title', title: 'Content Test',
status: 'published', content: '# Hello\n\nThis is my post.',
tags: ['custom', 'tag'],
}); });
expect(post.title).toBe('My Custom Title'); expect(post.content).toBe('# Hello\n\nThis is my post.');
expect(post.status).toBe('published');
expect(post.tags).toEqual(['custom', 'tag']);
}); });
});
describe('YAML Frontmatter Format', () => { it('should create a post with custom status', async () => {
it('should create valid frontmatter structure', () => { const post = await postEngine.createPost({
const post = createMockPost({ title: 'Published Post',
title: 'Test Post', status: 'published',
slug: 'test-post', });
status: 'draft',
expect(post.status).toBe('published');
});
it('should create a post with tags and categories', async () => {
const post = await postEngine.createPost({
title: 'Tagged Post',
tags: ['javascript', 'testing'],
categories: ['tutorials'],
});
expect(post.tags).toEqual(['javascript', 'testing']);
expect(post.categories).toEqual(['tutorials']);
});
it('should use custom slug when provided', async () => {
const post = await postEngine.createPost({
title: 'My Post',
slug: 'custom-permalink',
});
expect(post.slug).toBe('custom-permalink');
});
it('should set createdAt and updatedAt timestamps', async () => {
const before = new Date();
const post = await postEngine.createPost({ title: 'Timestamp Test' });
const after = new Date();
expect(post.createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(post.createdAt.getTime()).toBeLessThanOrEqual(after.getTime());
expect(post.updatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(post.updatedAt.getTime()).toBeLessThanOrEqual(after.getTime());
});
it('should emit postCreated event', async () => {
const handler = vi.fn();
postEngine.on('postCreated', handler);
const post = await postEngine.createPost({ title: 'Event Test' });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Event Test',
})
);
});
it('should use current project context for projectId', async () => {
postEngine.setProjectContext('my-project');
const post = await postEngine.createPost({ title: 'Project Test' });
expect(post.projectId).toBe('my-project');
});
it('should write post to filesystem', async () => {
const fs = await import('fs/promises');
await postEngine.createPost({ title: 'File Test' });
expect(fs.writeFile).toHaveBeenCalled();
expect(fs.mkdir).toHaveBeenCalled();
});
it('should insert into database', async () => {
await postEngine.createPost({ title: 'DB Test' });
expect(mockLocalDb.insert).toHaveBeenCalled();
});
it('should update FTS index when client is available', async () => {
await postEngine.createPost({ title: 'FTS Test' });
const ftsInsert = mockExecuteArgs.find((q) => q.sql.includes('posts_fts'));
expect(ftsInsert).toBeDefined();
});
it('should handle post without title using "Untitled"', async () => {
const post = await postEngine.createPost({});
expect(post.title).toBe('Untitled');
expect(post.slug).toBe('untitled');
});
it('should create post with author', async () => {
const post = await postEngine.createPost({
title: 'Author Test',
author: 'John Doe', author: 'John Doe',
tags: ['tech', 'tutorial'],
categories: ['programming'],
}); });
// Simulate frontmatter generation expect(post.author).toBe('John Doe');
const frontmatter = {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
status: post.status,
author: post.author,
createdAt: post.createdAt.toISOString(),
updatedAt: post.updatedAt.toISOString(),
publishedAt: post.publishedAt?.toISOString(),
tags: post.tags,
categories: post.categories,
};
expect(frontmatter.title).toBe('Test Post');
expect(frontmatter.tags).toEqual(['tech', 'tutorial']);
expect(frontmatter.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
}); });
it('should handle optional fields correctly', () => { it('should create post with excerpt', async () => {
const post = createMockPost({ const post = await postEngine.createPost({
excerpt: undefined, title: 'Excerpt Test',
author: undefined, excerpt: 'This is a short summary.',
publishedAt: undefined,
}); });
const frontmatter = { expect(post.excerpt).toBe('This is a short summary.');
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
status: post.status,
author: post.author,
createdAt: post.createdAt.toISOString(),
updatedAt: post.updatedAt.toISOString(),
publishedAt: post.publishedAt?.toISOString(),
tags: post.tags,
categories: post.categories,
};
expect(frontmatter.excerpt).toBeUndefined();
expect(frontmatter.author).toBeUndefined();
expect(frontmatter.publishedAt).toBeUndefined();
});
});
describe('Post Status Transitions', () => {
it('should allow valid status values', () => {
const validStatuses = ['draft', 'published', 'archived'] as const;
validStatuses.forEach(status => {
const post = createMockPost({ status });
expect(post.status).toBe(status);
});
}); });
it('should set publishedAt when publishing', () => { it('should handle publishedAt for published posts', async () => {
const now = new Date(); const publishDate = new Date('2024-01-15');
const post = createMockPost({ const post = await postEngine.createPost({
title: 'Published Post',
status: 'published', status: 'published',
publishedAt: now, publishedAt: publishDate,
}); });
expect(post.status).toBe('published'); expect(post.publishedAt).toEqual(publishDate);
expect(post.publishedAt).toEqual(now); });
});
describe('Event Emission', () => {
it('should be an EventEmitter', () => {
expect(postEngine.on).toBeDefined();
expect(postEngine.emit).toBeDefined();
expect(postEngine.removeListener).toBeDefined();
}); });
it('should not require publishedAt for drafts', () => { it('should allow adding event listeners', () => {
const post = createMockPost({ const listener = vi.fn();
status: 'draft', postEngine.on('testEvent', listener);
publishedAt: undefined, postEngine.emit('testEvent', { data: 'test' });
expect(listener).toHaveBeenCalledWith({ data: 'test' });
});
it('should allow removing event listeners', () => {
const listener = vi.fn();
postEngine.on('testEvent', listener);
postEngine.removeListener('testEvent', listener);
postEngine.emit('testEvent', { data: 'test' });
expect(listener).not.toHaveBeenCalled();
});
});
describe('Post Creation writes correct file format', () => {
it('should write markdown file with YAML frontmatter', async () => {
const fs = await import('fs/promises');
await postEngine.createPost({
title: 'Frontmatter Test',
content: '# Hello World',
tags: ['test'],
}); });
expect(fs.writeFile).toHaveBeenCalled();
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
const filePath = writeCall[0] as string;
const content = writeCall[1] as string;
expect(filePath).toContain('frontmatter-test.md');
expect(content).toContain('---');
expect(content).toContain('title: Frontmatter Test');
expect(content).toContain('# Hello World');
});
});
describe('Multiple post creation', () => {
it('should create multiple posts with unique IDs', async () => {
const post1 = await postEngine.createPost({ title: 'Post 1' });
const post2 = await postEngine.createPost({ title: 'Post 2' });
expect(post1.id).toBeDefined();
expect(post2.id).toBeDefined();
expect(post1.id).not.toBe(post2.id);
});
it('should create posts with different slugs', async () => {
const post1 = await postEngine.createPost({ title: 'First Post' });
const post2 = await postEngine.createPost({ title: 'Second Post' });
expect(post1.slug).toBe('first-post');
expect(post2.slug).toBe('second-post');
});
});
describe('Post status values', () => {
it('should accept draft status', async () => {
const post = await postEngine.createPost({ title: 'Draft', status: 'draft' });
expect(post.status).toBe('draft'); expect(post.status).toBe('draft');
expect(post.publishedAt).toBeUndefined(); });
it('should accept published status', async () => {
const post = await postEngine.createPost({ title: 'Published', status: 'published' });
expect(post.status).toBe('published');
});
it('should accept archived status', async () => {
const post = await postEngine.createPost({ title: 'Archived', status: 'archived' });
expect(post.status).toBe('archived');
}); });
}); });
describe('File Path Generation', () => { describe('Post with all fields', () => {
it('should generate correct file path from slug', () => { it('should create a fully populated post', async () => {
const postsDir = '/mock/userData/posts'; const publishDate = new Date('2024-06-15');
const slug = 'my-first-post'; const post = await postEngine.createPost({
title: 'Complete Post',
const filePath = `${postsDir}/${slug}.md`; slug: 'complete-post',
content: '# Complete\n\nFull content here.',
expect(filePath).toBe('/mock/userData/posts/my-first-post.md'); excerpt: 'A complete post with all fields.',
}); status: 'published',
author: 'Jane Doe',
it('should handle slugs with numbers', () => { publishedAt: publishDate,
const postsDir = '/mock/userData/posts'; tags: ['complete', 'full', 'test'],
const slug = 'post-123-test'; categories: ['testing', 'examples'],
const filePath = `${postsDir}/${slug}.md`;
expect(filePath).toBe('/mock/userData/posts/post-123-test.md');
});
});
describe('Tags and Categories', () => {
it('should serialize tags to JSON', () => {
const tags = ['javascript', 'typescript', 'node'];
const serialized = JSON.stringify(tags);
expect(serialized).toBe('["javascript","typescript","node"]');
expect(JSON.parse(serialized)).toEqual(tags);
});
it('should handle empty arrays', () => {
const tags: string[] = [];
const serialized = JSON.stringify(tags);
expect(serialized).toBe('[]');
expect(JSON.parse(serialized)).toEqual([]);
});
it('should handle tags with special characters', () => {
const tags = ['c#', 'c++', 'node.js'];
const serialized = JSON.stringify(tags);
expect(JSON.parse(serialized)).toEqual(tags);
});
});
describe('Date Handling', () => {
it('should use ISO format for dates', () => {
const date = new Date('2024-01-15T10:30:00.000Z');
const isoString = date.toISOString();
expect(isoString).toBe('2024-01-15T10:30:00.000Z');
});
it('should parse ISO dates correctly', () => {
const isoString = '2024-01-15T10:30:00.000Z';
const date = new Date(isoString);
expect(date.getUTCFullYear()).toBe(2024);
expect(date.getUTCMonth()).toBe(0); // January
expect(date.getUTCDate()).toBe(15);
});
it('should handle updatedAt being later than createdAt', () => {
const createdAt = new Date('2024-01-15T10:00:00.000Z');
const updatedAt = new Date('2024-01-16T15:30:00.000Z');
const post = createMockPost({ createdAt, updatedAt });
expect(post.updatedAt.getTime()).toBeGreaterThan(post.createdAt.getTime());
});
});
});
describe('PostEngine Integration Helpers', () => {
describe('Database Record Conversion', () => {
it('should convert PostData to database record format', () => {
const post = createMockPost({
tags: ['a', 'b'],
categories: ['c'],
}); });
const dbRecord = { expect(post.title).toBe('Complete Post');
id: post.id, expect(post.slug).toBe('complete-post');
title: post.title, expect(post.content).toBe('# Complete\n\nFull content here.');
slug: post.slug, expect(post.excerpt).toBe('A complete post with all fields.');
excerpt: post.excerpt, expect(post.status).toBe('published');
status: post.status, expect(post.author).toBe('Jane Doe');
author: post.author, expect(post.publishedAt).toEqual(publishDate);
createdAt: post.createdAt, expect(post.tags).toEqual(['complete', 'full', 'test']);
updatedAt: post.updatedAt, expect(post.categories).toEqual(['testing', 'examples']);
publishedAt: post.publishedAt,
filePath: `/mock/userData/posts/${post.slug}.md`,
syncStatus: 'pending' as const,
checksum: 'abc123',
tags: JSON.stringify(post.tags),
categories: JSON.stringify(post.categories),
};
expect(dbRecord.tags).toBe('["a","b"]');
expect(dbRecord.categories).toBe('["c"]');
});
it('should convert database record to PostData format', () => {
const dbRecord = {
id: 'post-1',
title: 'Test Post',
slug: 'test-post',
excerpt: 'An excerpt',
status: 'draft' as const,
author: 'Author',
createdAt: new Date(),
updatedAt: new Date(),
publishedAt: null,
filePath: '/mock/path.md',
syncStatus: 'pending' as const,
syncedAt: null,
checksum: 'abc123',
tags: '["a","b"]',
categories: '["c"]',
};
const postData = {
id: dbRecord.id,
title: dbRecord.title,
slug: dbRecord.slug,
excerpt: dbRecord.excerpt,
status: dbRecord.status,
author: dbRecord.author,
createdAt: dbRecord.createdAt,
updatedAt: dbRecord.updatedAt,
publishedAt: dbRecord.publishedAt || undefined,
tags: JSON.parse(dbRecord.tags) as string[],
categories: JSON.parse(dbRecord.categories) as string[],
content: '', // Would be read from file
};
expect(postData.tags).toEqual(['a', 'b']);
expect(postData.categories).toEqual(['c']);
expect(postData.publishedAt).toBeUndefined();
}); });
}); });
}); });

View File

@@ -0,0 +1,376 @@
/**
* ProjectEngine Unit Tests
*
* Tests the REAL ProjectEngine class with mocked dependencies.
* Following TDD best practices: mock external dependencies, test real implementation.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ProjectEngine, ProjectData } from '../../src/main/engine/ProjectEngine';
import { resetMockCounters } from '../utils/factories';
// Create mock data stores
const mockProjects = new Map<string, any>();
// Create chainable mock for Drizzle ORM
function createSelectChain() {
return {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockImplementation(function (this: any, condition: any) {
return this;
}),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
all: vi.fn().mockImplementation(() => Promise.resolve(Array.from(mockProjects.values()))),
get: vi.fn().mockImplementation(() => Promise.resolve(undefined)),
};
}
function createDrizzleMock() {
return {
select: vi.fn(() => createSelectChain()),
insert: vi.fn(() => ({
values: vi.fn((data: any) => {
if (data && data.id) {
mockProjects.set(data.id, data);
}
return Promise.resolve();
}),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
};
}
const mockLocalDb = createDrizzleMock();
// Mock the database module
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => mockLocalDb),
getLocalClient: vi.fn(() => null),
getRemote: vi.fn(() => null),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
media: '/mock/userData/media',
})),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(),
close: vi.fn(),
})),
}));
// Mock fs/promises
vi.mock('fs/promises', () => ({
readFile: vi.fn(async () => ''),
writeFile: vi.fn(async () => {}),
unlink: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
readdir: vi.fn(async () => []),
stat: vi.fn(async () => ({
isFile: () => false,
isDirectory: () => true,
size: 0,
})),
access: vi.fn(async () => {}),
}));
// Mock uuid
vi.mock('uuid', () => ({
v4: vi.fn(() => 'mock-project-uuid-' + Math.random().toString(36).substr(2, 9)),
}));
describe('ProjectEngine', () => {
let projectEngine: ProjectEngine;
beforeEach(() => {
vi.clearAllMocks();
mockProjects.clear();
resetMockCounters();
// Reset the mock implementations
vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain());
projectEngine = new ProjectEngine();
});
describe('Constructor and Initialization', () => {
it('should create a ProjectEngine instance', () => {
expect(projectEngine).toBeInstanceOf(ProjectEngine);
});
it('should extend EventEmitter', () => {
expect(typeof projectEngine.on).toBe('function');
expect(typeof projectEngine.emit).toBe('function');
});
});
describe('Slug Generation via createProject', () => {
it('should generate slug from name with lowercase', async () => {
const project = await projectEngine.createProject({ name: 'My Blog' });
expect(project.slug).toBe('my-blog');
});
it('should replace special characters with hyphens', async () => {
const project = await projectEngine.createProject({ name: 'My Blog & Notes!' });
expect(project.slug).toBe('my-blog-notes');
});
it('should remove leading and trailing hyphens', async () => {
const project = await projectEngine.createProject({ name: '---Test Blog---' });
expect(project.slug).toBe('test-blog');
});
it('should handle numbers in names', async () => {
const project = await projectEngine.createProject({ name: 'Blog 2024' });
expect(project.slug).toBe('blog-2024');
});
it('should use custom slug when provided', async () => {
const project = await projectEngine.createProject({
name: 'My Blog',
slug: 'custom-slug',
});
expect(project.slug).toBe('custom-slug');
});
});
describe('Project Creation', () => {
it('should create a project with required fields', async () => {
const project = await projectEngine.createProject({ name: 'Test Project' });
expect(project.id).toBeDefined();
expect(project.name).toBe('Test Project');
expect(project.slug).toBe('test-project');
});
it('should create a project with description', async () => {
const project = await projectEngine.createProject({
name: 'My Blog',
description: 'A personal blog about technology',
});
expect(project.description).toBe('A personal blog about technology');
});
it('should set isActive to false by default', async () => {
const project = await projectEngine.createProject({ name: 'New Project' });
expect(project.isActive).toBe(false);
});
it('should set createdAt and updatedAt timestamps', async () => {
const before = new Date();
const project = await projectEngine.createProject({ name: 'Timestamp Test' });
const after = new Date();
expect(project.createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(project.createdAt.getTime()).toBeLessThanOrEqual(after.getTime());
expect(project.updatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(project.updatedAt.getTime()).toBeLessThanOrEqual(after.getTime());
});
it('should emit projectCreated event', async () => {
const handler = vi.fn();
projectEngine.on('projectCreated', handler);
const project = await projectEngine.createProject({ name: 'Event Test' });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Event Test',
})
);
});
it('should insert project into database', async () => {
await projectEngine.createProject({ name: 'DB Test' });
expect(mockLocalDb.insert).toHaveBeenCalled();
});
it('should create project directories', async () => {
const fs = await import('fs/promises');
await projectEngine.createProject({ name: 'Directory Test' });
// Should create project, posts, and media directories
expect(vi.mocked(fs.mkdir).mock.calls.length).toBeGreaterThanOrEqual(3);
});
});
describe('Project Deletion', () => {
it('should not allow deleting the default project', async () => {
await expect(projectEngine.deleteProject('default')).rejects.toThrow(
'Cannot delete the default project'
);
});
it('should return false for non-existent project', async () => {
const result = await projectEngine.deleteProject('non-existent-id');
expect(result).toBe(false);
});
it('should emit projectDeleted event when successful', async () => {
// Setup: Add a project to mock
const projectId = 'test-project-id';
mockProjects.set(projectId, {
id: projectId,
name: 'Test Project',
slug: 'test-project',
createdAt: new Date(),
updatedAt: new Date(),
isActive: false,
});
// Make get() return the project
vi.mocked(mockLocalDb.select).mockImplementation(() => ({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
all: vi.fn().mockImplementation(() => Promise.resolve(Array.from(mockProjects.values()))),
get: vi.fn().mockImplementation(() => Promise.resolve(mockProjects.get(projectId))),
}));
const handler = vi.fn();
projectEngine.on('projectDeleted', handler);
const result = await projectEngine.deleteProject(projectId);
expect(result).toBe(true);
expect(handler).toHaveBeenCalledWith(projectId);
});
});
describe('getProjectPaths', () => {
it('should return paths for posts and media directories', () => {
const paths = projectEngine.getProjectPaths('my-blog');
expect(paths.posts).toContain('my-blog');
expect(paths.posts).toContain('posts');
expect(paths.media).toContain('my-blog');
expect(paths.media).toContain('media');
});
it('should include project slug in paths', () => {
const paths = projectEngine.getProjectPaths('custom-project');
expect(paths.posts).toContain('custom-project');
expect(paths.media).toContain('custom-project');
});
});
describe('Event Emission', () => {
it('should be an EventEmitter', () => {
expect(projectEngine.on).toBeDefined();
expect(projectEngine.emit).toBeDefined();
expect(projectEngine.removeListener).toBeDefined();
});
it('should allow adding event listeners', () => {
const listener = vi.fn();
projectEngine.on('testEvent', listener);
projectEngine.emit('testEvent', { data: 'test' });
expect(listener).toHaveBeenCalledWith({ data: 'test' });
});
it('should allow removing event listeners', () => {
const listener = vi.fn();
projectEngine.on('testEvent', listener);
projectEngine.removeListener('testEvent', listener);
projectEngine.emit('testEvent', { data: 'test' });
expect(listener).not.toHaveBeenCalled();
});
});
describe('Multiple Project Creation', () => {
it('should create multiple projects with unique IDs', async () => {
const project1 = await projectEngine.createProject({ name: 'Project 1' });
const project2 = await projectEngine.createProject({ name: 'Project 2' });
expect(project1.id).toBeDefined();
expect(project2.id).toBeDefined();
expect(project1.id).not.toBe(project2.id);
});
it('should create projects with different slugs', async () => {
const project1 = await projectEngine.createProject({ name: 'First Project' });
const project2 = await projectEngine.createProject({ name: 'Second Project' });
expect(project1.slug).toBe('first-project');
expect(project2.slug).toBe('second-project');
});
});
describe('Project with all fields', () => {
it('should create a fully populated project', async () => {
const project = await projectEngine.createProject({
name: 'Complete Project',
slug: 'complete-project',
description: 'A complete project with all fields filled in.',
});
expect(project.name).toBe('Complete Project');
expect(project.slug).toBe('complete-project');
expect(project.description).toBe('A complete project with all fields filled in.');
expect(project.isActive).toBe(false);
expect(project.id).toBeDefined();
expect(project.createdAt).toBeInstanceOf(Date);
expect(project.updatedAt).toBeInstanceOf(Date);
});
});
describe('ProjectData Structure', () => {
it('should have all required fields', async () => {
const project = await projectEngine.createProject({ name: 'Structure Test' });
expect(project).toHaveProperty('id');
expect(project).toHaveProperty('name');
expect(project).toHaveProperty('slug');
expect(project).toHaveProperty('createdAt');
expect(project).toHaveProperty('updatedAt');
expect(project).toHaveProperty('isActive');
});
it('should have optional description field', async () => {
const projectWithDesc = await projectEngine.createProject({
name: 'With Description',
description: 'Has a description',
});
const projectWithoutDesc = await projectEngine.createProject({
name: 'Without Description',
});
expect(projectWithDesc.description).toBe('Has a description');
expect(projectWithoutDesc.description).toBeUndefined();
});
});
describe('Slug Uniqueness', () => {
it('should handle duplicate slugs by appending counter', async () => {
// Create first project
await projectEngine.createProject({ name: 'My Blog' });
// Mock that 'my-blog' slug already exists
mockProjects.set('existing', { slug: 'my-blog' });
const project2 = await projectEngine.createProject({ name: 'My Blog' });
// Second project should have a modified slug
expect(project2.slug).toMatch(/^my-blog(-\d+)?$/);
});
});
});

View File

@@ -1,568 +1,453 @@
/** /**
* SyncEngine Unit Tests * SyncEngine Unit Tests
* *
* Tests for remote synchronization including: * Tests the REAL SyncEngine class with mocked dependencies.
* - Sync configuration * Following TDD best practices: mock external dependencies, test real implementation.
* - Push/pull operations
* - Conflict detection and resolution
* - Sync status tracking
* - Retry logic
*/ */
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { SyncEngine, SyncConfig, SyncResult, SyncDirection } from '../../src/main/engine/SyncEngine';
import { resetMockCounters } from '../utils/factories'; import { resetMockCounters } from '../utils/factories';
// Mock dependencies // Create mock data stores
vi.mock('../../src/main/database', () => ({ const mockPosts = new Map<string, any>();
getDatabase: vi.fn(() => ({ const mockMedia = new Map<string, any>();
getLocal: vi.fn(() => ({ const mockSyncLog = new Map<string, any>();
select: vi.fn(() => ({
from: vi.fn(() => ({ // Create chainable mock for Drizzle ORM
where: vi.fn(() => Promise.resolve([])), function createSelectChain(data: Map<string, any>) {
orderBy: vi.fn(() => Promise.resolve([])), return {
})), from: vi.fn().mockReturnThis(),
})), where: vi.fn().mockReturnThis(),
insert: vi.fn(() => ({ orderBy: vi.fn().mockReturnThis(),
values: vi.fn(() => Promise.resolve()), limit: vi.fn().mockReturnThis(),
})), offset: vi.fn().mockReturnThis(),
update: vi.fn(() => ({ all: vi.fn().mockImplementation(() => Promise.resolve(Array.from(data.values()))),
set: vi.fn(() => ({ get: vi.fn().mockImplementation(() => Promise.resolve(data.size > 0 ? Array.from(data.values())[0] : undefined)),
where: vi.fn(() => Promise.resolve()), };
})), }
})),
delete: vi.fn(() => ({ function createDrizzleMock(data: Map<string, any>) {
return {
select: vi.fn(() => createSelectChain(data)),
insert: vi.fn(() => ({
values: vi.fn((record: any) => {
if (record && record.id) {
data.set(record.id, record);
}
return Promise.resolve();
}),
onConflictDoUpdate: vi.fn(() => Promise.resolve()),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()), where: vi.fn(() => Promise.resolve()),
})), })),
})), })),
getRemote: vi.fn(() => null), delete: vi.fn(() => ({
initializeLocal: vi.fn(), where: vi.fn(() => Promise.resolve()),
initializeRemote: vi.fn(), })),
};
}
const mockLocalDb = createDrizzleMock(new Map());
const mockRemoteDb = createDrizzleMock(new Map());
// Mock the database module
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => mockLocalDb),
getLocalClient: vi.fn(() => null),
getRemote: vi.fn(() => null), // Will be overridden in tests
getDataPaths: vi.fn(() => ({ getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db', database: '/mock/userData/bds.db',
posts: '/mock/userData/posts', posts: '/mock/userData/posts',
media: '/mock/userData/media', media: '/mock/userData/media',
})), })),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(async () => {}),
close: vi.fn(),
})), })),
})); }));
// Mock PostEngine and MediaEngine
vi.mock('../../src/main/engine/PostEngine', () => ({ vi.mock('../../src/main/engine/PostEngine', () => ({
getPostEngine: vi.fn(() => ({ getPostEngine: vi.fn(() => ({
getAllPosts: vi.fn(() => Promise.resolve([])), on: vi.fn(),
createPost: vi.fn(), emit: vi.fn(),
updatePost: vi.fn(),
deletePost: vi.fn(),
})), })),
})); }));
vi.mock('../../src/main/engine/MediaEngine', () => ({ vi.mock('../../src/main/engine/MediaEngine', () => ({
getMediaEngine: vi.fn(() => ({ getMediaEngine: vi.fn(() => ({
getAllMedia: vi.fn(() => Promise.resolve([])), on: vi.fn(),
importMedia: vi.fn(), emit: vi.fn(),
updateMedia: vi.fn(),
deleteMedia: vi.fn(),
})), })),
})); }));
// Mock uuid
vi.mock('uuid', () => ({
v4: vi.fn(() => 'mock-sync-uuid-' + Math.random().toString(36).substr(2, 9)),
}));
describe('SyncEngine', () => { describe('SyncEngine', () => {
let syncEngine: SyncEngine;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
resetMockCounters();
vi.useFakeTimers(); vi.useFakeTimers();
mockPosts.clear();
mockMedia.clear();
mockSyncLog.clear();
resetMockCounters();
syncEngine = new SyncEngine();
}); });
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
syncEngine.stopAutoSync();
}); });
describe('Sync Configuration', () => { describe('Constructor and Initialization', () => {
it('should validate sync config structure', () => { it('should create a SyncEngine instance', () => {
interface SyncConfig { expect(syncEngine).toBeInstanceOf(SyncEngine);
tursoUrl: string;
tursoAuthToken: string;
autoSync: boolean;
syncInterval: number;
}
const validConfig: SyncConfig = {
tursoUrl: 'libsql://mydb.turso.io',
tursoAuthToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
autoSync: true,
syncInterval: 5,
};
expect(validConfig.tursoUrl).toMatch(/^libsql:\/\//);
expect(validConfig.tursoAuthToken).toBeDefined();
expect(validConfig.autoSync).toBe(true);
expect(validConfig.syncInterval).toBeGreaterThan(0);
}); });
it('should detect unconfigured state', () => { it('should extend EventEmitter', () => {
const isConfigured = (config: { tursoUrl?: string; tursoAuthToken?: string } | null): boolean => { expect(typeof syncEngine.on).toBe('function');
return config !== null && expect(typeof syncEngine.emit).toBe('function');
!!config.tursoUrl &&
!!config.tursoAuthToken;
};
expect(isConfigured(null)).toBe(false);
expect(isConfigured({ tursoUrl: '', tursoAuthToken: '' })).toBe(false);
expect(isConfigured({ tursoUrl: 'url', tursoAuthToken: '' })).toBe(false);
expect(isConfigured({ tursoUrl: 'url', tursoAuthToken: 'token' })).toBe(true);
}); });
it('should calculate sync interval in milliseconds', () => { it('should start with idle status', () => {
const minutesToMs = (minutes: number): number => minutes * 60 * 1000; expect(syncEngine.getSyncStatus()).toBe('idle');
});
expect(minutesToMs(1)).toBe(60000); it('should not be configured initially', () => {
expect(minutesToMs(5)).toBe(300000); expect(syncEngine.isConfigured()).toBe(false);
expect(minutesToMs(15)).toBe(900000);
}); });
}); });
describe('Sync Direction', () => { describe('Configuration', () => {
it('should support push direction', () => { it('should configure sync settings', async () => {
type SyncDirection = 'push' | 'pull' | 'bidirectional'; const config: SyncConfig = {
const direction: SyncDirection = 'push'; tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: 'test-token',
expect(['push', 'pull', 'bidirectional']).toContain(direction); autoSync: false,
}); syncInterval: 30,
it('should support pull direction', () => {
type SyncDirection = 'push' | 'pull' | 'bidirectional';
const direction: SyncDirection = 'pull';
expect(['push', 'pull', 'bidirectional']).toContain(direction);
});
it('should support bidirectional sync', () => {
type SyncDirection = 'push' | 'pull' | 'bidirectional';
const direction: SyncDirection = 'bidirectional';
expect(['push', 'pull', 'bidirectional']).toContain(direction);
});
});
describe('Sync Status', () => {
it('should track sync status states', () => {
type SyncStatus = 'idle' | 'syncing' | 'error';
const validStatuses: SyncStatus[] = ['idle', 'syncing', 'error'];
validStatuses.forEach(status => {
expect(['idle', 'syncing', 'error']).toContain(status);
});
});
it('should prevent concurrent syncs', () => {
let syncStatus: 'idle' | 'syncing' | 'error' = 'idle';
const canSync = (): boolean => syncStatus !== 'syncing';
expect(canSync()).toBe(true);
syncStatus = 'syncing';
expect(canSync()).toBe(false);
syncStatus = 'idle';
expect(canSync()).toBe(true);
});
});
describe('Sync Result', () => {
it('should create sync result structure', () => {
interface SyncResult {
success: boolean;
pushed: number;
pulled: number;
conflicts: number;
errors: string[];
}
const successResult: SyncResult = {
success: true,
pushed: 5,
pulled: 3,
conflicts: 0,
errors: [],
}; };
expect(successResult.success).toBe(true); await syncEngine.configure(config);
expect(successResult.pushed + successResult.pulled).toBe(8);
expect(successResult.errors).toHaveLength(0); expect(syncEngine.isConfigured()).toBe(true);
}); });
it('should report errors in result', () => { it('should emit configured event', async () => {
interface SyncResult { const handler = vi.fn();
success: boolean; syncEngine.on('configured', handler);
pushed: number;
pulled: number;
conflicts: number;
errors: string[];
}
const errorResult: SyncResult = { const config: SyncConfig = {
success: false, tursoUrl: 'libsql://test.turso.io',
pushed: 0, tursoAuthToken: 'test-token',
pulled: 0, autoSync: false,
conflicts: 0, syncInterval: 30,
errors: ['Network timeout', 'Authentication failed'],
}; };
expect(errorResult.success).toBe(false); await syncEngine.configure(config);
expect(errorResult.errors).toHaveLength(2);
expect(errorResult.errors).toContain('Network timeout'); expect(handler).toHaveBeenCalledWith(config);
}); });
it('should track conflicts', () => { it('should not be configured with empty URL', async () => {
interface SyncResult { const config: SyncConfig = {
success: boolean; tursoUrl: '',
pushed: number; tursoAuthToken: 'test-token',
pulled: number; autoSync: false,
conflicts: number; syncInterval: 30,
errors: string[];
}
const conflictResult: SyncResult = {
success: true, // Partial success
pushed: 4,
pulled: 2,
conflicts: 2,
errors: [],
}; };
expect(conflictResult.conflicts).toBeGreaterThan(0); await syncEngine.configure(config);
expect(syncEngine.isConfigured()).toBe(false);
}); });
});
describe('Entity Sync Status', () => { it('should not be configured with empty token', async () => {
it('should track sync status per entity', () => { const config: SyncConfig = {
type EntitySyncStatus = 'pending' | 'syncing' | 'synced' | 'conflict'; tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: '',
interface SyncableEntity { autoSync: false,
id: string; syncInterval: 30,
syncStatus: EntitySyncStatus;
syncedAt: Date | null;
checksum: string;
}
const entity: SyncableEntity = {
id: 'post-1',
syncStatus: 'pending',
syncedAt: null,
checksum: 'abc123',
}; };
expect(entity.syncStatus).toBe('pending'); await syncEngine.configure(config);
expect(entity.syncedAt).toBeNull();
});
it('should update sync status after successful sync', () => { expect(syncEngine.isConfigured()).toBe(false);
interface SyncableEntity {
syncStatus: 'pending' | 'syncing' | 'synced' | 'conflict';
syncedAt: Date | null;
}
const entity: SyncableEntity = {
syncStatus: 'pending',
syncedAt: null,
};
// Simulate sync completion
entity.syncStatus = 'synced';
entity.syncedAt = new Date();
expect(entity.syncStatus).toBe('synced');
expect(entity.syncedAt).toBeInstanceOf(Date);
});
});
describe('Conflict Detection', () => {
it('should detect conflict by checksum mismatch', () => {
const detectConflict = (localChecksum: string, remoteChecksum: string): boolean => {
return localChecksum !== remoteChecksum;
};
expect(detectConflict('abc123', 'abc123')).toBe(false);
expect(detectConflict('abc123', 'xyz789')).toBe(true);
});
it('should create conflict info structure', () => {
interface ConflictInfo {
entityId: string;
entityType: 'post' | 'media';
localChecksum: string;
remoteChecksum: string;
localUpdatedAt: Date;
remoteUpdatedAt: Date;
}
const conflict: ConflictInfo = {
entityId: 'post-1',
entityType: 'post',
localChecksum: 'local123',
remoteChecksum: 'remote456',
localUpdatedAt: new Date('2024-01-15T10:00:00Z'),
remoteUpdatedAt: new Date('2024-01-15T11:00:00Z'),
};
expect(conflict.localChecksum).not.toBe(conflict.remoteChecksum);
});
});
describe('Conflict Resolution', () => {
it('should support local-wins strategy', () => {
type ConflictResolution = 'local-wins' | 'remote-wins' | 'manual';
const resolveConflict = (strategy: ConflictResolution): 'local' | 'remote' | 'prompt' => {
switch (strategy) {
case 'local-wins': return 'local';
case 'remote-wins': return 'remote';
case 'manual': return 'prompt';
}
};
expect(resolveConflict('local-wins')).toBe('local');
});
it('should support remote-wins strategy', () => {
type ConflictResolution = 'local-wins' | 'remote-wins' | 'manual';
const resolveConflict = (strategy: ConflictResolution): 'local' | 'remote' | 'prompt' => {
switch (strategy) {
case 'local-wins': return 'local';
case 'remote-wins': return 'remote';
case 'manual': return 'prompt';
}
};
expect(resolveConflict('remote-wins')).toBe('remote');
});
it('should support last-write-wins based on timestamp', () => {
const lastWriteWins = (localTime: Date, remoteTime: Date): 'local' | 'remote' => {
return localTime.getTime() > remoteTime.getTime() ? 'local' : 'remote';
};
const earlier = new Date('2024-01-15T10:00:00Z');
const later = new Date('2024-01-15T11:00:00Z');
expect(lastWriteWins(later, earlier)).toBe('local');
expect(lastWriteWins(earlier, later)).toBe('remote');
});
});
describe('Retry Logic', () => {
it('should calculate exponential backoff delay', () => {
const getBackoffDelay = (attempt: number, baseDelay: number = 1000): number => {
return Math.pow(2, attempt) * baseDelay;
};
expect(getBackoffDelay(1)).toBe(2000); // 2^1 * 1000
expect(getBackoffDelay(2)).toBe(4000); // 2^2 * 1000
expect(getBackoffDelay(3)).toBe(8000); // 2^3 * 1000
expect(getBackoffDelay(4)).toBe(16000); // 2^4 * 1000
});
it('should cap maximum retry delay', () => {
const getBackoffDelay = (attempt: number, baseDelay: number = 1000, maxDelay: number = 30000): number => {
const delay = Math.pow(2, attempt) * baseDelay;
return Math.min(delay, maxDelay);
};
expect(getBackoffDelay(5)).toBe(30000); // Capped at max
expect(getBackoffDelay(10)).toBe(30000); // Still capped
});
it('should track retry count', () => {
interface RetryState {
attempts: number;
maxAttempts: number;
lastError: string | null;
}
const state: RetryState = {
attempts: 0,
maxAttempts: 3,
lastError: null,
};
const shouldRetry = (): boolean => state.attempts < state.maxAttempts;
expect(shouldRetry()).toBe(true);
state.attempts = 3;
expect(shouldRetry()).toBe(false);
});
});
describe('Sync Log', () => {
it('should create sync log entry structure', () => {
interface SyncLogEntry {
id: string;
entityType: 'post' | 'media';
entityId: string;
operation: 'push' | 'pull' | 'conflict';
status: 'pending' | 'success' | 'failed';
timestamp: Date;
errorMessage?: string;
}
const logEntry: SyncLogEntry = {
id: 'log-1',
entityType: 'post',
entityId: 'post-123',
operation: 'push',
status: 'success',
timestamp: new Date(),
};
expect(logEntry.operation).toBe('push');
expect(logEntry.status).toBe('success');
expect(logEntry.errorMessage).toBeUndefined();
});
it('should log errors', () => {
interface SyncLogEntry {
id: string;
entityType: 'post' | 'media';
entityId: string;
operation: 'push' | 'pull' | 'conflict';
status: 'pending' | 'success' | 'failed';
timestamp: Date;
errorMessage?: string;
}
const errorLogEntry: SyncLogEntry = {
id: 'log-2',
entityType: 'post',
entityId: 'post-456',
operation: 'push',
status: 'failed',
timestamp: new Date(),
errorMessage: 'Network timeout after 30s',
};
expect(errorLogEntry.status).toBe('failed');
expect(errorLogEntry.errorMessage).toBeDefined();
});
});
describe('Pending Changes', () => {
it('should count pending changes', () => {
const entities = [
{ id: '1', syncStatus: 'pending' },
{ id: '2', syncStatus: 'synced' },
{ id: '3', syncStatus: 'pending' },
{ id: '4', syncStatus: 'conflict' },
];
const pendingCount = entities.filter(e => e.syncStatus === 'pending').length;
expect(pendingCount).toBe(2);
});
it('should identify entities needing sync', () => {
type SyncStatus = 'pending' | 'syncing' | 'synced' | 'conflict';
const needsSync = (status: SyncStatus): boolean => {
return status === 'pending' || status === 'conflict';
};
expect(needsSync('pending')).toBe(true);
expect(needsSync('conflict')).toBe(true);
expect(needsSync('synced')).toBe(false);
expect(needsSync('syncing')).toBe(false);
}); });
}); });
describe('Auto Sync', () => { describe('Auto Sync', () => {
it('should start auto sync interval', () => { it('should start auto sync when enabled', async () => {
const setIntervalSpy = vi.spyOn(global, 'setInterval'); const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
const startAutoSync = (intervalMinutes: number, callback: () => void): NodeJS.Timeout => { tursoAuthToken: 'test-token',
return setInterval(callback, intervalMinutes * 60 * 1000); autoSync: true,
syncInterval: 1, // 1 minute
}; };
const callback = vi.fn(); await syncEngine.configure(config);
const intervalId = startAutoSync(5, callback);
expect(setIntervalSpy).toHaveBeenCalledWith(callback, 300000); // Auto sync should be scheduled
expect(syncEngine.isConfigured()).toBe(true);
clearInterval(intervalId);
}); });
it('should stop auto sync interval', () => { it('should stop auto sync when called', async () => {
const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); const handler = vi.fn();
syncEngine.on('autoSyncStopped', handler);
const intervalId = setInterval(() => {}, 1000);
clearInterval(intervalId);
expect(clearIntervalSpy).toHaveBeenCalled(); const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: 'test-token',
autoSync: true,
syncInterval: 1,
};
await syncEngine.configure(config);
syncEngine.stopAutoSync();
expect(handler).toHaveBeenCalled();
}); });
it('should trigger sync on interval', () => { it('should stop previous auto sync when reconfiguring', async () => {
const syncCallback = vi.fn(); const config1: SyncConfig = {
const intervalId = setInterval(syncCallback, 1000); tursoUrl: 'libsql://test1.turso.io',
tursoAuthToken: 'test-token-1',
autoSync: true,
syncInterval: 1,
};
vi.advanceTimersByTime(3000); const config2: SyncConfig = {
tursoUrl: 'libsql://test2.turso.io',
tursoAuthToken: 'test-token-2',
autoSync: true,
syncInterval: 5,
};
expect(syncCallback).toHaveBeenCalledTimes(3); await syncEngine.configure(config1);
await syncEngine.configure(config2);
clearInterval(intervalId);
}); expect(syncEngine.isConfigured()).toBe(true);
}); });
}); });
describe('SyncEngine Error Handling', () => { describe('Sync Status', () => {
describe('Network Errors', () => { it('should return idle when not syncing', () => {
it('should identify network errors', () => { expect(syncEngine.getSyncStatus()).toBe('idle');
const isNetworkError = (error: Error): boolean => { });
const networkErrorPatterns = [ });
'network',
'timeout', describe('Sync without Configuration', () => {
'ECONNREFUSED', it('should return error when syncing without configuration', async () => {
'ENOTFOUND', const result = await syncEngine.sync('bidirectional');
'fetch failed',
]; expect(result.success).toBe(false);
return networkErrorPatterns.some(pattern => expect(result.errors).toContain('Sync not configured');
error.message.toLowerCase().includes(pattern.toLowerCase()) });
);
}; it('should return zero counts when not configured', async () => {
const result = await syncEngine.sync('push');
expect(isNetworkError(new Error('Network timeout'))).toBe(true);
expect(isNetworkError(new Error('ECONNREFUSED'))).toBe(true); expect(result.pushed).toBe(0);
expect(isNetworkError(new Error('Invalid data'))).toBe(false); expect(result.pulled).toBe(0);
}); expect(result.conflicts).toBe(0);
});
it('should create user-friendly error messages', () => { });
const getUserFriendlyMessage = (error: Error): string => {
if (error.message.includes('timeout')) { describe('Sync Directions', () => {
return 'Connection timed out. Please check your internet connection.'; it('should accept push direction', async () => {
} const result = await syncEngine.sync('push');
if (error.message.includes('ECONNREFUSED')) { expect(result).toBeDefined();
return 'Unable to connect to sync server. Please try again later.'; });
}
if (error.message.includes('401') || error.message.includes('unauthorized')) { it('should accept pull direction', async () => {
return 'Authentication failed. Please check your sync credentials.'; const result = await syncEngine.sync('pull');
} expect(result).toBeDefined();
return 'An unexpected error occurred during sync.'; });
};
it('should accept bidirectional direction', async () => {
expect(getUserFriendlyMessage(new Error('timeout'))).toContain('timed out'); const result = await syncEngine.sync('bidirectional');
expect(getUserFriendlyMessage(new Error('401 unauthorized'))).toContain('Authentication'); expect(result).toBeDefined();
}); });
});
it('should default to bidirectional when no direction specified', async () => {
describe('Authentication Errors', () => { const result = await syncEngine.sync();
it('should detect auth errors', () => { expect(result).toBeDefined();
const isAuthError = (error: Error | { status?: number }): boolean => { });
if ('status' in error) { });
return error.status === 401 || error.status === 403;
} describe('Event Emission', () => {
const message = (error as Error).message.toLowerCase(); it('should be an EventEmitter', () => {
return message.includes('unauthorized') || expect(syncEngine.on).toBeDefined();
message.includes('forbidden') || expect(syncEngine.emit).toBeDefined();
message.includes('auth'); expect(syncEngine.removeListener).toBeDefined();
}; });
expect(isAuthError({ status: 401 })).toBe(true); it('should allow adding event listeners', () => {
expect(isAuthError({ status: 403 })).toBe(true); const listener = vi.fn();
expect(isAuthError({ status: 500 })).toBe(false); syncEngine.on('testEvent', listener);
expect(isAuthError(new Error('Unauthorized'))).toBe(true); syncEngine.emit('testEvent', { data: 'test' });
expect(listener).toHaveBeenCalledWith({ data: 'test' });
});
it('should allow removing event listeners', () => {
const listener = vi.fn();
syncEngine.on('testEvent', listener);
syncEngine.removeListener('testEvent', listener);
syncEngine.emit('testEvent', { data: 'test' });
expect(listener).not.toHaveBeenCalled();
});
});
describe('SyncResult Structure', () => {
it('should return complete SyncResult structure', async () => {
const result = await syncEngine.sync();
expect(result).toHaveProperty('success');
expect(result).toHaveProperty('pushed');
expect(result).toHaveProperty('pulled');
expect(result).toHaveProperty('conflicts');
expect(result).toHaveProperty('errors');
});
it('should have errors as an array', async () => {
const result = await syncEngine.sync();
expect(Array.isArray(result.errors)).toBe(true);
});
it('should have numeric counts', async () => {
const result = await syncEngine.sync();
expect(typeof result.pushed).toBe('number');
expect(typeof result.pulled).toBe('number');
expect(typeof result.conflicts).toBe('number');
});
});
describe('Pending Changes Count', () => {
it('should return pending changes count structure', async () => {
const count = await syncEngine.getPendingChangesCount();
expect(count).toHaveProperty('posts');
expect(count).toHaveProperty('media');
});
it('should return zero counts when no pending changes', async () => {
// With empty mock data
const count = await syncEngine.getPendingChangesCount();
expect(count.posts).toBeGreaterThanOrEqual(0);
expect(count.media).toBeGreaterThanOrEqual(0);
});
});
describe('Sync Log', () => {
it('should return sync log array', async () => {
const logs = await syncEngine.getSyncLog();
expect(Array.isArray(logs)).toBe(true);
});
it('should accept limit parameter', async () => {
const logs = await syncEngine.getSyncLog(10);
expect(Array.isArray(logs)).toBe(true);
});
it('should use default limit of 50', async () => {
const logs = await syncEngine.getSyncLog();
expect(Array.isArray(logs)).toBe(true);
});
});
describe('Stop Auto Sync', () => {
it('should emit autoSyncStopped event', () => {
const handler = vi.fn();
syncEngine.on('autoSyncStopped', handler);
syncEngine.stopAutoSync();
expect(handler).toHaveBeenCalled();
});
it('should be safe to call multiple times', () => {
expect(() => {
syncEngine.stopAutoSync();
syncEngine.stopAutoSync();
syncEngine.stopAutoSync();
}).not.toThrow();
});
});
describe('Sync Configuration Validation', () => {
it('should require both URL and token', async () => {
await syncEngine.configure({
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: '',
autoSync: false,
syncInterval: 30,
});
expect(syncEngine.isConfigured()).toBe(false);
await syncEngine.configure({
tursoUrl: '',
tursoAuthToken: 'token',
autoSync: false,
syncInterval: 30,
});
expect(syncEngine.isConfigured()).toBe(false);
});
it('should be configured with valid URL and token', async () => {
await syncEngine.configure({
tursoUrl: 'libsql://valid.turso.io',
tursoAuthToken: 'valid-token',
autoSync: false,
syncInterval: 30,
});
expect(syncEngine.isConfigured()).toBe(true);
});
});
describe('Sync Interval Configuration', () => {
it('should accept sync interval in minutes', async () => {
const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: 'test-token',
autoSync: true,
syncInterval: 15, // 15 minutes
};
await syncEngine.configure(config);
expect(syncEngine.isConfigured()).toBe(true);
});
it('should not set auto sync with zero interval', async () => {
const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: 'test-token',
autoSync: true,
syncInterval: 0,
};
await syncEngine.configure(config);
// Should not crash, but won't set up interval
expect(syncEngine.isConfigured()).toBe(true);
}); });
}); });
}); });

View File

@@ -4,9 +4,9 @@
*/ */
import { vi } from 'vitest'; import { vi } from 'vitest';
import type { PostData } from '../src/main/engine/PostEngine'; import type { PostData } from '../../src/main/engine/PostEngine';
import type { MediaData } from '../src/main/engine/MediaEngine'; import type { MediaData } from '../../src/main/engine/MediaEngine';
import type { Task, TaskProgress } from '../src/main/engine/TaskManager'; import type { Task, TaskProgress } from '../../src/main/engine/TaskManager';
// ============================================ // ============================================
// Post Mock Factory // Post Mock Factory
@@ -20,6 +20,7 @@ export function createMockPost(overrides?: Partial<PostData>): PostData {
return { return {
id, id,
projectId: 'default',
title: `Test Post ${id}`, title: `Test Post ${id}`,
slug: `test-post-${id}`, slug: `test-post-${id}`,
excerpt: 'This is a test excerpt', excerpt: 'This is a test excerpt',