Files
bDS/tests/utils/factories.ts

602 lines
17 KiB
TypeScript

/**
* Test utilities and mock factories
* Following TDD best practices with reusable test data generators
*/
import { vi } from 'vitest';
import type { PostData } from '../../src/main/engine/PostEngine';
import type { MediaData } from '../../src/main/engine/MediaEngine';
import type { ProjectData } from '../../src/main/engine/ProjectEngine';
import type { Task, TaskProgress } from '../../src/main/engine/TaskManager';
// ============================================
// Post Mock Factory
// ============================================
let postIdCounter = 1;
export function createMockPost(overrides?: Partial<PostData>): PostData {
const id = `post-${postIdCounter++}`;
const now = new Date();
return {
id,
projectId: 'default',
title: `Test Post ${id}`,
slug: `test-post-${id}`,
excerpt: 'This is a test excerpt',
content: '# Test Content\n\nThis is test content.',
status: 'draft',
author: 'Test Author',
createdAt: now,
updatedAt: now,
publishedAt: undefined,
tags: ['test', 'mock'],
categories: ['testing'],
...overrides,
};
}
export function createMockPublishedPost(overrides?: Partial<PostData>): PostData {
const now = new Date();
return createMockPost({
status: 'published',
publishedAt: now,
...overrides,
});
}
// ============================================
// Media Mock Factory
// ============================================
let mediaIdCounter = 1;
export function createMockMedia(overrides?: Partial<MediaData>): MediaData {
const id = `media-${mediaIdCounter++}`;
const now = new Date();
return {
id,
filename: `${id}.jpg`,
originalName: `original-${id}.jpg`,
mimeType: 'image/jpeg',
size: 1024 * 100, // 100KB
width: 800,
height: 600,
title: 'Test Image Title',
alt: 'Test image',
caption: 'A test image caption',
createdAt: now,
updatedAt: now,
tags: ['test', 'image'],
...overrides,
};
}
export function createMockPdfMedia(overrides?: Partial<MediaData>): MediaData {
return createMockMedia({
filename: 'document.pdf',
originalName: 'document.pdf',
mimeType: 'application/pdf',
width: undefined,
height: undefined,
...overrides,
});
}
// ============================================
// Project Mock Factory
// ============================================
let projectIdCounter = 1;
export function createMockProject(overrides?: Partial<ProjectData>): ProjectData {
const id = overrides?.id ?? `project-${projectIdCounter++}`;
const now = new Date();
return {
id,
name: `Test Project ${id}`,
slug: `test-project-${id}`,
description: 'A test project',
dataPath: undefined,
createdAt: now,
updatedAt: now,
isActive: false,
...overrides,
};
}
export function createMockActiveProject(overrides?: Partial<ProjectData>): ProjectData {
return createMockProject({
isActive: true,
...overrides,
});
}
// ============================================
// Task Mock Factory
// ============================================
let taskIdCounter = 1;
export function createMockTask<T = void>(
executor?: (onProgress: (progress: number, message: string) => void) => Promise<T>,
overrides?: Partial<Task<T>>
): Task<T> {
const id = `task-${taskIdCounter++}`;
return {
id,
name: `Test Task ${id}`,
execute: executor || (async (onProgress) => {
onProgress(50, 'Processing...');
onProgress(100, 'Done');
return undefined as T;
}),
...overrides,
};
}
export function createMockSlowTask(durationMs: number): Task<void> {
return createMockTask(async (onProgress) => {
const steps = 10;
const stepDuration = durationMs / steps;
for (let i = 1; i <= steps; i++) {
await new Promise(resolve => setTimeout(resolve, stepDuration));
onProgress(i * 10, `Step ${i}/${steps}`);
}
});
}
export function createMockFailingTask(errorMessage: string = 'Task failed'): Task<void> {
return createMockTask(async () => {
throw new Error(errorMessage);
});
}
export function createMockTaskProgress(overrides?: Partial<TaskProgress>): TaskProgress {
return {
taskId: `task-${taskIdCounter++}`,
status: 'pending',
progress: 0,
message: 'Waiting...',
startTime: new Date(),
...overrides,
};
}
// ============================================
// Database Mock Utilities
// ============================================
export function createMockDatabase() {
const data = {
posts: new Map<string, PostData>(),
media: new Map<string, MediaData>(),
};
return {
data,
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(),
};
}
// ============================================
// File System Mock Utilities
// ============================================
export function createMockFileSystem() {
const files = new Map<string, string | Buffer>();
const directories = new Set<string>(['/mock', '/mock/userData', '/mock/userData/posts', '/mock/userData/media']);
return {
files,
directories,
readFile: vi.fn(async (path: string) => {
const content = files.get(path);
if (content === undefined) {
const error = new Error(`ENOENT: no such file or directory, open '${path}'`);
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}
return content;
}),
writeFile: vi.fn(async (path: string, content: string | Buffer) => {
files.set(path, content);
}),
unlink: vi.fn(async (path: string) => {
if (!files.has(path)) {
const error = new Error(`ENOENT: no such file or directory, unlink '${path}'`);
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}
files.delete(path);
}),
mkdir: vi.fn(async (path: string) => {
directories.add(path);
}),
readdir: vi.fn(async (path: string) => {
const entries: string[] = [];
for (const filePath of files.keys()) {
if (filePath.startsWith(path + '/')) {
const relativePath = filePath.slice(path.length + 1);
const firstSegment = relativePath.split('/')[0];
if (!entries.includes(firstSegment)) {
entries.push(firstSegment);
}
}
}
return entries;
}),
stat: vi.fn(async (path: string) => {
if (files.has(path)) {
const content = files.get(path)!;
return {
isFile: () => true,
isDirectory: () => false,
size: typeof content === 'string' ? content.length : content.length,
};
}
if (directories.has(path)) {
return {
isFile: () => false,
isDirectory: () => true,
size: 0,
};
}
const error = new Error(`ENOENT: no such file or directory, stat '${path}'`);
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}),
copyFile: vi.fn(async (src: string, dest: string) => {
const content = files.get(src);
if (content === undefined) {
const error = new Error(`ENOENT: no such file or directory, copyfile '${src}'`);
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}
files.set(dest, content);
}),
access: vi.fn(async (path: string) => {
if (!files.has(path) && !directories.has(path)) {
const error = new Error(`ENOENT: no such file or directory, access '${path}'`);
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}
}),
};
}
// ============================================
// Dropbox Mock Factory
// ============================================
import type { DropboxSyncConfig, DropboxConflict } from '../../src/main/engine/DropboxSyncEngine';
let dropboxConflictIdCounter = 1;
export function createMockDropboxConfig(overrides?: Partial<DropboxSyncConfig>): DropboxSyncConfig {
return {
accessToken: 'mock-dropbox-access-token',
refreshToken: 'mock-dropbox-refresh-token',
appKey: 'mock-app-key',
appSecret: 'mock-app-secret',
syncEnabled: true,
syncInterval: 60,
localPostsDir: '/mock/userData/projects/default/posts',
localMediaDir: '/mock/userData/projects/default/media',
remoteBasePath: '/bds',
...overrides,
};
}
export function createMockDropboxClient() {
return {
filesUpload: vi.fn().mockResolvedValue({
result: {
name: 'test.md',
path_lower: '/bds/posts/2026/01/test.md',
content_hash: 'mockhash123',
server_modified: '2026-01-15T10:00:00Z',
size: 100,
},
}),
filesDownload: vi.fn().mockResolvedValue({
result: {
name: 'test.md',
path_lower: '/bds/posts/2026/01/test.md',
fileBinary: Buffer.from('downloaded content'),
content_hash: 'mockhash123',
server_modified: '2026-01-15T10:00:00Z',
size: 18,
},
}),
filesDeleteV2: vi.fn().mockResolvedValue({
result: { metadata: { '.tag': 'file', name: 'test.md' } },
}),
filesListFolder: vi.fn().mockResolvedValue({
result: { entries: [], cursor: 'mock-cursor', has_more: false },
}),
filesListFolderContinue: vi.fn().mockResolvedValue({
result: { entries: [], cursor: 'mock-cursor-next', has_more: false },
}),
filesListFolderGetLatestCursor: vi.fn().mockResolvedValue({
result: { cursor: 'mock-latest-cursor' },
}),
filesListFolderLongpoll: vi.fn().mockResolvedValue({
result: { changes: false, backoff: 0 },
}),
filesGetMetadata: vi.fn().mockResolvedValue({
result: {
'.tag': 'file',
name: 'test.md',
path_lower: '/bds/posts/2026/01/test.md',
content_hash: 'mockhash123',
server_modified: '2026-01-15T10:00:00Z',
size: 100,
},
}),
setRefreshToken: vi.fn(),
getRefreshToken: vi.fn().mockReturnValue('mock-refresh-token'),
getAccessToken: vi.fn().mockReturnValue('mock-access-token'),
};
}
export function createMockDropboxConflict(overrides?: Partial<DropboxConflict>): DropboxConflict {
const id = `conflict-${dropboxConflictIdCounter++}`;
return {
id,
localPath: '/mock/userData/projects/default/posts/2026/01/test.md',
remotePath: '/bds/posts/2026/01/test.md',
localModified: new Date('2026-01-20T15:00:00Z'),
remoteModified: new Date('2026-01-20T14:00:00Z'),
localHash: 'localhash123',
remoteHash: 'remotehash456',
...overrides,
};
}
// ============================================
// Import Analysis Report Mock Factory
// ============================================
import type {
ImportAnalysisReport,
AnalyzedPost,
AnalyzedMedia,
AnalyzedCategory,
AnalyzedTag,
PostAnalysisStatus,
MediaAnalysisStatus,
} from '../../src/main/engine/ImportAnalysisEngine';
import type { WxrPost, WxrMedia, WxrSiteInfo } from '../../src/main/engine/WxrParser';
let wxrPostIdCounter = 1;
let wxrMediaIdCounter = 1;
export function createMockWxrSiteInfo(overrides?: Partial<WxrSiteInfo>): WxrSiteInfo {
return {
title: 'Test WordPress Site',
link: 'https://example.com',
description: 'A test WordPress site',
language: 'en-US',
...overrides,
};
}
export function createMockWxrPost(overrides?: Partial<WxrPost>): WxrPost {
const id = wxrPostIdCounter++;
return {
wpId: id,
title: `Test Post ${id}`,
slug: `test-post-${id}`,
status: 'publish',
content: `<p>Test content for post ${id}</p>`,
excerpt: `Excerpt for post ${id}`,
pubDate: '2024-01-15T10:00:00Z',
postDate: '2024-01-15T10:00:00Z',
postModified: '2024-01-16T12:00:00Z',
creator: 'testauthor',
postType: 'post',
categories: ['Test Category'],
tags: ['test-tag'],
...overrides,
};
}
export function createMockWxrMedia(overrides?: Partial<WxrMedia>): WxrMedia {
const id = wxrMediaIdCounter++;
return {
wpId: id,
title: `Test Media ${id}`,
filename: `test-image-${id}.jpg`,
url: `https://example.com/wp-content/uploads/2024/01/test-image-${id}.jpg`,
relativePath: `2024/01/test-image-${id}.jpg`,
pubDate: '2024-01-15T10:00:00Z',
parentId: 0,
mimeType: 'image/jpeg',
description: `Description for media ${id}`,
...overrides,
};
}
export function createMockAnalyzedPost(
overrides?: Partial<AnalyzedPost>,
wxrOverrides?: Partial<WxrPost>
): AnalyzedPost {
const wxrPost = createMockWxrPost(wxrOverrides);
return {
wxrPost,
status: 'new' as PostAnalysisStatus,
contentHash: `hash-${wxrPost.wpId}`,
markdownPreview: `# ${wxrPost.title}\n\nTest content for post ${wxrPost.wpId}`,
...overrides,
};
}
export function createMockAnalyzedMedia(
overrides?: Partial<AnalyzedMedia>,
wxrOverrides?: Partial<WxrMedia>
): AnalyzedMedia {
const wxrMedia = createMockWxrMedia(wxrOverrides);
return {
wxrMedia,
status: 'new' as MediaAnalysisStatus,
fileHash: `filehash-${wxrMedia.wpId}`,
...overrides,
};
}
export function createMockAnalyzedCategory(overrides?: Partial<AnalyzedCategory>): AnalyzedCategory {
return {
name: 'Test Category',
slug: 'test-category',
existsInProject: false,
...overrides,
};
}
export function createMockAnalyzedTag(overrides?: Partial<AnalyzedTag>): AnalyzedTag {
return {
name: 'test-tag',
slug: 'test-tag',
existsInProject: false,
...overrides,
};
}
export function createMockImportAnalysisReport(
overrides?: Partial<ImportAnalysisReport>
): ImportAnalysisReport {
const posts = overrides?.posts?.items || [createMockAnalyzedPost()];
const pages = overrides?.pages?.items || [];
const mediaItems = overrides?.media?.items || [createMockAnalyzedMedia()];
const categories = overrides?.categories || [createMockAnalyzedCategory()];
const tags = overrides?.tags || [createMockAnalyzedTag()];
return {
sourceFile: '/path/to/export.xml',
site: createMockWxrSiteInfo(),
analyzedAt: new Date('2024-01-15T12:00:00Z'),
posts: {
total: posts.length,
new: posts.filter(p => p.status === 'new').length,
updates: posts.filter(p => p.status === 'update').length,
conflicts: posts.filter(p => p.status === 'conflict').length,
contentDuplicates: posts.filter(p => p.status === 'content-duplicate').length,
items: posts,
...overrides?.posts,
},
pages: {
total: pages.length,
new: pages.filter(p => p.status === 'new').length,
updates: pages.filter(p => p.status === 'update').length,
conflicts: pages.filter(p => p.status === 'conflict').length,
contentDuplicates: pages.filter(p => p.status === 'content-duplicate').length,
items: pages,
...overrides?.pages,
},
media: {
total: mediaItems.length,
new: mediaItems.filter(m => m.status === 'new').length,
updates: mediaItems.filter(m => m.status === 'update').length,
conflicts: mediaItems.filter(m => m.status === 'conflict').length,
contentDuplicates: mediaItems.filter(m => m.status === 'content-duplicate').length,
missing: mediaItems.filter(m => m.status === 'missing').length,
items: mediaItems,
...overrides?.media,
},
categories,
tags,
macros: overrides?.macros || {
total: 0,
mappedCount: 0,
unmappedCount: 0,
discovered: [],
},
...overrides,
};
}
// ============================================
// Reset Utilities
// ============================================
export function resetMockCounters(): void {
postIdCounter = 1;
mediaIdCounter = 1;
projectIdCounter = 1;
taskIdCounter = 1;
dropboxConflictIdCounter = 1;
wxrPostIdCounter = 1;
wxrMediaIdCounter = 1;
}
// ============================================
// Assertion Helpers
// ============================================
export function expectPostToMatchPartial(
actual: PostData,
expected: Partial<PostData>
): void {
for (const [key, value] of Object.entries(expected)) {
if (value instanceof Date) {
expect((actual as Record<string, unknown>)[key]).toBeInstanceOf(Date);
} else {
expect((actual as Record<string, unknown>)[key]).toEqual(value);
}
}
}
export function expectMediaToMatchPartial(
actual: MediaData,
expected: Partial<MediaData>
): void {
for (const [key, value] of Object.entries(expected)) {
if (value instanceof Date) {
expect((actual as Record<string, unknown>)[key]).toBeInstanceOf(Date);
} else {
expect((actual as Record<string, unknown>)[key]).toEqual(value);
}
}
}