Files
bDS/tests/engine/ImportExecutionEngine.test.ts

1617 lines
47 KiB
TypeScript

/**
* ImportExecutionEngine Unit Tests
*
* Tests the ImportExecutionEngine which handles the actual import of WXR data.
* Following TDD best practices: mock external dependencies, test real implementation.
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import type {
ImportAnalysisReport,
AnalyzedPost,
AnalyzedMedia,
AnalyzedCategory,
AnalyzedTag,
} from '../../src/main/engine/ImportAnalysisEngine';
import type { WxrPost, WxrMedia, WxrSiteInfo } from '../../src/main/engine/WxrParser';
// Mock modules before importing the engine
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => mockDb),
getLocalClient: vi.fn(() => mockClient),
})),
}));
vi.mock('fs/promises', () => ({
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
copyFile: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue(Buffer.from('test image data')),
access: vi.fn().mockResolvedValue(undefined),
stat: vi.fn().mockResolvedValue({ size: 1024 }),
}));
vi.mock('electron', () => ({
app: {
getPath: vi.fn(() => '/mock/user/data'),
},
}));
vi.mock('uuid', () => ({
v4: vi.fn(() => 'mock-uuid-' + Math.random().toString(36).substr(2, 9)),
}));
// Mock the TagEngine
const mockTagEngine = {
setProjectContext: vi.fn(),
createTag: vi.fn().mockImplementation(async (input: { name: string }) => ({
id: `tag-${input.name}`,
projectId: 'test-project',
name: input.name.toLowerCase(),
createdAt: new Date(),
updatedAt: new Date(),
})),
getAllTags: vi.fn().mockResolvedValue([]),
};
vi.mock('../../src/main/engine/TagEngine', () => ({
getTagEngine: vi.fn(() => mockTagEngine),
}));
// Mock the PostEngine
const mockPostEngine = {
setProjectContext: vi.fn(),
createPost: vi.fn().mockImplementation(async (data: any) => ({
id: data.id || 'mock-post-id',
projectId: data.projectId || 'test-project',
title: data.title,
slug: data.slug,
content: data.content,
excerpt: data.excerpt,
status: data.status,
author: data.author,
createdAt: data.createdAt || new Date(),
updatedAt: data.updatedAt || new Date(),
publishedAt: data.publishedAt,
tags: data.tags || [],
categories: data.categories || [],
})),
publishPost: vi.fn().mockImplementation(async (id: string) => ({ id, status: 'published' })),
isSlugAvailable: vi.fn().mockResolvedValue(true),
generateUniqueSlug: vi.fn().mockImplementation(async (title: string) => `${title.toLowerCase().replace(/\s+/g, '-')}-new`),
updateFTSIndex: vi.fn().mockResolvedValue(undefined),
};
vi.mock('../../src/main/engine/PostEngine', () => ({
getPostEngine: vi.fn(() => mockPostEngine),
}));
// Mock the MediaEngine
const mockMediaEngine = {
setProjectContext: vi.fn(),
importMedia: vi.fn().mockImplementation(async (sourcePath: string, metadata?: any) => ({
id: 'mock-media-id',
filename: 'test.jpg',
originalName: metadata?.originalName || 'test.jpg',
mimeType: metadata?.mimeType || 'image/jpeg',
size: 1024,
width: 800,
height: 600,
alt: metadata?.alt,
caption: metadata?.caption,
createdAt: new Date(),
updatedAt: new Date(),
tags: metadata?.tags || [],
linkedPostIds: metadata?.linkedPostIds || [],
})),
updateMedia: vi.fn().mockResolvedValue({}),
};
vi.mock('../../src/main/engine/MediaEngine', () => ({
getMediaEngine: vi.fn(() => mockMediaEngine),
}));
// Mock database - track inserts for verification
const insertedPosts: any[] = [];
const insertedMedia: any[] = [];
// Create a mock that tracks based on table name property
const createMockInsert = () => ({
values: vi.fn((data: any) => {
// The insert will be called for posts or media tables
// We determine which based on the data structure
if (data.slug !== undefined && data.projectId !== undefined) {
insertedPosts.push(data);
}
return Promise.resolve();
}),
});
const mockDb = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
get: vi.fn().mockResolvedValue(null),
all: vi.fn().mockResolvedValue([]),
})),
})),
})),
insert: vi.fn(() => createMockInsert()),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn().mockResolvedValue(undefined),
})),
})),
};
const mockClient = {
execute: vi.fn().mockResolvedValue({ rows: [] }),
};
// Import engine after mocks
import { ImportExecutionEngine, ImportExecutionResult, ImportExecutionOptions } from '../../src/main/engine/ImportExecutionEngine';
// Test fixtures
function createMockWxrPost(overrides?: Partial<WxrPost>): WxrPost {
return {
wpId: 1,
title: 'Test Post',
slug: 'test-post',
content: '<p>Test content with [gallery ids="1,2,3"]</p>',
excerpt: 'Test excerpt',
pubDate: new Date('2024-01-15T10:00:00Z'),
postDate: new Date('2024-01-15T10:00:00Z'),
postModified: new Date('2024-01-20T15:00:00Z'),
creator: 'admin',
status: 'publish',
postType: 'post',
categories: ['Technology'],
tags: ['JavaScript', 'TypeScript'],
...overrides,
};
}
function createMockWxrMedia(overrides?: Partial<WxrMedia>): WxrMedia {
return {
wpId: 100,
title: 'Test Image',
url: 'https://example.com/wp-content/uploads/2024/01/test.jpg',
filename: 'test.jpg',
relativePath: '2024/01/test.jpg',
pubDate: new Date('2024-01-15T10:00:00Z'),
parentId: 1,
mimeType: 'image/jpeg',
description: 'A test image description',
...overrides,
};
}
function createMockAnalyzedPost(wxrPost: WxrPost, status: 'new' | 'update' | 'conflict' | 'content-duplicate' = 'new', conflictResolution?: 'ignore' | 'overwrite' | 'import'): AnalyzedPost {
return {
wxrPost,
status,
contentHash: 'abc123',
markdownPreview: 'Test content preview',
conflictResolution,
};
}
function createMockAnalyzedMedia(wxrMedia: WxrMedia, status: 'new' | 'update' | 'conflict' | 'content-duplicate' | 'missing' = 'new'): AnalyzedMedia {
return {
wxrMedia,
status,
fileHash: 'def456',
};
}
function createMockAnalysisReport(overrides?: Partial<ImportAnalysisReport>): ImportAnalysisReport {
return {
sourceFile: '/path/to/export.xml',
site: {
title: 'Test Blog',
link: 'https://example.com',
description: 'A test blog',
language: 'en-US',
},
analyzedAt: new Date(),
posts: {
total: 0,
new: 0,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [],
},
pages: {
total: 0,
new: 0,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [],
},
media: {
total: 0,
new: 0,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
missing: 0,
items: [],
},
categories: [],
tags: [],
macros: {
total: 0,
mappedCount: 0,
unmappedCount: 0,
discovered: [],
},
...overrides,
};
}
describe('ImportExecutionEngine', () => {
let engine: ImportExecutionEngine;
beforeEach(() => {
vi.clearAllMocks();
insertedPosts.length = 0;
insertedMedia.length = 0;
engine = new ImportExecutionEngine();
engine.setProjectContext('test-project', '/mock/project/data');
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('setProjectContext', () => {
it('should set the project context', () => {
engine.setProjectContext('new-project', '/new/data/path');
expect(engine.getProjectContext()).toBe('new-project');
});
it('should handle project context without dataDir', () => {
engine.setProjectContext('another-project');
expect(engine.getProjectContext()).toBe('another-project');
});
});
describe('Error Handling', () => {
it('should handle top-level executeImport errors gracefully', async () => {
// Make tagEngine.setProjectContext throw to simulate catastrophic failure
mockTagEngine.setProjectContext.mockImplementationOnce(() => {
throw new Error('Catastrophic failure');
});
const report = createMockAnalysisReport({});
const result = await engine.executeImport(report, {});
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors[0]).toContain('Catastrophic failure');
});
});
describe('Phase 1: Tag Creation', () => {
it('should create new tags from analysis report', async () => {
const report = createMockAnalysisReport({
tags: [
{ name: 'NewTag', slug: 'newtag', existsInProject: false },
{ name: 'ExistingTag', slug: 'existingtag', existsInProject: true },
],
categories: [
{ name: 'NewCategory', slug: 'newcategory', existsInProject: false },
],
});
const result = await engine.executeImport(report, {});
// Should create only new tags (not existing ones)
expect(mockTagEngine.createTag).toHaveBeenCalledWith({ name: 'newtag' });
expect(mockTagEngine.createTag).toHaveBeenCalledWith({ name: 'newcategory' });
expect(mockTagEngine.createTag).toHaveBeenCalledTimes(2);
});
it('should not create tags that already exist', async () => {
const report = createMockAnalysisReport({
tags: [
{ name: 'ExistingTag', slug: 'existingtag', existsInProject: true },
],
});
await engine.executeImport(report, {});
expect(mockTagEngine.createTag).not.toHaveBeenCalled();
});
it('should use mapped tag names when creating tags', async () => {
const report = createMockAnalysisReport({
tags: [
{ name: 'OldName', slug: 'oldname', existsInProject: false, mappedTo: 'NewName' },
],
});
await engine.executeImport(report, {});
// Should NOT create the tag if it's mapped to an existing one
// The mappedTo value means it should use an existing tag
expect(mockTagEngine.createTag).not.toHaveBeenCalled();
});
it('should count skipped when tag creation fails (duplicate/race condition)', async () => {
mockTagEngine.createTag.mockRejectedValueOnce(new Error('Tag already exists'));
const report = createMockAnalysisReport({
tags: [
{ name: 'NewTag', slug: 'newtag', existsInProject: false },
],
});
const result = await engine.executeImport(report, {});
// Tag creation failed, should be counted as skipped not created
expect(result.tags.skipped).toBe(1);
expect(result.tags.created).toBe(0);
});
it('should count skipped when category creation fails', async () => {
mockTagEngine.createTag.mockRejectedValueOnce(new Error('Category already exists'));
const report = createMockAnalysisReport({
categories: [
{ name: 'NewCategory', slug: 'newcategory', existsInProject: false },
],
});
const result = await engine.executeImport(report, {});
expect(result.tags.skipped).toBe(1);
expect(result.tags.created).toBe(0);
});
});
describe('Phase 2: Post Import', () => {
it('should import new posts with correct dates from WXR', async () => {
const wxrPost = createMockWxrPost({
postDate: new Date('2024-01-15T10:00:00Z'),
postModified: new Date('2024-01-20T15:00:00Z'),
});
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPost, 'new')],
},
});
await engine.executeImport(report, { uploadsFolder: '/uploads' });
// Check that post was inserted into DB with correct dates
expect(insertedPosts.length).toBe(1);
expect(insertedPosts[0].title).toBe('Test Post');
expect(insertedPosts[0].slug).toBe('test-post');
expect(insertedPosts[0].createdAt).toEqual(new Date('2024-01-15T10:00:00Z'));
expect(insertedPosts[0].updatedAt).toEqual(new Date('2024-01-20T15:00:00Z'));
});
it('should skip posts with conflict resolution "ignore"', async () => {
const wxrPost = createMockWxrPost();
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 0,
updates: 0,
conflicts: 1,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPost, 'conflict', 'ignore')],
},
});
const result = await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(0);
expect(result.posts.skipped).toBe(1);
});
it('should create draft for conflict resolution "overwrite"', async () => {
const wxrPost = createMockWxrPost();
const analyzed = createMockAnalyzedPost(wxrPost, 'conflict', 'overwrite');
analyzed.existingPost = {
id: 'existing-post-id',
title: 'Existing Post',
slug: 'test-post',
checksum: 'old-checksum',
pubDate: null,
excerpt: null,
author: null,
tags: [],
categories: [],
};
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 0,
updates: 0,
conflicts: 1,
contentDuplicates: 0,
items: [analyzed],
},
});
await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1);
expect(insertedPosts[0].slug).toBe('test-post');
expect(insertedPosts[0].status).toBe('draft');
});
it('should create new post with new slug for conflict resolution "import"', async () => {
const wxrPost = createMockWxrPost();
const analyzed = createMockAnalyzedPost(wxrPost, 'conflict', 'import');
analyzed.existingPost = {
id: 'existing-post-id',
title: 'Existing Post',
slug: 'test-post',
checksum: 'old-checksum',
pubDate: null,
excerpt: null,
author: null,
tags: [],
categories: [],
};
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 0,
updates: 0,
conflicts: 1,
contentDuplicates: 0,
items: [analyzed],
},
});
await engine.executeImport(report, {});
expect(mockPostEngine.generateUniqueSlug).toHaveBeenCalled();
expect(insertedPosts.length).toBe(1);
});
it('should skip posts with status "content-duplicate"', async () => {
const wxrPost = createMockWxrPost();
const analyzed = createMockAnalyzedPost(wxrPost, 'content-duplicate');
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 0,
updates: 0,
conflicts: 0,
contentDuplicates: 1,
items: [analyzed],
},
});
const result = await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(0);
expect(result.posts.skipped).toBe(1);
expect(result.posts.imported).toBe(0);
});
it('should skip posts with status "update"', async () => {
const wxrPost = createMockWxrPost();
const analyzed = createMockAnalyzedPost(wxrPost, 'update');
analyzed.existingPost = {
id: 'existing-post-id',
title: 'Existing Post',
slug: 'test-post',
checksum: 'same-content-checksum',
pubDate: null,
excerpt: null,
author: null,
tags: [],
categories: [],
};
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 0,
updates: 1,
conflicts: 0,
contentDuplicates: 0,
items: [analyzed],
},
});
const result = await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(0);
expect(result.posts.skipped).toBe(1);
});
it('should handle empty content gracefully', async () => {
const wxrPost = createMockWxrPost({
content: '',
});
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPost, 'new')],
},
});
await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1);
// Empty content should be preserved as empty string
});
it('should handle whitespace-only content gracefully', async () => {
const wxrPost = createMockWxrPost({
content: ' \n\t ',
});
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPost, 'new')],
},
});
await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1);
});
it('should include excerpt in post metadata when present', async () => {
const wxrPost = createMockWxrPost({
excerpt: 'This is a post excerpt',
});
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPost, 'new')],
},
});
await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1);
expect(insertedPosts[0].excerpt).toBe('This is a post excerpt');
});
it('should include author in post metadata when present', async () => {
const wxrPost = createMockWxrPost({
creator: 'johndoe',
});
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPost, 'new')],
},
});
await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1);
expect(insertedPosts[0].author).toBe('johndoe');
});
it('should include publishedAt for published posts', async () => {
const pubDate = new Date('2024-01-15T10:00:00Z');
const wxrPost = createMockWxrPost({
pubDate,
});
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPost, 'new')],
},
});
await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1);
expect(insertedPosts[0].publishedAt).toEqual(pubDate);
});
it('should not set publishedAt for draft posts', async () => {
const wxrPost = createMockWxrPost();
const analyzed = createMockAnalyzedPost(wxrPost, 'conflict', 'overwrite');
analyzed.existingPost = {
id: 'existing-post-id',
title: 'Existing Post',
slug: 'test-post',
checksum: 'old-checksum',
pubDate: null,
excerpt: null,
author: null,
tags: [],
categories: [],
};
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 0,
updates: 0,
conflicts: 1,
contentDuplicates: 0,
items: [analyzed],
},
});
await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1);
expect(insertedPosts[0].status).toBe('draft');
expect(insertedPosts[0].publishedAt).toBeUndefined();
});
it('should handle post without optional fields', async () => {
const wxrPost = createMockWxrPost({
excerpt: '',
creator: '',
pubDate: undefined,
postDate: undefined,
postModified: undefined,
});
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPost, 'new')],
},
});
await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1);
// Should use current date as fallback
expect(insertedPosts[0].createdAt).toBeInstanceOf(Date);
});
it('should handle dates that are ISO strings (from JSON serialization through IPC)', async () => {
// After JSON.parse, dates become strings like "2024-01-15T10:00:00.000Z"
const wxrPost = createMockWxrPost({
postDate: '2023-06-20T08:30:00.000Z' as any,
postModified: '2023-07-01T12:00:00.000Z' as any,
pubDate: '2023-06-20T08:30:00.000Z' as any,
status: 'publish',
});
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPost, 'new')],
},
});
await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1);
expect(insertedPosts[0].createdAt).toBeInstanceOf(Date);
expect(insertedPosts[0].createdAt).toEqual(new Date('2023-06-20T08:30:00.000Z'));
expect(insertedPosts[0].updatedAt).toBeInstanceOf(Date);
expect(insertedPosts[0].updatedAt).toEqual(new Date('2023-07-01T12:00:00.000Z'));
expect(insertedPosts[0].publishedAt).toBeInstanceOf(Date);
expect(insertedPosts[0].publishedAt).toEqual(new Date('2023-06-20T08:30:00.000Z'));
});
it('should handle invalid date strings gracefully', async () => {
const wxrPost = createMockWxrPost({
postDate: 'not-a-date' as any,
postModified: '' as any,
pubDate: undefined,
});
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPost, 'new')],
},
});
await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1);
// Invalid dates should fall back to current date
expect(insertedPosts[0].createdAt).toBeInstanceOf(Date);
});
it('should record error when post import fails', async () => {
const wxrPost = createMockWxrPost();
// Make the database insert throw an error
const originalInsert = mockDb.insert;
mockDb.insert = vi.fn(() => ({
values: vi.fn().mockRejectedValue(new Error('Database error')),
}));
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPost, 'new')],
},
});
const result = await engine.executeImport(report, {});
expect(result.posts.errors).toBe(1);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors[0]).toContain('Failed to import post');
// Restore
mockDb.insert = originalInsert;
});
it('should transform WordPress shortcodes to double bracket format', async () => {
const wxrPost = createMockWxrPost({
content: '<p>Check out [gallery ids="1,2"] and [video src="test.mp4"]</p>',
});
// Create as conflict with overwrite resolution to get a draft (content in DB)
const analyzed = createMockAnalyzedPost(wxrPost, 'conflict', 'overwrite');
analyzed.existingPost = {
id: 'existing-post-id',
title: 'Existing Post',
slug: 'test-post',
checksum: 'old-checksum',
pubDate: null,
excerpt: null,
author: null,
tags: [],
categories: [],
};
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 0,
updates: 0,
conflicts: 1,
contentDuplicates: 0,
items: [analyzed],
},
});
await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1);
// Draft posts store content in DB
expect(insertedPosts[0].content).toContain('[[gallery ids="1,2"]]');
expect(insertedPosts[0].content).toContain('[[video src="test.mp4"]]');
});
it('should not escape underscores inside macro names during markdown conversion', async () => {
const wxrPost = createMockWxrPost({
content: '<p>Here is a photo archive: [photo_archive]</p>',
});
const analyzed = createMockAnalyzedPost(wxrPost, 'conflict', 'overwrite');
analyzed.existingPost = {
id: 'existing-post-id',
title: 'Existing Post',
slug: 'test-post',
checksum: 'old-checksum',
pubDate: null,
excerpt: null,
author: null,
tags: [],
categories: [],
};
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 0,
updates: 0,
conflicts: 1,
contentDuplicates: 0,
items: [analyzed],
},
});
await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1);
expect(insertedPosts[0].content).toContain('[[photo_archive]]');
expect(insertedPosts[0].content).not.toContain('photo\\_archive');
});
it('should not escape underscores in macro attributes during markdown conversion', async () => {
const wxrPost = createMockWxrPost({
content: '<p>Show: [my_gallery type="grid_view" size="large_thumb"]</p>',
});
const analyzed = createMockAnalyzedPost(wxrPost, 'conflict', 'overwrite');
analyzed.existingPost = {
id: 'existing-post-id',
title: 'Existing Post',
slug: 'test-post',
checksum: 'old-checksum',
pubDate: null,
excerpt: null,
author: null,
tags: [],
categories: [],
};
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 0,
updates: 0,
conflicts: 1,
contentDuplicates: 0,
items: [analyzed],
},
});
await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1);
expect(insertedPosts[0].content).toContain('[[my_gallery type="grid_view" size="large_thumb"]]');
});
it('should map tags based on analysis mappings', async () => {
const wxrPost = createMockWxrPost({
tags: ['OldTagName', 'NewTag'],
categories: ['MappedCategory'],
});
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPost, 'new')],
},
tags: [
{ name: 'OldTagName', slug: 'oldtagname', existsInProject: false, mappedTo: 'ExistingTag' },
{ name: 'NewTag', slug: 'newtag', existsInProject: false },
],
categories: [
{ name: 'MappedCategory', slug: 'mappedcategory', existsInProject: false, mappedTo: 'TargetCategory' },
],
});
await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1);
const tags = JSON.parse(insertedPosts[0].tags);
const categories = JSON.parse(insertedPosts[0].categories);
expect(tags).toContain('existingtag');
expect(tags).toContain('newtag');
expect(categories).toContain('targetcategory');
});
it('should build wpId to postId mapping for media phase', async () => {
const wxrPost = createMockWxrPost({ wpId: 42 });
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPost, 'new')],
},
});
const result = await engine.executeImport(report, {});
expect(result.wpIdToPostId.get(42)).toBeDefined();
});
});
describe('Phase 3: Media Import', () => {
it('should import new media files', async () => {
const wxrMedia = createMockWxrMedia();
const report = createMockAnalysisReport({
media: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
missing: 0,
items: [createMockAnalyzedMedia(wxrMedia, 'new')],
},
});
await engine.executeImport(report, { uploadsFolder: '/uploads' });
expect(mockMediaEngine.importMedia).toHaveBeenCalled();
});
it('should pass createdAt and updatedAt from WXR pubDate to MediaEngine', async () => {
const pubDate = new Date('2020-03-15T14:30:00Z');
const wxrMedia = createMockWxrMedia({ pubDate });
const report = createMockAnalysisReport({
media: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
missing: 0,
items: [createMockAnalyzedMedia(wxrMedia, 'new')],
},
});
await engine.executeImport(report, { uploadsFolder: '/uploads' });
expect(mockMediaEngine.importMedia).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
createdAt: pubDate,
updatedAt: pubDate,
})
);
});
it('should handle media pubDate as string (from JSON serialization)', async () => {
const wxrMedia = createMockWxrMedia({
pubDate: '2019-11-25T09:00:00.000Z' as any,
});
const report = createMockAnalysisReport({
media: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
missing: 0,
items: [createMockAnalyzedMedia(wxrMedia, 'new')],
},
});
await engine.executeImport(report, { uploadsFolder: '/uploads' });
expect(mockMediaEngine.importMedia).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
createdAt: new Date('2019-11-25T09:00:00.000Z'),
updatedAt: new Date('2019-11-25T09:00:00.000Z'),
})
);
});
it('should set title from WXR title', async () => {
const wxrMedia = createMockWxrMedia({ title: 'Beautiful Sunset' });
const report = createMockAnalysisReport({
media: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
missing: 0,
items: [createMockAnalyzedMedia(wxrMedia, 'new')],
},
});
await engine.executeImport(report, { uploadsFolder: '/uploads' });
expect(mockMediaEngine.importMedia).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
title: 'Beautiful Sunset',
})
);
});
it('should link media to parent post using wpId mapping', async () => {
// First import a post
const wxrPost = createMockWxrPost({ wpId: 42 });
const wxrMedia = createMockWxrMedia({ parentId: 42 });
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPost, 'new')],
},
media: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
missing: 0,
items: [createMockAnalyzedMedia(wxrMedia, 'new')],
},
});
await engine.executeImport(report, { uploadsFolder: '/uploads' });
expect(mockMediaEngine.importMedia).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
linkedPostIds: expect.any(Array),
})
);
});
it('should skip missing media files', async () => {
const wxrMedia = createMockWxrMedia();
const report = createMockAnalysisReport({
media: {
total: 1,
new: 0,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
missing: 1,
items: [createMockAnalyzedMedia(wxrMedia, 'missing')],
},
});
const result = await engine.executeImport(report, { uploadsFolder: '/uploads' });
expect(mockMediaEngine.importMedia).not.toHaveBeenCalled();
expect(result.media.skipped).toBe(1);
});
it('should skip media with conflict resolution "ignore"', async () => {
const wxrMedia = createMockWxrMedia();
const analyzed = createMockAnalyzedMedia(wxrMedia, 'conflict');
(analyzed as any).conflictResolution = 'ignore';
const report = createMockAnalysisReport({
media: {
total: 1,
new: 0,
updates: 0,
conflicts: 1,
contentDuplicates: 0,
missing: 0,
items: [analyzed],
},
});
const result = await engine.executeImport(report, { uploadsFolder: '/uploads' });
expect(mockMediaEngine.importMedia).not.toHaveBeenCalled();
expect(result.media.skipped).toBe(1);
});
it('should skip media with status "content-duplicate"', async () => {
const wxrMedia = createMockWxrMedia();
const analyzed = createMockAnalyzedMedia(wxrMedia, 'content-duplicate');
const report = createMockAnalysisReport({
media: {
total: 1,
new: 0,
updates: 0,
conflicts: 0,
contentDuplicates: 1,
missing: 0,
items: [analyzed],
},
});
const result = await engine.executeImport(report, { uploadsFolder: '/uploads' });
expect(mockMediaEngine.importMedia).not.toHaveBeenCalled();
expect(result.media.skipped).toBe(1);
});
it('should skip media with status "update"', async () => {
const wxrMedia = createMockWxrMedia();
const analyzed = createMockAnalyzedMedia(wxrMedia, 'update');
const report = createMockAnalysisReport({
media: {
total: 1,
new: 0,
updates: 1,
conflicts: 0,
contentDuplicates: 0,
missing: 0,
items: [analyzed],
},
});
const result = await engine.executeImport(report, { uploadsFolder: '/uploads' });
expect(mockMediaEngine.importMedia).not.toHaveBeenCalled();
expect(result.media.skipped).toBe(1);
});
it('should skip media when uploadsFolder is not provided', async () => {
const wxrMedia = createMockWxrMedia();
const report = createMockAnalysisReport({
media: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
missing: 0,
items: [createMockAnalyzedMedia(wxrMedia, 'new')],
},
});
const result = await engine.executeImport(report, {}); // No uploadsFolder
expect(mockMediaEngine.importMedia).not.toHaveBeenCalled();
expect(result.media.skipped).toBe(1);
});
it('should skip media when source file does not exist', async () => {
const { access } = await import('fs/promises');
vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT'));
const wxrMedia = createMockWxrMedia();
const report = createMockAnalysisReport({
media: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
missing: 0,
items: [createMockAnalyzedMedia(wxrMedia, 'new')],
},
});
const result = await engine.executeImport(report, { uploadsFolder: '/uploads' });
expect(mockMediaEngine.importMedia).not.toHaveBeenCalled();
expect(result.media.skipped).toBe(1);
});
it('should import media with conflict resolution "overwrite"', async () => {
const wxrMedia = createMockWxrMedia();
const analyzed = createMockAnalyzedMedia(wxrMedia, 'conflict');
(analyzed as any).conflictResolution = 'overwrite';
const report = createMockAnalysisReport({
media: {
total: 1,
new: 0,
updates: 0,
conflicts: 1,
contentDuplicates: 0,
missing: 0,
items: [analyzed],
},
});
await engine.executeImport(report, { uploadsFolder: '/uploads' });
expect(mockMediaEngine.importMedia).toHaveBeenCalled();
});
it('should import media with conflict resolution "import"', async () => {
const wxrMedia = createMockWxrMedia();
const analyzed = createMockAnalyzedMedia(wxrMedia, 'conflict');
(analyzed as any).conflictResolution = 'import';
const report = createMockAnalysisReport({
media: {
total: 1,
new: 0,
updates: 0,
conflicts: 1,
contentDuplicates: 0,
missing: 0,
items: [analyzed],
},
});
await engine.executeImport(report, { uploadsFolder: '/uploads' });
expect(mockMediaEngine.importMedia).toHaveBeenCalled();
});
it('should set alt text from WXR description', async () => {
const wxrMedia = createMockWxrMedia({ description: 'Alt text for image' });
const report = createMockAnalysisReport({
media: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
missing: 0,
items: [createMockAnalyzedMedia(wxrMedia, 'new')],
},
});
await engine.executeImport(report, { uploadsFolder: '/uploads' });
expect(mockMediaEngine.importMedia).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
alt: 'Alt text for image',
})
);
});
it('should handle media with no parent post', async () => {
const wxrMedia = createMockWxrMedia({ parentId: 0 });
const report = createMockAnalysisReport({
media: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
missing: 0,
items: [createMockAnalyzedMedia(wxrMedia, 'new')],
},
});
await engine.executeImport(report, { uploadsFolder: '/uploads' });
expect(mockMediaEngine.importMedia).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
linkedPostIds: [],
})
);
});
it('should record error when media import fails', async () => {
mockMediaEngine.importMedia.mockRejectedValueOnce(new Error('Media import failed'));
const wxrMedia = createMockWxrMedia();
const report = createMockAnalysisReport({
media: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
missing: 0,
items: [createMockAnalyzedMedia(wxrMedia, 'new')],
},
});
const result = await engine.executeImport(report, { uploadsFolder: '/uploads' });
expect(result.media.errors).toBe(1);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors[0]).toContain('Failed to import media');
});
});
describe('Phase 4: Page Import', () => {
it('should import pages with "page" category', async () => {
const wxrPage = createMockWxrPost({
postType: 'page',
categories: [],
});
const report = createMockAnalysisReport({
pages: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPage, 'new')],
},
});
await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1);
const categories = JSON.parse(insertedPosts[0].categories);
expect(categories).toContain('page');
});
it('should preserve existing page categories and add "page"', async () => {
const wxrPage = createMockWxrPost({
postType: 'page',
categories: ['Documentation', 'Help'],
});
const report = createMockAnalysisReport({
pages: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPage, 'new')],
},
categories: [
{ name: 'Documentation', slug: 'documentation', existsInProject: false },
{ name: 'Help', slug: 'help', existsInProject: false },
],
});
await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1);
const categories = JSON.parse(insertedPosts[0].categories);
expect(categories).toContain('page');
expect(categories).toContain('documentation');
expect(categories).toContain('help');
});
it('should use existing page category mapping when defined in report', async () => {
const wxrPage = createMockWxrPost({
postType: 'page',
categories: [],
});
const report = createMockAnalysisReport({
pages: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPage, 'new')],
},
categories: [
// Page is already defined in the category mapping
{ name: 'page', slug: 'page', existsInProject: true },
],
});
await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1);
const categories = JSON.parse(insertedPosts[0].categories);
expect(categories).toContain('page');
});
it('should skip pages with status "content-duplicate"', async () => {
const wxrPage = createMockWxrPost({
postType: 'page',
categories: [],
});
const report = createMockAnalysisReport({
pages: {
total: 1,
new: 0,
updates: 0,
conflicts: 0,
contentDuplicates: 1,
items: [createMockAnalyzedPost(wxrPage, 'content-duplicate')],
},
});
const result = await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(0);
expect(result.pages.skipped).toBe(1);
});
it('should skip pages with status "update"', async () => {
const wxrPage = createMockWxrPost({
postType: 'page',
categories: [],
});
const analyzed = createMockAnalyzedPost(wxrPage, 'update');
analyzed.existingPost = {
id: 'existing-page-id',
title: 'Existing Page',
slug: 'test-page',
checksum: 'same-content-checksum',
pubDate: null,
excerpt: null,
author: null,
tags: [],
categories: [],
};
const report = createMockAnalysisReport({
pages: {
total: 1,
new: 0,
updates: 1,
conflicts: 0,
contentDuplicates: 0,
items: [analyzed],
},
});
const result = await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(0);
expect(result.pages.skipped).toBe(1);
});
it('should handle page conflict with "overwrite" resolution', async () => {
const wxrPage = createMockWxrPost({
postType: 'page',
categories: [],
});
const analyzed = createMockAnalyzedPost(wxrPage, 'conflict', 'overwrite');
analyzed.existingPost = {
id: 'existing-page-id',
title: 'Existing Page',
slug: 'test-post',
checksum: 'old-checksum',
pubDate: null,
excerpt: null,
author: null,
tags: [],
categories: [],
};
const report = createMockAnalysisReport({
pages: {
total: 1,
new: 0,
updates: 0,
conflicts: 1,
contentDuplicates: 0,
items: [analyzed],
},
});
await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1);
expect(insertedPosts[0].status).toBe('draft');
const categories = JSON.parse(insertedPosts[0].categories);
expect(categories).toContain('page');
});
it('should handle page conflict with "import" resolution', async () => {
const wxrPage = createMockWxrPost({
postType: 'page',
categories: [],
});
const analyzed = createMockAnalyzedPost(wxrPage, 'conflict', 'import');
analyzed.existingPost = {
id: 'existing-page-id',
title: 'Existing Page',
slug: 'test-post',
checksum: 'old-checksum',
pubDate: null,
excerpt: null,
author: null,
tags: [],
categories: [],
};
const report = createMockAnalysisReport({
pages: {
total: 1,
new: 0,
updates: 0,
conflicts: 1,
contentDuplicates: 0,
items: [analyzed],
},
});
await engine.executeImport(report, {});
expect(mockPostEngine.generateUniqueSlug).toHaveBeenCalled();
expect(insertedPosts.length).toBe(1);
const categories = JSON.parse(insertedPosts[0].categories);
expect(categories).toContain('page');
});
it('should record error when page import fails', async () => {
const wxrPage = createMockWxrPost({
postType: 'page',
categories: [],
});
// Make the database insert throw an error
const originalInsert = mockDb.insert;
mockDb.insert = vi.fn(() => ({
values: vi.fn().mockRejectedValue(new Error('Database error')),
}));
const report = createMockAnalysisReport({
pages: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPage, 'new')],
},
});
const result = await engine.executeImport(report, {});
expect(result.pages.errors).toBe(1);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors[0]).toContain('Failed to import page');
// Restore
mockDb.insert = originalInsert;
});
});
describe('Progress Reporting', () => {
it('should call progress callback during import', async () => {
const progressCallback = vi.fn();
const wxrPost = createMockWxrPost();
const report = createMockAnalysisReport({
posts: {
total: 1,
new: 1,
updates: 0,
conflicts: 0,
contentDuplicates: 0,
items: [createMockAnalyzedPost(wxrPost, 'new')],
},
});
await engine.executeImport(report, { onProgress: progressCallback });
expect(progressCallback).toHaveBeenCalled();
});
});
describe('Result Summary', () => {
it('should return accurate counts in result', async () => {
const report = createMockAnalysisReport({
tags: [
{ name: 'NewTag', slug: 'newtag', existsInProject: false },
],
posts: {
total: 2,
new: 1,
updates: 0,
conflicts: 1,
contentDuplicates: 0,
items: [
createMockAnalyzedPost(createMockWxrPost({ wpId: 1 }), 'new'),
createMockAnalyzedPost(createMockWxrPost({ wpId: 2 }), 'conflict', 'ignore'),
],
},
});
const result = await engine.executeImport(report, {});
expect(result.tags.created).toBe(1);
expect(result.posts.imported).toBe(1);
expect(result.posts.skipped).toBe(1);
});
});
});