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

@@ -1,419 +1,451 @@
/**
* 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([])),
})),
})),
insert: vi.fn(() => ({
values: vi.fn(() => Promise.resolve()),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
delete: vi.fn(() => ({
// 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((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()),
})),
})),
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 });
expect(withAlt.alt).toBe('Description');
expect(withAlt.caption).toBe('A caption');
expect(withoutAlt.alt).toBeUndefined();
expect(withoutAlt.caption).toBeUndefined();
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);
});
});
describe('MIME Type Handling', () => {
it('should recognize common image MIME types', () => {
const imageMimeTypes = [
'image/jpeg',
'image/png',
'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);
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 recognize document MIME types', () => {
const docMimeTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
it('should set createdAt and updatedAt timestamps', async () => {
const before = new Date();
const media = await mediaEngine.importMedia('/source/image.jpg');
const after = new Date();
docMimeTypes.forEach(mimeType => {
const media = createMockMedia({ mimeType });
expect(media.mimeType).toBe(mimeType);
});
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 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);
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('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);
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'));
});
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 detect image/jpeg for .jpg files', async () => {
const media = await mediaEngine.importMedia('/source/photo.jpg');
expect(media.mimeType).toBe('image/jpeg');
});
it('should sanitize original filename', () => {
const sanitizeFilename = (filename: string): string => {
return filename.replace(/[^a-zA-Z0-9.-]/g, '_');
};
it('should detect image/jpeg for .jpeg files', async () => {
const media = await mediaEngine.importMedia('/source/photo.jpeg');
expect(media.mimeType).toBe('image/jpeg');
});
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');
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');
});
});
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');
describe('Media with Image Dimensions', () => {
beforeEach(() => {
mockFiles.set('/source/image.jpg', Buffer.from('image-data'));
});
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);
describe('Event Emission', () => {
it('should be an EventEmitter', () => {
expect(mediaEngine.on).toBeDefined();
expect(mediaEngine.emit).toBeDefined();
expect(mediaEngine.removeListener).toBeDefined();
});
it('should serialize tags to JSON', () => {
const tags = ['photo', 'landscape', '2024'];
const serialized = JSON.stringify(tags);
const deserialized = JSON.parse(serialized);
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');
});
});
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.height).toBe(2160);
expect(media.alt).toBe('A scenic mountain view');
});
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 store caption for display', async () => {
const media = await mediaEngine.importMedia('/source/image.jpg', {
caption: 'Photo taken at Mt. Rainier, 2024',
});
expect(getAspectRatio(1920, 1080)).toBe('16:9');
expect(getAspectRatio(1024, 768)).toBe('4:3');
expect(getAspectRatio(1080, 1080)).toBe('1:1');
expect(media.caption).toBe('Photo taken at Mt. Rainier, 2024');
});
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 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,39 +1,69 @@
/**
* 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([])),
})),
})),
insert: vi.fn(() => ({
values: vi.fn(() => Promise.resolve()),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
delete: vi.fn(() => ({
// Create mock data stores
const mockPosts = new Map<string, any>();
const mockFiles = new Map<string, string>();
let mockExecuteArgs: any[] = [];
// Create chainable mock for Drizzle ORM
function createSelectChain() {
return {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockImplementation(function(this: any) {
return this;
}),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
all: vi.fn().mockImplementation(() => Promise.resolve(Array.from(mockPosts.values()))),
get: vi.fn().mockImplementation(() => Promise.resolve(undefined)),
};
}
function createDrizzleMock() {
return {
select: vi.fn(() => createSelectChain()),
insert: vi.fn(() => ({
values: vi.fn((data: any) => {
if (data && data.id) {
mockPosts.set(data.id, data);
}
return Promise.resolve();
}),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
};
}
const mockLocalDb = createDrizzleMock();
const mockLocalClient = {
execute: vi.fn(async (query: { sql: string; args: any[] }) => {
mockExecuteArgs.push(query);
return { rows: [] };
}),
};
// Mock the database module
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => mockLocalDb),
getLocalClient: vi.fn(() => mockLocalClient),
getRemote: vi.fn(() => null),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
@@ -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({});
expect(post.title).toBe('Untitled');
it('should remove leading and trailing hyphens', async () => {
const post = await postEngine.createPost({ title: '---Test---' });
expect(post.slug).toBe('test');
});
it('should handle numbers in titles', async () => {
const post = await postEngine.createPost({ title: '10 Tips for Testing' });
expect(post.slug).toBe('10-tips-for-testing');
});
it('should convert multiple spaces to single hyphen', async () => {
const post = await postEngine.createPost({ title: 'Multiple Spaces Here' });
expect(post.slug).toBe('multiple-spaces-here');
});
it('should handle unicode characters by removing them', async () => {
const post = await postEngine.createPost({ title: 'Café Test' });
expect(post.slug).toBe('caf-test');
});
});
describe('Post Creation', () => {
it('should create a post with default values', async () => {
const post = await postEngine.createPost({ title: 'My Test Post' });
expect(post.id).toBeDefined();
expect(post.title).toBe('My Test Post');
expect(post.slug).toBe('my-test-post');
expect(post.status).toBe('draft');
expect(post.content).toBe('');
expect(post.tags).toEqual([]);
expect(post.categories).toEqual([]);
});
it('should preserve provided values', () => {
const post = createMockPost({
title: 'My Custom Title',
status: 'published',
tags: ['custom', 'tag'],
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.title).toBe('My Custom Title');
expect(post.status).toBe('published');
expect(post.tags).toEqual(['custom', 'tag']);
expect(post.content).toBe('# Hello\n\nThis is my post.');
});
});
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 custom status', async () => {
const post = await postEngine.createPost({
title: 'Published Post',
status: 'published',
});
expect(post.status).toBe('published');
});
it('should create a post with tags and categories', async () => {
const post = await postEngine.createPost({
title: 'Tagged Post',
tags: ['javascript', 'testing'],
categories: ['tutorials'],
});
expect(post.tags).toEqual(['javascript', 'testing']);
expect(post.categories).toEqual(['tutorials']);
});
it('should use custom slug when provided', async () => {
const post = await postEngine.createPost({
title: 'My Post',
slug: 'custom-permalink',
});
expect(post.slug).toBe('custom-permalink');
});
it('should set createdAt and updatedAt timestamps', async () => {
const before = new Date();
const post = await postEngine.createPost({ title: 'Timestamp Test' });
const after = new Date();
expect(post.createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(post.createdAt.getTime()).toBeLessThanOrEqual(after.getTime());
expect(post.updatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(post.updatedAt.getTime()).toBeLessThanOrEqual(after.getTime());
});
it('should emit postCreated event', async () => {
const handler = vi.fn();
postEngine.on('postCreated', handler);
const post = await postEngine.createPost({ title: 'Event Test' });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Event Test',
})
);
});
it('should use current project context for projectId', async () => {
postEngine.setProjectContext('my-project');
const post = await postEngine.createPost({ title: 'Project Test' });
expect(post.projectId).toBe('my-project');
});
it('should 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();
});
});
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);
});
expect(post.excerpt).toBe('This is a short summary.');
});
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);
});
});
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', () => {
const post = createMockPost({
status: 'draft',
publishedAt: undefined,
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');
});
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'],
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'],
});
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,568 +1,453 @@
/**
* 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([])),
})),
})),
insert: vi.fn(() => ({
values: vi.fn(() => Promise.resolve()),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
delete: vi.fn(() => ({
// 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((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()),
})),
})),
getRemote: vi.fn(() => null),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(),
delete: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
};
}
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;
}
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);
describe('Constructor and Initialization', () => {
it('should create a SyncEngine instance', () => {
expect(syncEngine).toBeInstanceOf(SyncEngine);
});
it('should detect unconfigured state', () => {
const isConfigured = (config: { tursoUrl?: string; tursoAuthToken?: string } | null): boolean => {
return config !== null &&
!!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 extend EventEmitter', () => {
expect(typeof syncEngine.on).toBe('function');
expect(typeof syncEngine.emit).toBe('function');
});
it('should calculate sync interval in milliseconds', () => {
const minutesToMs = (minutes: number): number => minutes * 60 * 1000;
it('should start with idle status', () => {
expect(syncEngine.getSyncStatus()).toBe('idle');
});
expect(minutesToMs(1)).toBe(60000);
expect(minutesToMs(5)).toBe(300000);
expect(minutesToMs(15)).toBe(900000);
it('should not be configured initially', () => {
expect(syncEngine.isConfigured()).toBe(false);
});
});
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: [],
describe('Configuration', () => {
it('should configure sync settings', async () => {
const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
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(true);
});
it('should report errors in result', () => {
interface SyncResult {
success: boolean;
pushed: number;
pulled: number;
conflicts: number;
errors: string[];
}
it('should emit configured event', async () => {
const handler = vi.fn();
syncEngine.on('configured', handler);
const errorResult: SyncResult = {
success: false,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: ['Network timeout', 'Authentication failed'],
const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: 'test-token',
autoSync: false,
syncInterval: 30,
};
expect(errorResult.success).toBe(false);
expect(errorResult.errors).toHaveLength(2);
expect(errorResult.errors).toContain('Network timeout');
await syncEngine.configure(config);
expect(handler).toHaveBeenCalledWith(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: [],
it('should not be configured with empty URL', async () => {
const config: SyncConfig = {
tursoUrl: '',
tursoAuthToken: 'test-token',
autoSync: false,
syncInterval: 30,
};
expect(conflictResult.conflicts).toBeGreaterThan(0);
await syncEngine.configure(config);
expect(syncEngine.isConfigured()).toBe(false);
});
});
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',
it('should not be configured with empty token', async () => {
const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: '',
autoSync: false,
syncInterval: 30,
};
expect(entity.syncStatus).toBe('pending');
expect(entity.syncedAt).toBeNull();
});
await syncEngine.configure(config);
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');
const intervalId = setInterval(() => {}, 1000);
clearInterval(intervalId);
it('should stop auto sync when called', async () => {
const handler = vi.fn();
syncEngine.on('autoSyncStopped', handler);
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', () => {
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);
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);
await syncEngine.configure(config1);
await syncEngine.configure(config2);
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',