/** * MetadataDiffEngine Unit Tests * * Tests the REAL MetadataDiffEngine class with mocked dependencies. * Following TDD best practices: mock external dependencies, test real implementation. */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { MetadataDiffEngine, PostMetadataDiff, DiffGroup, DiffField } from '../../src/main/engine/MetadataDiffEngine'; import { resetMockCounters } from '../utils/factories'; // Mock posts data store - used for single-item .get() queries const mockPosts = new Map(); // Queue of posts for sequential .get() calls (used in scanAllPublishedPosts) let mockPostsGetQueue: any[] = []; let mockAllPostsRows: any[] = []; // Create chainable mock for Drizzle ORM function createSelectChain(data: any[] = []) { const chain: any = { from: vi.fn().mockImplementation(() => chain), where: vi.fn().mockImplementation(() => chain), orderBy: vi.fn().mockImplementation(() => chain), limit: vi.fn().mockImplementation(() => chain), offset: vi.fn().mockImplementation(() => chain), all: vi.fn().mockResolvedValue(data), get: vi.fn().mockImplementation(() => { // If there are queued posts, return from queue if (mockPostsGetQueue.length > 0) { return Promise.resolve(mockPostsGetQueue.shift()); } // Otherwise return from map return Promise.resolve(mockPosts.size > 0 ? Array.from(mockPosts.values())[0] : undefined); }), then: (resolve: (value: any[]) => void, reject?: (reason: any) => void) => { return Promise.resolve(data).then(resolve, reject); }, }; return chain; } function createDrizzleMock() { return { select: vi.fn(() => createSelectChain(mockAllPostsRows)), 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()), })), }; } const mockLocalDb = createDrizzleMock(); const mockLocalClient = { execute: vi.fn(async () => ({ rows: [] })), }; // Mock the database module vi.mock('../../src/main/database', () => ({ getDatabase: vi.fn(() => ({ getLocal: vi.fn(() => mockLocalDb), getLocalClient: vi.fn(() => mockLocalClient), getRemote: vi.fn(() => null), getDataPaths: vi.fn(() => ({ database: '/mock/userData/bds.db', posts: '/mock/userData/posts', media: '/mock/userData/media', })), initializeLocal: vi.fn(), initializeRemote: vi.fn(), close: vi.fn(), })), })); // Mock file contents for readPostFile const mockFileData = new Map(); // Mock fs/promises vi.mock('fs/promises', () => ({ readFile: vi.fn(async (path: string) => { const data = mockFileData.get(path); if (!data) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); return data; }), writeFile: vi.fn(async () => {}), unlink: vi.fn(async () => {}), mkdir: vi.fn(async () => {}), readdir: vi.fn(async () => []), access: vi.fn(async (path: string) => { if (!mockFileData.has(path)) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }), stat: vi.fn(async () => ({ isFile: () => true, isDirectory: () => false, })), })); // Mock gray-matter vi.mock('gray-matter', () => ({ default: vi.fn((content: string) => { // Simple mock that extracts frontmatter const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (!match) return { data: {}, content }; // Parse YAML-like frontmatter const frontmatter = match[1]; const body = match[2]; const data: any = {}; frontmatter.split('\n').forEach(line => { const colonIndex = line.indexOf(':'); if (colonIndex > 0) { const key = line.slice(0, colonIndex).trim(); let value = line.slice(colonIndex + 1).trim(); // Parse arrays if (value.startsWith('[') && value.endsWith(']')) { value = JSON.parse(value.replace(/'/g, '"')); } // Parse strings else if (value.startsWith('"') && value.endsWith('"')) { value = value.slice(1, -1); } data[key] = value; } }); return { data, content: body }; }), })); // Mock electron app vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/mock/userData'), }, })); // Mock TaskManager vi.mock('../../src/main/engine/TaskManager', () => ({ taskManager: { runTask: vi.fn(async (task: any) => { return task.execute((progress: number, message: string) => {}); }), }, })); // Track the mock function for PostEngine.syncPublishedPostFile const mockSyncPublishedPostFile = vi.fn(async () => true); // Mock PostEngine vi.mock('../../src/main/engine/PostEngine', () => ({ getPostEngine: vi.fn(() => ({ syncPublishedPostFile: mockSyncPublishedPostFile, })), })); describe('MetadataDiffEngine', () => { let engine: MetadataDiffEngine; beforeEach(() => { vi.clearAllMocks(); mockPosts.clear(); mockPostsGetQueue = []; mockFileData.clear(); mockAllPostsRows = []; mockSyncPublishedPostFile.mockClear(); resetMockCounters(); engine = new MetadataDiffEngine({ syncPublishedPostFile: mockSyncPublishedPostFile } as any); engine.setProjectContext('test-project'); }); afterEach(() => { vi.restoreAllMocks(); }); describe('constructor', () => { it('should create a new MetadataDiffEngine instance', () => { expect(engine).toBeDefined(); expect(engine).toBeInstanceOf(MetadataDiffEngine); }); }); describe('setProjectContext', () => { it('should set the current project ID', () => { engine.setProjectContext('project-123'); expect(engine.getProjectContext()).toBe('project-123'); }); }); describe('comparePostMetadata', () => { it('should return null for draft posts (no file)', async () => { // Set up a draft post in database const dbPost = { id: 'post-1', projectId: 'test-project', title: 'Draft Post', slug: 'draft-post', status: 'draft', filePath: null, tags: '["tag1"]', categories: '["cat1"]', createdAt: new Date(), updatedAt: new Date(), }; mockPosts.set('post-1', dbPost); const result = await engine.comparePostMetadata('post-1'); expect(result).toBeNull(); }); it('should detect tag differences between DB and file', async () => { const dbPost = { id: 'post-1', projectId: 'test-project', title: 'Published Post', slug: 'published-post', status: 'published', filePath: '/mock/userData/posts/2024/01/published-post.md', tags: '["tag1", "tag2"]', categories: '["cat1"]', createdAt: new Date('2024-01-15'), updatedAt: new Date('2024-01-15'), publishedAt: new Date('2024-01-15'), }; // DB has tag2 but file doesn't mockPosts.set('post-1', dbPost); // File has different tags mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `--- id: post-1 projectId: test-project title: "Published Post" slug: published-post status: published tags: ["tag1", "old-tag"] categories: ["cat1"] createdAt: 2024-01-15T00:00:00.000Z updatedAt: 2024-01-15T00:00:00.000Z publishedAt: 2024-01-15T00:00:00.000Z --- Content here`); const result = await engine.comparePostMetadata('post-1'); expect(result).not.toBeNull(); expect(result?.hasDifferences).toBe(true); expect(result?.differences.tags).toBeDefined(); expect(result?.differences.tags?.dbValue).toEqual(['tag1', 'tag2']); expect(result?.differences.tags?.fileValue).toEqual(['tag1', 'old-tag']); }); it('should detect category differences between DB and file', async () => { const dbPost = { id: 'post-1', projectId: 'test-project', title: 'Published Post', slug: 'published-post', status: 'published', filePath: '/mock/userData/posts/2024/01/published-post.md', tags: '[]', categories: '["cat1", "cat2"]', createdAt: new Date('2024-01-15'), updatedAt: new Date('2024-01-15'), publishedAt: new Date('2024-01-15'), }; mockPosts.set('post-1', dbPost); mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `--- id: post-1 projectId: test-project title: "Published Post" slug: published-post status: published tags: [] categories: ["cat1"] createdAt: 2024-01-15T00:00:00.000Z updatedAt: 2024-01-15T00:00:00.000Z publishedAt: 2024-01-15T00:00:00.000Z --- Content here`); const result = await engine.comparePostMetadata('post-1'); expect(result).not.toBeNull(); expect(result?.hasDifferences).toBe(true); expect(result?.differences.categories).toBeDefined(); expect(result?.differences.categories?.dbValue).toEqual(['cat1', 'cat2']); expect(result?.differences.categories?.fileValue).toEqual(['cat1']); }); it('should detect language differences between DB and file', async () => { const dbPost = { id: 'post-1', projectId: 'test-project', title: 'Published Post', slug: 'published-post', status: 'published', filePath: '/mock/userData/posts/2024/01/published-post.md', tags: '[]', categories: '[]', language: 'en', createdAt: new Date('2024-01-15'), updatedAt: new Date('2024-01-15'), publishedAt: new Date('2024-01-15'), }; mockPosts.set('post-1', dbPost); mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `--- id: post-1 projectId: test-project title: "Published Post" slug: published-post status: published tags: [] categories: [] language: fr createdAt: 2024-01-15T00:00:00.000Z updatedAt: 2024-01-15T00:00:00.000Z publishedAt: 2024-01-15T00:00:00.000Z --- Content here`); const result = await engine.comparePostMetadata('post-1'); expect(result).not.toBeNull(); expect(result?.hasDifferences).toBe(true); expect(result?.differences.language).toBeDefined(); expect(result?.differences.language?.dbValue).toBe('en'); expect(result?.differences.language?.fileValue).toBe('fr'); }); it('should detect missing language in file when DB has language', async () => { const dbPost = { id: 'post-1', projectId: 'test-project', title: 'Published Post', slug: 'published-post', status: 'published', filePath: '/mock/userData/posts/2024/01/published-post.md', tags: '[]', categories: '[]', language: 'de', createdAt: new Date('2024-01-15'), updatedAt: new Date('2024-01-15'), publishedAt: new Date('2024-01-15'), }; mockPosts.set('post-1', dbPost); mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `--- id: post-1 projectId: test-project title: "Published Post" slug: published-post status: published tags: [] categories: [] createdAt: 2024-01-15T00:00:00.000Z updatedAt: 2024-01-15T00:00:00.000Z publishedAt: 2024-01-15T00:00:00.000Z --- Content here`); const result = await engine.comparePostMetadata('post-1'); expect(result).not.toBeNull(); expect(result?.hasDifferences).toBe(true); expect(result?.differences.language).toBeDefined(); expect(result?.differences.language?.dbValue).toBe('de'); expect(result?.differences.language?.fileValue).toBe(''); }); it('should return hasDifferences=false when metadata matches', async () => { const dbPost = { id: 'post-1', projectId: 'test-project', title: 'Published Post', slug: 'published-post', status: 'published', filePath: '/mock/userData/posts/2024/01/published-post.md', tags: '["tag1"]', categories: '["cat1"]', createdAt: new Date('2024-01-15'), updatedAt: new Date('2024-01-15'), publishedAt: new Date('2024-01-15'), }; mockPosts.set('post-1', dbPost); mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `--- id: post-1 projectId: test-project title: "Published Post" slug: published-post status: published tags: ["tag1"] categories: ["cat1"] createdAt: 2024-01-15T00:00:00.000Z updatedAt: 2024-01-15T00:00:00.000Z publishedAt: 2024-01-15T00:00:00.000Z --- Content here`); const result = await engine.comparePostMetadata('post-1'); expect(result).not.toBeNull(); expect(result?.hasDifferences).toBe(false); }); }); describe('scanAllPublishedPosts', () => { it('should scan all published posts and return differences', async () => { // Mock the raw SQL query that returns published posts mockLocalClient.execute.mockResolvedValueOnce({ rows: [ { id: 'post-1', title: 'Post 1', slug: 'post-1', file_path: '/mock/userData/posts/2024/01/post-1.md', tags: '["new-tag"]', categories: '["cat1"]', excerpt: null, author: null, }, { id: 'post-2', title: 'Post 2', slug: 'post-2', file_path: '/mock/userData/posts/2024/01/post-2.md', tags: '["tag1"]', categories: '["cat1"]', excerpt: null, author: null, }, ], }); // Queue the posts for sequential .get() calls in comparePostMetadata mockPostsGetQueue = [ { id: 'post-1', projectId: 'test-project', title: 'Post 1', slug: 'post-1', status: 'published', filePath: '/mock/userData/posts/2024/01/post-1.md', tags: '["new-tag"]', categories: '["cat1"]', createdAt: new Date('2024-01-15'), updatedAt: new Date('2024-01-15'), }, { id: 'post-2', projectId: 'test-project', title: 'Post 2', slug: 'post-2', status: 'published', filePath: '/mock/userData/posts/2024/01/post-2.md', tags: '["tag1"]', categories: '["cat1"]', createdAt: new Date('2024-01-15'), updatedAt: new Date('2024-01-15'), }, ]; // Post 1 has tag difference mockFileData.set('/mock/userData/posts/2024/01/post-1.md', `--- id: post-1 projectId: test-project title: "Post 1" slug: post-1 status: published tags: ["old-tag"] categories: ["cat1"] createdAt: 2024-01-15T00:00:00.000Z updatedAt: 2024-01-15T00:00:00.000Z publishedAt: 2024-01-15T00:00:00.000Z --- Content`); // Post 2 matches mockFileData.set('/mock/userData/posts/2024/01/post-2.md', `--- id: post-2 projectId: test-project title: "Post 2" slug: post-2 status: published tags: ["tag1"] categories: ["cat1"] createdAt: 2024-01-15T00:00:00.000Z updatedAt: 2024-01-15T00:00:00.000Z publishedAt: 2024-01-15T00:00:00.000Z --- Content`); const result = await engine.scanAllPublishedPosts((current, total) => {}); expect(result.totalScanned).toBe(2); expect(result.postsWithDifferences).toBe(1); expect(result.differences.length).toBe(1); expect(result.differences[0].postId).toBe('post-1'); }); }); describe('groupDifferencesByField', () => { it('should group differences by field type', () => { const diffs: PostMetadataDiff[] = [ { postId: 'post-1', title: 'Post 1', slug: 'post-1', hasDifferences: true, differences: { tags: { dbValue: ['new-tag'], fileValue: ['old-tag'] }, }, }, { postId: 'post-2', title: 'Post 2', slug: 'post-2', hasDifferences: true, differences: { tags: { dbValue: ['tag1'], fileValue: ['tag2'] }, categories: { dbValue: ['cat1'], fileValue: ['cat2'] }, }, }, { postId: 'post-3', title: 'Post 3', slug: 'post-3', hasDifferences: true, differences: { categories: { dbValue: ['catA'], fileValue: ['catB'] }, }, }, ]; const groups = engine.groupDifferencesByField(diffs); expect(groups).toHaveLength(2); const tagsGroup = groups.find(g => g.field === 'tags'); expect(tagsGroup).toBeDefined(); expect(tagsGroup?.posts).toHaveLength(2); const categoriesGroup = groups.find(g => g.field === 'categories'); expect(categoriesGroup).toBeDefined(); expect(categoriesGroup?.posts).toHaveLength(2); }); }); describe('syncDbToFile', () => { it('should sync database metadata to file for given posts', async () => { const postIds = ['post-1', 'post-2']; // This will call syncPublishedPostFile for each post await engine.syncDbToFile(postIds); // PostEngine.syncPublishedPostFile should have been called twice expect(mockSyncPublishedPostFile).toHaveBeenCalledTimes(2); expect(mockSyncPublishedPostFile).toHaveBeenCalledWith('post-1'); expect(mockSyncPublishedPostFile).toHaveBeenCalledWith('post-2'); }); it('should report progress on first and final items based on cadence', async () => { const postIds = Array.from({ length: 11 }, (_, i) => `post-${i + 1}`); const onProgress = vi.fn(); await engine.syncDbToFile(postIds, onProgress); expect(onProgress).toHaveBeenCalledTimes(2); expect(onProgress).toHaveBeenNthCalledWith(1, 9, 'Synced 1 of 11 posts...'); expect(onProgress).toHaveBeenNthCalledWith(2, 100, 'Synced 11 of 11 posts...'); }); it('should keep processing and count failures when sync throws or returns false', async () => { mockSyncPublishedPostFile .mockResolvedValueOnce(true) .mockResolvedValueOnce(false) .mockRejectedValueOnce(new Error('sync failure')); const result = await engine.syncDbToFile(['post-1', 'post-2', 'post-3']); expect(result).toEqual({ success: 1, failed: 2 }); expect(mockSyncPublishedPostFile).toHaveBeenCalledTimes(3); }); }); describe('syncFileToDb', () => { it('should sync file metadata to database for given posts', async () => { const dbPost = { id: 'post-1', projectId: 'test-project', title: 'Published Post', slug: 'published-post', status: 'published', filePath: '/mock/userData/posts/2024/01/published-post.md', tags: '["db-tag"]', categories: '["db-cat"]', createdAt: new Date('2024-01-15'), updatedAt: new Date('2024-01-15'), publishedAt: new Date('2024-01-15'), }; mockPosts.set('post-1', dbPost); mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `--- id: post-1 projectId: test-project title: "Published Post" slug: published-post status: published tags: ["file-tag"] categories: ["file-cat"] createdAt: 2024-01-15T00:00:00.000Z updatedAt: 2024-01-15T00:00:00.000Z publishedAt: 2024-01-15T00:00:00.000Z --- Content here`); await engine.syncFileToDb(['post-1'], 'tags'); // Verify the database update was called expect(mockLocalDb.update).toHaveBeenCalled(); }); it('should sync language field from file to database', async () => { const dbPost = { id: 'post-1', projectId: 'test-project', title: 'Published Post', slug: 'published-post', status: 'published', filePath: '/mock/userData/posts/2024/01/published-post.md', tags: '[]', categories: '[]', language: 'en', createdAt: new Date('2024-01-15'), updatedAt: new Date('2024-01-15'), publishedAt: new Date('2024-01-15'), }; mockPosts.set('post-1', dbPost); mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `--- id: post-1 projectId: test-project title: "Published Post" slug: published-post status: published language: fr tags: [] categories: [] createdAt: 2024-01-15T00:00:00.000Z updatedAt: 2024-01-15T00:00:00.000Z publishedAt: 2024-01-15T00:00:00.000Z --- Content here`); await engine.syncFileToDb(['post-1'], 'language'); expect(mockLocalDb.update).toHaveBeenCalled(); // Verify the set call includes language const updateResult = mockLocalDb.update.mock.results[0].value; const setCall = updateResult.set.mock.calls[0][0]; expect(setCall.language).toBe('fr'); }); it('should report progress on first and final items based on cadence', async () => { const postIds = Array.from({ length: 11 }, (_, i) => `post-${i + 1}`); mockPostsGetQueue = postIds.map((postId) => ({ id: postId, projectId: 'test-project', title: `Post ${postId}`, slug: postId, status: 'published', filePath: `/mock/userData/posts/2024/01/${postId}.md`, tags: '[]', categories: '[]', createdAt: new Date('2024-01-15'), updatedAt: new Date('2024-01-15'), })); for (const postId of postIds) { mockFileData.set(`/mock/userData/posts/2024/01/${postId}.md`, `---\nid: ${postId}\nprojectId: test-project\ntitle: "${postId}"\nslug: ${postId}\nstatus: published\ntags: []\ncategories: []\n---\nContent`); } const onProgress = vi.fn(); await engine.syncFileToDb(postIds, undefined, onProgress); expect(onProgress).toHaveBeenCalledTimes(2); expect(onProgress).toHaveBeenNthCalledWith(1, 9, 'Synced 1 of 11 posts...'); expect(onProgress).toHaveBeenNthCalledWith(2, 100, 'Synced 11 of 11 posts...'); }); it('should continue after missing file path and file read failures', async () => { const postIds = ['post-1', 'post-2', 'post-3']; mockPostsGetQueue = [ { id: 'post-1', projectId: 'test-project', title: 'Post 1', slug: 'post-1', status: 'published', filePath: null, tags: '[]', categories: '[]', createdAt: new Date('2024-01-15'), updatedAt: new Date('2024-01-15'), }, { id: 'post-2', projectId: 'test-project', title: 'Post 2', slug: 'post-2', status: 'published', filePath: '/mock/userData/posts/2024/01/post-2.md', tags: '[]', categories: '[]', createdAt: new Date('2024-01-15'), updatedAt: new Date('2024-01-15'), }, { id: 'post-3', projectId: 'test-project', title: 'Post 3', slug: 'post-3', status: 'published', filePath: '/mock/userData/posts/2024/01/post-3.md', tags: '[]', categories: '[]', createdAt: new Date('2024-01-15'), updatedAt: new Date('2024-01-15'), }, ]; mockFileData.set('/mock/userData/posts/2024/01/post-3.md', `---\nid: post-3\nprojectId: test-project\ntitle: "Post 3"\nslug: post-3\nstatus: published\ntags: ["from-file"]\ncategories: []\n---\nContent`); const result = await engine.syncFileToDb(postIds, 'tags'); expect(result).toEqual({ success: 1, failed: 2 }); expect(mockLocalDb.update).toHaveBeenCalledTimes(1); }); }); });