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)
- **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
### Separation of Concerns
@@ -414,20 +434,58 @@ src/
## 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
// 1. RED: Write a failing test first
describe('PostEngine.createPost', () => {
// ✅ CORRECT: Import and test the REAL class
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 () => {
const result = await postEngine.createPost({ title: '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
```

View File

@@ -80,7 +80,12 @@ in a compact form in the overview pages. More on rendering in the publishing pip
## 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
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
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
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.

View File

@@ -4,6 +4,7 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import * as crypto from 'crypto';
import { eq } from 'drizzle-orm';
import { app } from 'electron';
import { getDatabase } from '../database';
import { media, Media, NewMedia } from '../database/schema';
import { taskManager, Task } from './TaskManager';
@@ -45,8 +46,6 @@ export class MediaEngine extends EventEmitter {
}
private getMediaDir(): string {
const { app } = require('electron');
const path = require('path');
const userDataPath = app.getPath('userData');
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> {
const metadata: PostMetadata = {
const metadata: Record<string, unknown> = {
id: post.id,
projectId: post.projectId,
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,
};
// 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();
await fs.mkdir(postsDir, { recursive: true });

View File

@@ -1,29 +1,59 @@
/**
* MediaEngine Unit Tests
*
* Tests for media file management including:
* - Media import and storage
* - Sidecar metadata file handling
* - MIME type detection
* - Checksum calculation
* - Filename generation
* Tests the REAL MediaEngine class with mocked dependencies.
* Following TDD best practices: mock external dependencies, test real implementation.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createMockMedia, createMockPdfMedia, resetMockCounters } from '../utils/factories';
import { resetMockCounters } from '../utils/factories';
// Mock dependencies
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => ({
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => Promise.resolve([])),
orderBy: vi.fn(() => Promise.resolve([])),
})),
})),
// Mock electron BEFORE importing MediaEngine (it uses require('electron') internally)
vi.mock('electron', () => ({
app: {
getPath: vi.fn((name: string) => {
const paths: Record<string, string> = {
userData: '/mock/userData',
appData: '/mock/appData',
temp: '/mock/temp',
};
return paths[name] || '/mock/unknown';
}),
},
}));
// Import MediaEngine after mocks are set up
import { MediaEngine, MediaData } from '../../src/main/engine/MediaEngine';
// Create mock data stores
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(() => Promise.resolve()),
values: vi.fn((data: any) => {
if (data && data.id) {
mockMedia.set(data.id, data);
}
return Promise.resolve();
}),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
@@ -33,387 +63,389 @@ vi.mock('../../src/main/database', () => ({
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(),
})),
}));
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', () => {
let mediaEngine: MediaEngine;
beforeEach(() => {
vi.clearAllMocks();
mockMedia.clear();
mockFiles.clear();
resetMockCounters();
// Reset the mock implementations
vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain());
mediaEngine = new MediaEngine();
});
describe('Media Data Validation', () => {
it('should create valid media with required fields', () => {
const media = createMockMedia();
describe('Constructor and Initialization', () => {
it('should create a MediaEngine instance', () => {
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.filename).toBeDefined();
expect(media.originalName).toBeDefined();
expect(media.mimeType).toBeDefined();
expect(media.size).toBeGreaterThan(0);
expect(media.createdAt).toBeInstanceOf(Date);
expect(media.updatedAt).toBeInstanceOf(Date);
expect(media.originalName).toBe('image.jpg');
});
it('should allow optional dimension fields', () => {
const imageMedia = createMockMedia({ width: 1920, height: 1080 });
const pdfMedia = createMockPdfMedia();
expect(imageMedia.width).toBe(1920);
expect(imageMedia.height).toBe(1080);
expect(pdfMedia.width).toBeUndefined();
expect(pdfMedia.height).toBeUndefined();
it('should detect mime type from file extension', async () => {
const jpgMedia = await mediaEngine.importMedia('/source/image.jpg');
expect(jpgMedia.mimeType).toBe('image/jpeg');
});
it('should allow optional alt and caption', () => {
const withAlt = createMockMedia({ alt: 'Description', caption: 'A caption' });
const withoutAlt = createMockMedia({ alt: undefined, caption: undefined });
it('should set file size from source', async () => {
const media = await mediaEngine.importMedia('/source/image.jpg');
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();
it('should use provided metadata', async () => {
const media = await mediaEngine.importMedia('/source/image.jpg', {
alt: 'A beautiful sunset',
caption: 'Sunset over the ocean',
tags: ['nature', 'sunset'],
});
expect(media.alt).toBe('A beautiful sunset');
expect(media.caption).toBe('Sunset over the ocean');
expect(media.tags).toEqual(['nature', 'sunset']);
});
it('should set createdAt and updatedAt timestamps', async () => {
const before = new Date();
const media = await mediaEngine.importMedia('/source/image.jpg');
const after = new Date();
expect(media.createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(media.createdAt.getTime()).toBeLessThanOrEqual(after.getTime());
expect(media.updatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(media.updatedAt.getTime()).toBeLessThanOrEqual(after.getTime());
});
it('should emit mediaImported event', async () => {
const handler = vi.fn();
mediaEngine.on('mediaImported', handler);
const media = await mediaEngine.importMedia('/source/image.jpg');
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('MIME Type Handling', () => {
it('should recognize common image MIME types', () => {
const imageMimeTypes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
];
describe('MIME Type Detection', () => {
beforeEach(() => {
// Setup various file types
mockFiles.set('/source/photo.jpg', Buffer.from('jpg-data'));
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'));
mockFiles.set('/source/modern.webp', Buffer.from('webp-data'));
mockFiles.set('/source/vector.svg', Buffer.from('svg-data'));
mockFiles.set('/source/unknown.xyz', Buffer.from('unknown-data'));
});
imageMimeTypes.forEach(mimeType => {
const media = createMockMedia({ mimeType });
expect(media.mimeType).toBe(mimeType);
expect(media.mimeType.startsWith('image/')).toBe(true);
it('should detect image/jpeg for .jpg files', async () => {
const media = await mediaEngine.importMedia('/source/photo.jpg');
expect(media.mimeType).toBe('image/jpeg');
});
it('should detect image/jpeg for .jpeg files', async () => {
const media = await mediaEngine.importMedia('/source/photo.jpeg');
expect(media.mimeType).toBe('image/jpeg');
});
it('should detect image/png for .png files', async () => {
const media = await mediaEngine.importMedia('/source/image.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');
});
});
it('should recognize document MIME types', () => {
const docMimeTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
docMimeTypes.forEach(mimeType => {
const media = createMockMedia({ mimeType });
expect(media.mimeType).toBe(mimeType);
});
describe('Media with Image Dimensions', () => {
beforeEach(() => {
mockFiles.set('/source/image.jpg', Buffer.from('image-data'));
});
it('should identify image types by MIME prefix', () => {
const isImage = (mimeType: string) => mimeType.startsWith('image/');
expect(isImage('image/jpeg')).toBe(true);
expect(isImage('image/png')).toBe(true);
expect(isImage('application/pdf')).toBe(false);
expect(isImage('video/mp4')).toBe(false);
});
});
describe('Filename Generation', () => {
it('should generate unique filenames', () => {
const generateFilename = (originalName: string, id: string): string => {
const ext = originalName.split('.').pop() || '';
return `${id}.${ext}`;
};
const filename1 = generateFilename('photo.jpg', 'media-1');
const filename2 = generateFilename('photo.jpg', 'media-2');
expect(filename1).toBe('media-1.jpg');
expect(filename2).toBe('media-2.jpg');
expect(filename1).not.toBe(filename2);
});
it('should preserve file extension', () => {
const getExtension = (filename: string): string => {
const parts = filename.split('.');
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', () => {
const sanitizeFilename = (filename: string): string => {
return filename.replace(/[^a-zA-Z0-9.-]/g, '_');
};
expect(sanitizeFilename('my file (1).jpg')).toBe('my_file__1_.jpg');
expect(sanitizeFilename('résumé.pdf')).toBe('r_sum_.pdf');
expect(sanitizeFilename('normal-file.png')).toBe('normal-file.png');
});
});
describe('Sidecar Metadata', () => {
it('should generate sidecar path from media path', () => {
const getSidecarPath = (mediaPath: string): string => `${mediaPath}.meta`;
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', () => {
const media = createMockMedia({
id: 'media-123',
originalName: 'vacation-photo.jpg',
mimeType: 'image/jpeg',
size: 1024000,
it('should store width and height when provided', async () => {
const media = await mediaEngine.importMedia('/source/image.jpg', {
width: 1920,
height: 1080,
alt: 'Vacation photo',
caption: 'Summer vacation 2024',
tags: ['vacation', 'summer'],
});
const sidecarContent = {
id: media.id,
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']);
expect(media.width).toBe(1920);
expect(media.height).toBe(1080);
});
it('should handle missing optional fields in sidecar', () => {
const media = createMockPdfMedia({
alt: undefined,
caption: undefined,
});
it('should handle media without dimensions', async () => {
const media = await mediaEngine.importMedia('/source/image.jpg');
const sidecarContent = {
id: media.id,
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');
expect(media.width).toBeUndefined();
expect(media.height).toBeUndefined();
});
});
describe('Media Tags', () => {
it('should handle empty tags array', () => {
const media = createMockMedia({ tags: [] });
beforeEach(() => {
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([]);
});
it('should preserve tag order', () => {
const tags = ['first', 'second', 'third'];
const media = createMockMedia({ tags });
expect(media.tags).toEqual(tags);
});
it('should serialize tags to JSON', () => {
const tags = ['photo', 'landscape', '2024'];
const serialized = JSON.stringify(tags);
const deserialized = JSON.parse(serialized);
describe('Event Emission', () => {
it('should be an EventEmitter', () => {
expect(mediaEngine.on).toBeDefined();
expect(mediaEngine.emit).toBeDefined();
expect(mediaEngine.removeListener).toBeDefined();
});
expect(deserialized).toEqual(tags);
it('should allow adding event listeners', () => {
const listener = vi.fn();
mediaEngine.on('testEvent', listener);
mediaEngine.emit('testEvent', { data: 'test' });
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', () => {
it('should store width and height for images', () => {
const media = createMockMedia({
mimeType: 'image/jpeg',
width: 3840,
height: 2160,
describe('getMediaPath', () => {
it('should return path in media directory', () => {
const path = mediaEngine.getMediaPath('test-id');
expect(path).toContain('test-id');
expect(path).toContain('media');
});
});
expect(media.width).toBe(3840);
expect(media.height).toBe(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 calculate aspect ratio', () => {
const getAspectRatio = (width: number, height: number): string => {
const gcd = (a: number, b: number): number => b === 0 ? a : gcd(b, a % b);
const divisor = gcd(width, height);
return `${width / divisor}:${height / divisor}`;
};
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(getAspectRatio(1920, 1080)).toBe('16:9');
expect(getAspectRatio(1024, 768)).toBe('4:3');
expect(getAspectRatio(1080, 1080)).toBe('1:1');
expect(media1.id).toBeDefined();
expect(media2.id).toBeDefined();
expect(media1.id).not.toBe(media2.id);
});
it('should not require dimensions for non-images', () => {
const pdf = createMockPdfMedia();
expect(pdf.width).toBeUndefined();
expect(pdf.height).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');
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.alt).toBe('A scenic mountain view');
});
it('should store caption for display', async () => {
const media = await mediaEngine.importMedia('/source/image.jpg', {
caption: 'Photo taken at Mt. Rainier, 2024',
});
expect(media.caption).toBe('Photo taken at Mt. Rainier, 2024');
});
it('should handle media without alt or caption', async () => {
const media = await mediaEngine.importMedia('/source/image.jpg');
expect(media.alt).toBeUndefined();
expect(media.caption).toBeUndefined();
});
});
});

View File

@@ -1,29 +1,44 @@
/**
* PostEngine Unit Tests
*
* Tests for blog post management including:
* - Post CRUD operations
* - Slug generation
* - Markdown with YAML frontmatter handling
* - Checksum calculation
* - Event emissions
* Tests the REAL PostEngine class with mocked dependencies.
* Following TDD best practices: mock external dependencies, test real implementation.
*/
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { createMockPost, createMockFileSystem, createMockDatabase, resetMockCounters } from '../utils/factories';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { PostEngine, PostData } from '../../src/main/engine/PostEngine';
import { resetMockCounters } from '../utils/factories';
// Mock the database module before importing PostEngine
vi.mock('../../src/main/database', () => {
const mockDb = {
getLocal: vi.fn(() => ({
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => Promise.resolve([])),
orderBy: vi.fn(() => Promise.resolve([])),
})),
})),
// Create mock data stores
const mockPosts = new Map<string, any>();
const mockFiles = new Map<string, string>();
let mockExecuteArgs: any[] = [];
// Create chainable mock for Drizzle ORM
function createSelectChain() {
return {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockImplementation(function(this: any) {
return this;
}),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
all: vi.fn().mockImplementation(() => Promise.resolve(Array.from(mockPosts.values()))),
get: vi.fn().mockImplementation(() => Promise.resolve(undefined)),
};
}
function createDrizzleMock() {
return {
select: vi.fn(() => createSelectChain()),
insert: vi.fn(() => ({
values: vi.fn(() => Promise.resolve()),
values: vi.fn((data: any) => {
if (data && data.id) {
mockPosts.set(data.id, data);
}
return Promise.resolve();
}),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
@@ -33,7 +48,22 @@ vi.mock('../../src/main/database', () => {
delete: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
};
}
const mockLocalDb = createDrizzleMock();
const mockLocalClient = {
execute: vi.fn(async (query: { sql: string; args: any[] }) => {
mockExecuteArgs.push(query);
return { rows: [] };
}),
};
// Mock the database module
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => mockLocalDb),
getLocalClient: vi.fn(() => mockLocalClient),
getRemote: vi.fn(() => null),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
@@ -43,394 +73,372 @@ vi.mock('../../src/main/database', () => {
initializeLocal: vi.fn(),
initializeRemote: vi.fn(),
close: vi.fn(),
};
return {
getDatabase: vi.fn(() => mockDb),
};
});
})),
}));
// 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', () => {
let postEngine: PostEngine;
beforeEach(() => {
vi.clearAllMocks();
mockPosts.clear();
mockFiles.clear();
mockExecuteArgs = [];
resetMockCounters();
// Reset the mock implementations
vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain());
postEngine = new PostEngine();
});
describe('Slug Generation', () => {
it('should generate slug from title with lowercase', () => {
// Test the slug generation logic
const generateSlug = (title: string): string => {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
};
expect(generateSlug('Hello World')).toBe('hello-world');
describe('Constructor and Initialization', () => {
it('should create a PostEngine instance', () => {
expect(postEngine).toBeInstanceOf(PostEngine);
});
it('should replace special characters with hyphens', () => {
const generateSlug = (title: string): string => {
return title
.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 extend EventEmitter', () => {
expect(typeof postEngine.on).toBe('function');
expect(typeof postEngine.emit).toBe('function');
});
it('should remove leading and trailing hyphens', () => {
const generateSlug = (title: string): string => {
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');
it('should have default project context', () => {
expect(postEngine.getProjectContext()).toBe('default');
});
});
describe('Checksum Calculation', () => {
it('should generate consistent checksums', () => {
const crypto = require('crypto');
const calculateChecksum = (content: string): string => {
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
describe('Project Context', () => {
it('should set project context', () => {
postEngine.setProjectContext('my-blog');
expect(postEngine.getProjectContext()).toBe('my-blog');
});
it('should generate different checksums for different content', () => {
const crypto = require('crypto');
const calculateChecksum = (content: string): string => {
return crypto.createHash('md5').update(content).digest('hex');
};
it('should allow changing project context multiple times', () => {
postEngine.setProjectContext('blog-1');
expect(postEngine.getProjectContext()).toBe('blog-1');
const checksum1 = calculateChecksum('Content A');
const checksum2 = calculateChecksum('Content B');
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);
postEngine.setProjectContext('blog-2');
expect(postEngine.getProjectContext()).toBe('blog-2');
});
});
describe('Post Data Validation', () => {
it('should create a valid post with default values', () => {
const createPostData = (data: Partial<ReturnType<typeof createMockPost>>) => {
const now = new Date();
const id = data.id || 'generated-id';
const slug = data.slug || 'untitled';
describe('Slug Generation via createPost', () => {
it('should generate slug from title with lowercase', async () => {
const post = await postEngine.createPost({ title: 'Hello World' });
expect(post.slug).toBe('hello-world');
});
return {
id,
title: data.title || 'Untitled',
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 || [],
};
};
it('should replace special characters with hyphens', async () => {
const post = await postEngine.createPost({ title: 'Hello, World! How are you?' });
expect(post.slug).toBe('hello-world-how-are-you');
});
const post = createPostData({});
it('should remove leading and trailing hyphens', async () => {
const post = await postEngine.createPost({ title: '---Test---' });
expect(post.slug).toBe('test');
});
expect(post.title).toBe('Untitled');
it('should handle numbers in titles', async () => {
const post = await postEngine.createPost({ title: '10 Tips for Testing' });
expect(post.slug).toBe('10-tips-for-testing');
});
it('should convert multiple spaces to single hyphen', async () => {
const post = await postEngine.createPost({ title: 'Multiple Spaces Here' });
expect(post.slug).toBe('multiple-spaces-here');
});
it('should handle unicode characters by removing them', async () => {
const post = await postEngine.createPost({ title: 'Café Test' });
expect(post.slug).toBe('caf-test');
});
});
describe('Post Creation', () => {
it('should create a post with default values', async () => {
const post = await postEngine.createPost({ title: 'My Test Post' });
expect(post.id).toBeDefined();
expect(post.title).toBe('My Test Post');
expect(post.slug).toBe('my-test-post');
expect(post.status).toBe('draft');
expect(post.content).toBe('');
expect(post.tags).toEqual([]);
expect(post.categories).toEqual([]);
});
it('should preserve provided values', () => {
const post = createMockPost({
title: 'My Custom Title',
it('should create a post with provided content', async () => {
const post = await postEngine.createPost({
title: 'Content Test',
content: '# Hello\n\nThis is my post.',
});
expect(post.content).toBe('# Hello\n\nThis is my post.');
});
it('should create a post with custom status', async () => {
const post = await postEngine.createPost({
title: 'Published Post',
status: 'published',
tags: ['custom', 'tag'],
});
expect(post.title).toBe('My Custom Title');
expect(post.status).toBe('published');
expect(post.tags).toEqual(['custom', 'tag']);
});
});
describe('YAML Frontmatter Format', () => {
it('should create valid frontmatter structure', () => {
const post = createMockPost({
title: 'Test Post',
slug: 'test-post',
status: 'draft',
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',
tags: ['tech', 'tutorial'],
categories: ['programming'],
});
// Simulate frontmatter generation
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/);
expect(post.author).toBe('John Doe');
});
it('should handle optional fields correctly', () => {
const post = createMockPost({
excerpt: undefined,
author: undefined,
publishedAt: undefined,
it('should create post with excerpt', async () => {
const post = await postEngine.createPost({
title: 'Excerpt Test',
excerpt: 'This is a short summary.',
});
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.excerpt).toBeUndefined();
expect(frontmatter.author).toBeUndefined();
expect(frontmatter.publishedAt).toBeUndefined();
});
expect(post.excerpt).toBe('This is a short summary.');
});
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', () => {
const now = new Date();
const post = createMockPost({
it('should handle publishedAt for published posts', async () => {
const publishDate = new Date('2024-01-15');
const post = await postEngine.createPost({
title: 'Published Post',
status: 'published',
publishedAt: now,
publishedAt: publishDate,
});
expect(post.status).toBe('published');
expect(post.publishedAt).toEqual(now);
expect(post.publishedAt).toEqual(publishDate);
});
});
it('should not require publishedAt for drafts', () => {
const post = createMockPost({
status: 'draft',
publishedAt: undefined,
describe('Event Emission', () => {
it('should be an EventEmitter', () => {
expect(postEngine.on).toBeDefined();
expect(postEngine.emit).toBeDefined();
expect(postEngine.removeListener).toBeDefined();
});
it('should allow adding event listeners', () => {
const listener = vi.fn();
postEngine.on('testEvent', listener);
postEngine.emit('testEvent', { data: 'test' });
expect(listener).toHaveBeenCalledWith({ data: 'test' });
});
it('should allow removing event listeners', () => {
const listener = vi.fn();
postEngine.on('testEvent', listener);
postEngine.removeListener('testEvent', listener);
postEngine.emit('testEvent', { data: 'test' });
expect(listener).not.toHaveBeenCalled();
});
});
describe('Post Creation writes correct file format', () => {
it('should write markdown file with YAML frontmatter', async () => {
const fs = await import('fs/promises');
await postEngine.createPost({
title: 'Frontmatter Test',
content: '# Hello World',
tags: ['test'],
});
expect(fs.writeFile).toHaveBeenCalled();
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
const filePath = writeCall[0] as string;
const content = writeCall[1] as string;
expect(filePath).toContain('frontmatter-test.md');
expect(content).toContain('---');
expect(content).toContain('title: Frontmatter Test');
expect(content).toContain('# Hello World');
});
});
describe('Multiple post creation', () => {
it('should create multiple posts with unique IDs', async () => {
const post1 = await postEngine.createPost({ title: 'Post 1' });
const post2 = await postEngine.createPost({ title: 'Post 2' });
expect(post1.id).toBeDefined();
expect(post2.id).toBeDefined();
expect(post1.id).not.toBe(post2.id);
});
it('should create posts with different slugs', async () => {
const post1 = await postEngine.createPost({ title: 'First Post' });
const post2 = await postEngine.createPost({ title: 'Second Post' });
expect(post1.slug).toBe('first-post');
expect(post2.slug).toBe('second-post');
});
});
describe('Post status values', () => {
it('should accept draft status', async () => {
const post = await postEngine.createPost({ title: 'Draft', status: 'draft' });
expect(post.status).toBe('draft');
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', () => {
it('should generate correct file path from slug', () => {
const postsDir = '/mock/userData/posts';
const slug = 'my-first-post';
const filePath = `${postsDir}/${slug}.md`;
expect(filePath).toBe('/mock/userData/posts/my-first-post.md');
describe('Post with all fields', () => {
it('should create a fully populated post', async () => {
const publishDate = new Date('2024-06-15');
const post = await postEngine.createPost({
title: 'Complete Post',
slug: 'complete-post',
content: '# Complete\n\nFull content here.',
excerpt: 'A complete post with all fields.',
status: 'published',
author: 'Jane Doe',
publishedAt: publishDate,
tags: ['complete', 'full', 'test'],
categories: ['testing', 'examples'],
});
it('should handle slugs with numbers', () => {
const postsDir = '/mock/userData/posts';
const slug = 'post-123-test';
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 = {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
status: post.status,
author: post.author,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
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();
expect(post.title).toBe('Complete Post');
expect(post.slug).toBe('complete-post');
expect(post.content).toBe('# Complete\n\nFull content here.');
expect(post.excerpt).toBe('A complete post with all fields.');
expect(post.status).toBe('published');
expect(post.author).toBe('Jane Doe');
expect(post.publishedAt).toEqual(publishDate);
expect(post.tags).toEqual(['complete', 'full', 'test']);
expect(post.categories).toEqual(['testing', 'examples']);
});
});
});

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,29 +1,43 @@
/**
* SyncEngine Unit Tests
*
* Tests for remote synchronization including:
* - Sync configuration
* - Push/pull operations
* - Conflict detection and resolution
* - Sync status tracking
* - Retry logic
* Tests the REAL SyncEngine class with mocked dependencies.
* Following TDD best practices: mock external dependencies, test real implementation.
*/
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';
// Mock dependencies
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => ({
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => Promise.resolve([])),
orderBy: vi.fn(() => Promise.resolve([])),
})),
})),
// Create mock data stores
const mockPosts = new Map<string, any>();
const mockMedia = new Map<string, any>();
const mockSyncLog = new Map<string, any>();
// Create chainable mock for Drizzle ORM
function createSelectChain(data: Map<string, any>) {
return {
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(data.values()))),
get: vi.fn().mockImplementation(() => Promise.resolve(data.size > 0 ? Array.from(data.values())[0] : undefined)),
};
}
function createDrizzleMock(data: Map<string, any>) {
return {
select: vi.fn(() => createSelectChain(data)),
insert: vi.fn(() => ({
values: vi.fn(() => Promise.resolve()),
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(() => ({
@@ -33,536 +47,407 @@ vi.mock('../../src/main/database', () => ({
delete: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
getRemote: vi.fn(() => null),
initializeLocal: vi.fn(),
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(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
media: '/mock/userData/media',
})),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(async () => {}),
close: vi.fn(),
})),
}));
// Mock PostEngine and MediaEngine
vi.mock('../../src/main/engine/PostEngine', () => ({
getPostEngine: vi.fn(() => ({
getAllPosts: vi.fn(() => Promise.resolve([])),
createPost: vi.fn(),
updatePost: vi.fn(),
deletePost: vi.fn(),
on: vi.fn(),
emit: vi.fn(),
})),
}));
vi.mock('../../src/main/engine/MediaEngine', () => ({
getMediaEngine: vi.fn(() => ({
getAllMedia: vi.fn(() => Promise.resolve([])),
importMedia: vi.fn(),
updateMedia: vi.fn(),
deleteMedia: vi.fn(),
on: vi.fn(),
emit: vi.fn(),
})),
}));
// Mock uuid
vi.mock('uuid', () => ({
v4: vi.fn(() => 'mock-sync-uuid-' + Math.random().toString(36).substr(2, 9)),
}));
describe('SyncEngine', () => {
let syncEngine: SyncEngine;
beforeEach(() => {
vi.clearAllMocks();
resetMockCounters();
vi.useFakeTimers();
mockPosts.clear();
mockMedia.clear();
mockSyncLog.clear();
resetMockCounters();
syncEngine = new SyncEngine();
});
afterEach(() => {
vi.useRealTimers();
syncEngine.stopAutoSync();
});
describe('Sync Configuration', () => {
it('should validate sync config structure', () => {
interface SyncConfig {
tursoUrl: string;
tursoAuthToken: string;
autoSync: boolean;
syncInterval: number;
}
describe('Constructor and Initialization', () => {
it('should create a SyncEngine instance', () => {
expect(syncEngine).toBeInstanceOf(SyncEngine);
});
const validConfig: SyncConfig = {
tursoUrl: 'libsql://mydb.turso.io',
tursoAuthToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
autoSync: true,
syncInterval: 5,
it('should extend EventEmitter', () => {
expect(typeof syncEngine.on).toBe('function');
expect(typeof syncEngine.emit).toBe('function');
});
it('should start with idle status', () => {
expect(syncEngine.getSyncStatus()).toBe('idle');
});
it('should not be configured initially', () => {
expect(syncEngine.isConfigured()).toBe(false);
});
});
describe('Configuration', () => {
it('should configure sync settings', async () => {
const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: 'test-token',
autoSync: false,
syncInterval: 30,
};
expect(validConfig.tursoUrl).toMatch(/^libsql:\/\//);
expect(validConfig.tursoAuthToken).toBeDefined();
expect(validConfig.autoSync).toBe(true);
expect(validConfig.syncInterval).toBeGreaterThan(0);
await syncEngine.configure(config);
expect(syncEngine.isConfigured()).toBe(true);
});
it('should detect unconfigured state', () => {
const isConfigured = (config: { tursoUrl?: string; tursoAuthToken?: string } | null): boolean => {
return config !== null &&
!!config.tursoUrl &&
!!config.tursoAuthToken;
it('should emit configured event', async () => {
const handler = vi.fn();
syncEngine.on('configured', handler);
const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: 'test-token',
autoSync: false,
syncInterval: 30,
};
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);
await syncEngine.configure(config);
expect(handler).toHaveBeenCalledWith(config);
});
it('should calculate sync interval in milliseconds', () => {
const minutesToMs = (minutes: number): number => minutes * 60 * 1000;
expect(minutesToMs(1)).toBe(60000);
expect(minutesToMs(5)).toBe(300000);
expect(minutesToMs(15)).toBe(900000);
});
});
describe('Sync Direction', () => {
it('should support push direction', () => {
type SyncDirection = 'push' | 'pull' | 'bidirectional';
const direction: SyncDirection = 'push';
expect(['push', 'pull', 'bidirectional']).toContain(direction);
});
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: [],
it('should not be configured with empty URL', async () => {
const config: SyncConfig = {
tursoUrl: '',
tursoAuthToken: 'test-token',
autoSync: false,
syncInterval: 30,
};
expect(successResult.success).toBe(true);
expect(successResult.pushed + successResult.pulled).toBe(8);
expect(successResult.errors).toHaveLength(0);
await syncEngine.configure(config);
expect(syncEngine.isConfigured()).toBe(false);
});
it('should report errors in result', () => {
interface SyncResult {
success: boolean;
pushed: number;
pulled: number;
conflicts: number;
errors: string[];
}
const errorResult: SyncResult = {
success: false,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: ['Network timeout', 'Authentication failed'],
it('should not be configured with empty token', async () => {
const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: '',
autoSync: false,
syncInterval: 30,
};
expect(errorResult.success).toBe(false);
expect(errorResult.errors).toHaveLength(2);
expect(errorResult.errors).toContain('Network timeout');
});
await syncEngine.configure(config);
it('should track conflicts', () => {
interface SyncResult {
success: boolean;
pushed: number;
pulled: number;
conflicts: number;
errors: string[];
}
const conflictResult: SyncResult = {
success: true, // Partial success
pushed: 4,
pulled: 2,
conflicts: 2,
errors: [],
};
expect(conflictResult.conflicts).toBeGreaterThan(0);
});
});
describe('Entity Sync Status', () => {
it('should track sync status per entity', () => {
type EntitySyncStatus = 'pending' | 'syncing' | 'synced' | 'conflict';
interface SyncableEntity {
id: string;
syncStatus: EntitySyncStatus;
syncedAt: Date | null;
checksum: string;
}
const entity: SyncableEntity = {
id: 'post-1',
syncStatus: 'pending',
syncedAt: null,
checksum: 'abc123',
};
expect(entity.syncStatus).toBe('pending');
expect(entity.syncedAt).toBeNull();
});
it('should update sync status after successful sync', () => {
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);
expect(syncEngine.isConfigured()).toBe(false);
});
});
describe('Auto Sync', () => {
it('should start auto sync interval', () => {
const setIntervalSpy = vi.spyOn(global, 'setInterval');
const startAutoSync = (intervalMinutes: number, callback: () => void): NodeJS.Timeout => {
return setInterval(callback, intervalMinutes * 60 * 1000);
it('should start auto sync when enabled', async () => {
const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: 'test-token',
autoSync: true,
syncInterval: 1, // 1 minute
};
const callback = vi.fn();
const intervalId = startAutoSync(5, callback);
await syncEngine.configure(config);
expect(setIntervalSpy).toHaveBeenCalledWith(callback, 300000);
clearInterval(intervalId);
// Auto sync should be scheduled
expect(syncEngine.isConfigured()).toBe(true);
});
it('should stop auto sync interval', () => {
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
it('should stop auto sync when called', async () => {
const handler = vi.fn();
syncEngine.on('autoSyncStopped', handler);
const intervalId = setInterval(() => {}, 1000);
clearInterval(intervalId);
const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: 'test-token',
autoSync: true,
syncInterval: 1,
};
expect(clearIntervalSpy).toHaveBeenCalled();
await syncEngine.configure(config);
syncEngine.stopAutoSync();
expect(handler).toHaveBeenCalled();
});
it('should trigger sync on interval', () => {
const syncCallback = vi.fn();
const intervalId = setInterval(syncCallback, 1000);
it('should stop previous auto sync when reconfiguring', async () => {
const config1: SyncConfig = {
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);
});
});
});
describe('SyncEngine Error Handling', () => {
describe('Network Errors', () => {
it('should identify network errors', () => {
const isNetworkError = (error: Error): boolean => {
const networkErrorPatterns = [
'network',
'timeout',
'ECONNREFUSED',
'ENOTFOUND',
'fetch failed',
];
return networkErrorPatterns.some(pattern =>
error.message.toLowerCase().includes(pattern.toLowerCase())
);
};
expect(isNetworkError(new Error('Network timeout'))).toBe(true);
expect(isNetworkError(new Error('ECONNREFUSED'))).toBe(true);
expect(isNetworkError(new Error('Invalid data'))).toBe(false);
});
it('should create user-friendly error messages', () => {
const getUserFriendlyMessage = (error: Error): string => {
if (error.message.includes('timeout')) {
return 'Connection timed out. Please check your internet connection.';
}
if (error.message.includes('ECONNREFUSED')) {
return 'Unable to connect to sync server. Please try again later.';
}
if (error.message.includes('401') || error.message.includes('unauthorized')) {
return 'Authentication failed. Please check your sync credentials.';
}
return 'An unexpected error occurred during sync.';
};
expect(getUserFriendlyMessage(new Error('timeout'))).toContain('timed out');
expect(getUserFriendlyMessage(new Error('401 unauthorized'))).toContain('Authentication');
});
});
describe('Authentication Errors', () => {
it('should detect auth errors', () => {
const isAuthError = (error: Error | { status?: number }): boolean => {
if ('status' in error) {
return error.status === 401 || error.status === 403;
}
const message = (error as Error).message.toLowerCase();
return message.includes('unauthorized') ||
message.includes('forbidden') ||
message.includes('auth');
};
expect(isAuthError({ status: 401 })).toBe(true);
expect(isAuthError({ status: 403 })).toBe(true);
expect(isAuthError({ status: 500 })).toBe(false);
expect(isAuthError(new Error('Unauthorized'))).toBe(true);
expect(syncEngine.isConfigured()).toBe(true);
});
});
describe('Sync Status', () => {
it('should return idle when not syncing', () => {
expect(syncEngine.getSyncStatus()).toBe('idle');
});
});
describe('Sync without Configuration', () => {
it('should return error when syncing without configuration', async () => {
const result = await syncEngine.sync('bidirectional');
expect(result.success).toBe(false);
expect(result.errors).toContain('Sync not configured');
});
it('should return zero counts when not configured', async () => {
const result = await syncEngine.sync('push');
expect(result.pushed).toBe(0);
expect(result.pulled).toBe(0);
expect(result.conflicts).toBe(0);
});
});
describe('Sync Directions', () => {
it('should accept push direction', async () => {
const result = await syncEngine.sync('push');
expect(result).toBeDefined();
});
it('should accept pull direction', async () => {
const result = await syncEngine.sync('pull');
expect(result).toBeDefined();
});
it('should accept bidirectional direction', async () => {
const result = await syncEngine.sync('bidirectional');
expect(result).toBeDefined();
});
it('should default to bidirectional when no direction specified', async () => {
const result = await syncEngine.sync();
expect(result).toBeDefined();
});
});
describe('Event Emission', () => {
it('should be an EventEmitter', () => {
expect(syncEngine.on).toBeDefined();
expect(syncEngine.emit).toBeDefined();
expect(syncEngine.removeListener).toBeDefined();
});
it('should allow adding event listeners', () => {
const listener = vi.fn();
syncEngine.on('testEvent', listener);
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 type { PostData } from '../src/main/engine/PostEngine';
import type { MediaData } from '../src/main/engine/MediaEngine';
import type { Task, TaskProgress } from '../src/main/engine/TaskManager';
import type { PostData } from '../../src/main/engine/PostEngine';
import type { MediaData } from '../../src/main/engine/MediaEngine';
import type { Task, TaskProgress } from '../../src/main/engine/TaskManager';
// ============================================
// Post Mock Factory
@@ -20,6 +20,7 @@ export function createMockPost(overrides?: Partial<PostData>): PostData {
return {
id,
projectId: 'default',
title: `Test Post ${id}`,
slug: `test-post-${id}`,
excerpt: 'This is a test excerpt',