initial commit
This commit is contained in:
419
tests/engine/MediaEngine.test.ts
Normal file
419
tests/engine/MediaEngine.test.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* MediaEngine Unit Tests
|
||||
*
|
||||
* Tests for media file management including:
|
||||
* - Media import and storage
|
||||
* - Sidecar metadata file handling
|
||||
* - MIME type detection
|
||||
* - Checksum calculation
|
||||
* - Filename generation
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createMockMedia, createMockPdfMedia, 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(() => ({
|
||||
where: vi.fn(() => Promise.resolve()),
|
||||
})),
|
||||
})),
|
||||
getRemote: vi.fn(() => null),
|
||||
getDataPaths: vi.fn(() => ({
|
||||
database: '/mock/userData/bds.db',
|
||||
posts: '/mock/userData/posts',
|
||||
media: '/mock/userData/media',
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises');
|
||||
|
||||
describe('MediaEngine', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetMockCounters();
|
||||
});
|
||||
|
||||
describe('Media Data Validation', () => {
|
||||
it('should create valid media with required fields', () => {
|
||||
const media = createMockMedia();
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 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();
|
||||
});
|
||||
});
|
||||
|
||||
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 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);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
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']);
|
||||
});
|
||||
|
||||
it('should handle missing optional fields in sidecar', () => {
|
||||
const media = createMockPdfMedia({
|
||||
alt: undefined,
|
||||
caption: undefined,
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Media Tags', () => {
|
||||
it('should handle empty tags array', () => {
|
||||
const media = createMockMedia({ tags: [] });
|
||||
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);
|
||||
|
||||
expect(deserialized).toEqual(tags);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Image Dimensions', () => {
|
||||
it('should store width and height for images', () => {
|
||||
const media = createMockMedia({
|
||||
mimeType: 'image/jpeg',
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
});
|
||||
|
||||
expect(media.width).toBe(3840);
|
||||
expect(media.height).toBe(2160);
|
||||
});
|
||||
|
||||
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}`;
|
||||
};
|
||||
|
||||
expect(getAspectRatio(1920, 1080)).toBe('16:9');
|
||||
expect(getAspectRatio(1024, 768)).toBe('4:3');
|
||||
expect(getAspectRatio(1080, 1080)).toBe('1:1');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
436
tests/engine/PostEngine.test.ts
Normal file
436
tests/engine/PostEngine.test.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* PostEngine Unit Tests
|
||||
*
|
||||
* Tests for blog post management including:
|
||||
* - Post CRUD operations
|
||||
* - Slug generation
|
||||
* - Markdown with YAML frontmatter handling
|
||||
* - Checksum calculation
|
||||
* - Event emissions
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
|
||||
import { createMockPost, createMockFileSystem, createMockDatabase, 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(() => ({
|
||||
where: vi.fn(() => Promise.resolve()),
|
||||
})),
|
||||
})),
|
||||
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(),
|
||||
};
|
||||
|
||||
return {
|
||||
getDatabase: vi.fn(() => mockDb),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => createMockFileSystem());
|
||||
|
||||
describe('PostEngine', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetMockCounters();
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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 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');
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
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');
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
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 || [],
|
||||
};
|
||||
};
|
||||
|
||||
const post = createPostData({});
|
||||
|
||||
expect(post.title).toBe('Untitled');
|
||||
expect(post.status).toBe('draft');
|
||||
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'],
|
||||
});
|
||||
|
||||
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',
|
||||
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/);
|
||||
});
|
||||
|
||||
it('should handle optional fields correctly', () => {
|
||||
const post = createMockPost({
|
||||
excerpt: undefined,
|
||||
author: undefined,
|
||||
publishedAt: undefined,
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set publishedAt when publishing', () => {
|
||||
const now = new Date();
|
||||
const post = createMockPost({
|
||||
status: 'published',
|
||||
publishedAt: now,
|
||||
});
|
||||
|
||||
expect(post.status).toBe('published');
|
||||
expect(post.publishedAt).toEqual(now);
|
||||
});
|
||||
|
||||
it('should not require publishedAt for drafts', () => {
|
||||
const post = createMockPost({
|
||||
status: 'draft',
|
||||
publishedAt: undefined,
|
||||
});
|
||||
|
||||
expect(post.status).toBe('draft');
|
||||
expect(post.publishedAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
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'],
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
568
tests/engine/SyncEngine.test.ts
Normal file
568
tests/engine/SyncEngine.test.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* SyncEngine Unit Tests
|
||||
*
|
||||
* Tests for remote synchronization including:
|
||||
* - Sync configuration
|
||||
* - Push/pull operations
|
||||
* - Conflict detection and resolution
|
||||
* - Sync status tracking
|
||||
* - Retry logic
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
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(() => ({
|
||||
where: vi.fn(() => Promise.resolve()),
|
||||
})),
|
||||
})),
|
||||
getRemote: vi.fn(() => null),
|
||||
initializeLocal: vi.fn(),
|
||||
initializeRemote: vi.fn(),
|
||||
getDataPaths: vi.fn(() => ({
|
||||
database: '/mock/userData/bds.db',
|
||||
posts: '/mock/userData/posts',
|
||||
media: '/mock/userData/media',
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/PostEngine', () => ({
|
||||
getPostEngine: vi.fn(() => ({
|
||||
getAllPosts: vi.fn(() => Promise.resolve([])),
|
||||
createPost: vi.fn(),
|
||||
updatePost: vi.fn(),
|
||||
deletePost: 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(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('SyncEngine', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetMockCounters();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 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: [],
|
||||
};
|
||||
|
||||
expect(successResult.success).toBe(true);
|
||||
expect(successResult.pushed + successResult.pulled).toBe(8);
|
||||
expect(successResult.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
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'],
|
||||
};
|
||||
|
||||
expect(errorResult.success).toBe(false);
|
||||
expect(errorResult.errors).toHaveLength(2);
|
||||
expect(errorResult.errors).toContain('Network timeout');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const callback = vi.fn();
|
||||
const intervalId = startAutoSync(5, callback);
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenCalledWith(callback, 300000);
|
||||
|
||||
clearInterval(intervalId);
|
||||
});
|
||||
|
||||
it('should stop auto sync interval', () => {
|
||||
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
|
||||
|
||||
const intervalId = setInterval(() => {}, 1000);
|
||||
clearInterval(intervalId);
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger sync on interval', () => {
|
||||
const syncCallback = vi.fn();
|
||||
const intervalId = setInterval(syncCallback, 1000);
|
||||
|
||||
vi.advanceTimersByTime(3000);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
371
tests/engine/TaskManager.test.ts
Normal file
371
tests/engine/TaskManager.test.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* TaskManager Unit Tests
|
||||
*
|
||||
* Tests for the async task management system including:
|
||||
* - Task execution and progress tracking
|
||||
* - Task queuing and concurrency limits
|
||||
* - Task cancellation
|
||||
* - Event emissions
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { TaskManager, Task, TaskProgress, TaskStatus } from '../../src/main/engine/TaskManager';
|
||||
import {
|
||||
createMockTask,
|
||||
createMockSlowTask,
|
||||
createMockFailingTask,
|
||||
resetMockCounters
|
||||
} from '../utils/factories';
|
||||
|
||||
describe('TaskManager', () => {
|
||||
let taskManager: TaskManager;
|
||||
|
||||
beforeEach(() => {
|
||||
taskManager = new TaskManager();
|
||||
resetMockCounters();
|
||||
});
|
||||
|
||||
describe('Task Execution', () => {
|
||||
it('should execute a task successfully', async () => {
|
||||
const task = createMockTask<string>(async (onProgress) => {
|
||||
onProgress(50, 'Halfway');
|
||||
onProgress(100, 'Done');
|
||||
return 'result';
|
||||
});
|
||||
|
||||
const result = await taskManager.runTask(task);
|
||||
|
||||
expect(result).toBe('result');
|
||||
});
|
||||
|
||||
it('should track task progress', async () => {
|
||||
const progressUpdates: { progress: number; message: string }[] = [];
|
||||
|
||||
taskManager.on('taskProgress', (taskProgress: TaskProgress) => {
|
||||
progressUpdates.push({
|
||||
progress: taskProgress.progress,
|
||||
message: taskProgress.message,
|
||||
});
|
||||
});
|
||||
|
||||
const task = createMockTask(async (onProgress) => {
|
||||
onProgress(25, 'Step 1');
|
||||
onProgress(50, 'Step 2');
|
||||
onProgress(75, 'Step 3');
|
||||
onProgress(100, 'Complete');
|
||||
});
|
||||
|
||||
await taskManager.runTask(task);
|
||||
|
||||
expect(progressUpdates.length).toBe(4);
|
||||
expect(progressUpdates[0]).toEqual({ progress: 25, message: 'Step 1' });
|
||||
expect(progressUpdates[3]).toEqual({ progress: 100, message: 'Complete' });
|
||||
});
|
||||
|
||||
it('should emit taskCreated event when task starts', async () => {
|
||||
const createdHandler = vi.fn();
|
||||
taskManager.on('taskCreated', createdHandler);
|
||||
|
||||
const task = createMockTask();
|
||||
await taskManager.runTask(task);
|
||||
|
||||
// The handler is called at some point during task lifecycle
|
||||
expect(createdHandler).toHaveBeenCalled();
|
||||
expect(createdHandler.mock.calls[0][0].taskId).toBe(task.id);
|
||||
});
|
||||
|
||||
it('should emit taskStarted event when task begins execution', async () => {
|
||||
const startedHandler = vi.fn();
|
||||
taskManager.on('taskStarted', startedHandler);
|
||||
|
||||
const task = createMockTask();
|
||||
await taskManager.runTask(task);
|
||||
|
||||
// The handler is called at some point during task lifecycle
|
||||
expect(startedHandler).toHaveBeenCalled();
|
||||
expect(startedHandler.mock.calls[0][0].taskId).toBe(task.id);
|
||||
});
|
||||
|
||||
it('should emit taskCompleted event when task finishes', async () => {
|
||||
const completedHandler = vi.fn();
|
||||
taskManager.on('taskCompleted', completedHandler);
|
||||
|
||||
const task = createMockTask();
|
||||
await taskManager.runTask(task);
|
||||
|
||||
expect(completedHandler).toHaveBeenCalledTimes(1);
|
||||
expect(completedHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
taskId: task.id,
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should set endTime when task completes', async () => {
|
||||
const task = createMockTask();
|
||||
await taskManager.runTask(task);
|
||||
|
||||
const status = taskManager.getTaskStatus(task.id);
|
||||
expect(status?.endTime).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task Failure', () => {
|
||||
it('should handle task failure gracefully', async () => {
|
||||
const task = createMockFailingTask('Test error');
|
||||
|
||||
await expect(taskManager.runTask(task)).rejects.toThrow('Test error');
|
||||
});
|
||||
|
||||
it('should emit taskFailed event on error', async () => {
|
||||
const failedHandler = vi.fn();
|
||||
taskManager.on('taskFailed', failedHandler);
|
||||
|
||||
const task = createMockFailingTask('Something went wrong');
|
||||
|
||||
try {
|
||||
await taskManager.runTask(task);
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
|
||||
expect(failedHandler).toHaveBeenCalledTimes(1);
|
||||
expect(failedHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
taskId: task.id,
|
||||
status: 'failed',
|
||||
error: 'Something went wrong',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should set task status to failed on error', async () => {
|
||||
const task = createMockFailingTask();
|
||||
|
||||
try {
|
||||
await taskManager.runTask(task);
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
|
||||
const status = taskManager.getTaskStatus(task.id);
|
||||
expect(status?.status).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task Status Queries', () => {
|
||||
it('should return task status by id', async () => {
|
||||
const task = createMockTask();
|
||||
|
||||
// Before running
|
||||
expect(taskManager.getTaskStatus(task.id)).toBeUndefined();
|
||||
|
||||
await taskManager.runTask(task);
|
||||
|
||||
// After running
|
||||
const status = taskManager.getTaskStatus(task.id);
|
||||
expect(status).toBeDefined();
|
||||
expect(status?.taskId).toBe(task.id);
|
||||
});
|
||||
|
||||
it('should return all tasks', async () => {
|
||||
const task1 = createMockTask();
|
||||
const task2 = createMockTask();
|
||||
|
||||
await Promise.all([
|
||||
taskManager.runTask(task1),
|
||||
taskManager.runTask(task2),
|
||||
]);
|
||||
|
||||
const allTasks = taskManager.getAllTasks();
|
||||
expect(allTasks.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should return only running tasks', async () => {
|
||||
// Create a slow task that will still be running
|
||||
let resolveTask: () => void;
|
||||
const slowTask = createMockTask(async (onProgress) => {
|
||||
onProgress(10, 'Working...');
|
||||
await new Promise<void>(resolve => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
const fastTask = createMockTask();
|
||||
|
||||
// Start slow task (don't await)
|
||||
const slowPromise = taskManager.runTask(slowTask);
|
||||
|
||||
// Run fast task
|
||||
await taskManager.runTask(fastTask);
|
||||
|
||||
const runningTasks = taskManager.getRunningTasks();
|
||||
expect(runningTasks.length).toBe(1);
|
||||
expect(runningTasks[0].taskId).toBe(slowTask.id);
|
||||
|
||||
// Cleanup
|
||||
resolveTask!();
|
||||
await slowPromise;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task Cancellation', () => {
|
||||
it('should cancel a running task that checks for abort', async () => {
|
||||
// Task that polls for cancellation via onProgress check
|
||||
const task = createMockTask(async (onProgress) => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
// onProgress will throw if task was cancelled
|
||||
onProgress(i * 5, `Step ${i}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
});
|
||||
|
||||
const taskPromise = taskManager.runTask(task);
|
||||
|
||||
// Give task time to start and make some progress
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
const cancelled = taskManager.cancelTask(task.id);
|
||||
expect(cancelled).toBe(true);
|
||||
|
||||
// The task should throw 'Task cancelled' error
|
||||
await expect(taskPromise).rejects.toThrow('Task cancelled');
|
||||
});
|
||||
|
||||
it('should return false when cancelling non-existent task', () => {
|
||||
const cancelled = taskManager.cancelTask('non-existent-id');
|
||||
expect(cancelled).toBe(false);
|
||||
});
|
||||
|
||||
it('should set task status to cancelled after cancel', async () => {
|
||||
// Task that polls for cancellation
|
||||
const task = createMockTask(async (onProgress) => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
onProgress(i * 5, `Step ${i}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
});
|
||||
|
||||
const taskPromise = taskManager.runTask(task);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
taskManager.cancelTask(task.id);
|
||||
|
||||
try {
|
||||
await taskPromise;
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
|
||||
const status = taskManager.getTaskStatus(task.id);
|
||||
expect(status?.status).toBe('cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Clear Completed Tasks', () => {
|
||||
it('should remove completed tasks', async () => {
|
||||
const task = createMockTask();
|
||||
await taskManager.runTask(task);
|
||||
|
||||
expect(taskManager.getAllTasks().length).toBe(1);
|
||||
|
||||
taskManager.clearCompletedTasks();
|
||||
|
||||
expect(taskManager.getAllTasks().length).toBe(0);
|
||||
});
|
||||
|
||||
it('should remove failed tasks', async () => {
|
||||
const task = createMockFailingTask();
|
||||
|
||||
try {
|
||||
await taskManager.runTask(task);
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
|
||||
expect(taskManager.getAllTasks().length).toBe(1);
|
||||
|
||||
taskManager.clearCompletedTasks();
|
||||
|
||||
expect(taskManager.getAllTasks().length).toBe(0);
|
||||
});
|
||||
|
||||
it('should emit tasksCleared event', async () => {
|
||||
const clearedHandler = vi.fn();
|
||||
taskManager.on('tasksCleared', clearedHandler);
|
||||
|
||||
const task = createMockTask();
|
||||
await taskManager.runTask(task);
|
||||
|
||||
taskManager.clearCompletedTasks();
|
||||
|
||||
expect(clearedHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task Return Values', () => {
|
||||
it('should return typed results from tasks', async () => {
|
||||
interface TaskResult {
|
||||
count: number;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
const task = createMockTask<TaskResult>(async () => ({
|
||||
count: 3,
|
||||
items: ['a', 'b', 'c'],
|
||||
}));
|
||||
|
||||
const result = await taskManager.runTask(task);
|
||||
|
||||
expect(result.count).toBe(3);
|
||||
expect(result.items).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('should handle void tasks', async () => {
|
||||
const task = createMockTask<void>(async () => {
|
||||
// No return
|
||||
});
|
||||
|
||||
const result = await taskManager.runTask(task);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskManager Concurrency', () => {
|
||||
let taskManager: TaskManager;
|
||||
const MAX_CONCURRENT = 3;
|
||||
|
||||
beforeEach(() => {
|
||||
taskManager = new TaskManager();
|
||||
resetMockCounters();
|
||||
});
|
||||
|
||||
it('should run tasks up to concurrency limit', async () => {
|
||||
// Create fast tasks that complete quickly
|
||||
const tasks = Array.from({ length: 5 }, () =>
|
||||
createMockTask(async (onProgress) => {
|
||||
onProgress(50, 'Working');
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
})
|
||||
);
|
||||
|
||||
// Start all tasks
|
||||
const promises = tasks.map(t => taskManager.runTask(t));
|
||||
|
||||
// Since max concurrent is 3, we should never exceed that
|
||||
// The first 3 will start immediately, others will queue
|
||||
const runningCount = taskManager.getRunningTasks().length;
|
||||
expect(runningCount).toBeLessThanOrEqual(MAX_CONCURRENT);
|
||||
|
||||
// Wait for all to complete
|
||||
await Promise.all(promises);
|
||||
|
||||
// All should be completed now
|
||||
expect(taskManager.getAllTasks().every(t => t.status === 'completed')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user