/** * TagEngine Unit Tests * * Tests the REAL TagEngine class with mocked dependencies. * Following TDD best practices: mock external dependencies, test real implementation. */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { TagEngine, TagData, TagWithCount, MergeTagsResult, DeleteTagResult } from '../../src/main/engine/TagEngine'; import { resetMockCounters } from '../utils/factories'; import { getDatabase } from '../../src/main/database'; // Create mock data stores const mockTags = new Map(); const mockPosts = new Map(); let mockExecuteArgs: any[] = []; // Configure what data the Drizzle select chain returns - supports queue for multiple calls let mockSelectDataQueue: any[][] = []; let mockSelectDataDefault: any[] = []; function getNextMockSelectData(): any[] { if (mockSelectDataQueue.length > 0) { return mockSelectDataQueue.shift()!; } if (mockSelectDataDefault.length > 0) { return mockSelectDataDefault; } return Array.from(mockTags.values()); } // Create chainable mock for Drizzle ORM that is thenable (can be awaited) function createSelectChain() { 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), // Make the chain thenable so it can be awaited directly then: (resolve: (value: any[]) => void, reject?: (reason: any) => void) => { return Promise.resolve(getNextMockSelectData()).then(resolve, reject); }, }; return chain; } function createDrizzleMock() { return { select: vi.fn(() => createSelectChain()), insert: vi.fn(() => ({ values: vi.fn((data: any) => { if (data && data.id) { mockTags.set(data.id, data); } return Promise.resolve(); }), })), update: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn(() => Promise.resolve()), })), })), delete: vi.fn(() => ({ where: vi.fn(() => Promise.resolve()), })), }; } const mockLocalDb = createDrizzleMock(); const mockLocalClient = { execute: vi.fn(async (query: { sql: string; args: any[] }) => { mockExecuteArgs.push(query); return { rows: [] }; }), }; // Mock the database module vi.mock('../../src/main/database', () => ({ getDatabase: vi.fn(() => ({ getLocal: vi.fn(() => mockLocalDb), getLocalClient: vi.fn(() => mockLocalClient), getRemote: vi.fn(() => null), getDataPaths: vi.fn(() => ({ database: '/mock/userData/bds.db', posts: '/mock/userData/posts', media: '/mock/userData/media', })), initializeLocal: vi.fn(), initializeRemote: vi.fn(), close: vi.fn(), })), })); // Mock fs/promises vi.mock('fs/promises', () => ({ readFile: vi.fn(async () => '[]'), writeFile: vi.fn(async () => {}), unlink: vi.fn(async () => {}), mkdir: vi.fn(async () => {}), readdir: vi.fn(async () => []), stat: vi.fn(async () => ({ isFile: () => false, isDirectory: () => true, })), access: vi.fn(async () => {}), })); // 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) => { // Execute the task for testing return task.execute((progress: number, message: string) => {}); }), }, })); describe('TagEngine', () => { let tagEngine: TagEngine; beforeEach(() => { vi.clearAllMocks(); mockTags.clear(); mockPosts.clear(); mockExecuteArgs = []; mockSelectDataQueue = []; mockSelectDataDefault = []; resetMockCounters(); tagEngine = new TagEngine(); }); afterEach(() => { vi.restoreAllMocks(); }); describe('constructor', () => { it('should create a new TagEngine instance', () => { expect(tagEngine).toBeDefined(); expect(tagEngine).toBeInstanceOf(TagEngine); }); }); describe('setProjectContext', () => { it('should set the current project ID', () => { tagEngine.setProjectContext('project-123'); expect(tagEngine.getProjectContext()).toBe('project-123'); }); }); describe('getTagsWithCounts', () => { it('should return an empty array when no tags exist', async () => { mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); const result = await tagEngine.getTagsWithCounts(); expect(result).toEqual([]); }); it('should return tags with their post counts', async () => { mockLocalClient.execute.mockResolvedValueOnce({ rows: [ { name: 'javascript', color: null, post_count: 5 }, { name: 'typescript', color: '#3178c6', post_count: 3 }, ], }); const result = await tagEngine.getTagsWithCounts(); expect(result).toHaveLength(2); expect(result[0]).toEqual({ name: 'javascript', color: null, count: 5 }); expect(result[1]).toEqual({ name: 'typescript', color: '#3178c6', count: 3 }); }); }); describe('createTag', () => { it('should create a new tag with a name', async () => { const result = await tagEngine.createTag({ name: 'react' }); expect(result).toBeDefined(); expect(result.name).toBe('react'); expect(result.id).toBeDefined(); }); it('should normalize tag name to lowercase', async () => { const result = await tagEngine.createTag({ name: 'React' }); expect(result.name).toBe('react'); }); it('should create a new tag with a color', async () => { const result = await tagEngine.createTag({ name: 'react', color: '#61dafb' }); expect(result.name).toBe('react'); expect(result.color).toBe('#61dafb'); }); it('should emit tagCreated event', async () => { const handler = vi.fn(); tagEngine.on('tagCreated', handler); await tagEngine.createTag({ name: 'vue' }); expect(handler).toHaveBeenCalledWith(expect.objectContaining({ name: 'vue' })); }); it('should throw error for empty tag name', async () => { await expect(tagEngine.createTag({ name: '' })).rejects.toThrow('Tag name is required'); }); it('should throw error for duplicate tag name', async () => { // Drizzle ORM: check for existing tag with same name mockSelectDataQueue = [[{ id: 'existing', name: 'react' }]]; await expect(tagEngine.createTag({ name: 'react' })).rejects.toThrow('Tag "react" already exists'); }); }); describe('updateTag', () => { it('should update tag color', async () => { mockSelectDataDefault = [{ id: 'tag-1', name: 'react', color: null, projectId: 'default', createdAt: new Date(), updatedAt: new Date() }]; const result = await tagEngine.updateTag('tag-1', { color: '#61dafb' }); expect(result).toBeDefined(); expect(result?.color).toBe('#61dafb'); }); it('should emit tagUpdated event', async () => { mockSelectDataDefault = [{ id: 'tag-1', name: 'react', color: null, projectId: 'default', createdAt: new Date(), updatedAt: new Date() }]; const handler = vi.fn(); tagEngine.on('tagUpdated', handler); await tagEngine.updateTag('tag-1', { color: '#61dafb' }); expect(handler).toHaveBeenCalled(); }); it('should return null for non-existent tag', async () => { mockSelectDataDefault = []; const result = await tagEngine.updateTag('non-existent', { color: '#fff' }); expect(result).toBeNull(); }); }); describe('deleteTag', () => { it('should delete tag and remove from posts as a background task', async () => { // Drizzle ORM: get tag first mockSelectDataQueue = [ [{ id: 'tag-1', name: 'react', color: null, projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], ]; // Raw SQL: find posts with tag mockLocalClient.execute.mockImplementationOnce(async () => ({ rows: [ { id: 'post-1', tags: '["react", "typescript"]' }, { id: 'post-2', tags: '["react"]' }, ], })); const result = await tagEngine.deleteTag('tag-1'); expect(result.success).toBe(true); expect(result.postsUpdated).toBe(2); }); it('should emit tagDeleted event', async () => { mockSelectDataQueue = [ [{ id: 'tag-1', name: 'react', color: null, projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], ]; mockLocalClient.execute.mockImplementationOnce(async () => ({ rows: [] })); const handler = vi.fn(); tagEngine.on('tagDeleted', handler); await tagEngine.deleteTag('tag-1'); expect(handler).toHaveBeenCalledWith('tag-1'); }); it('should throw error for non-existent tag', async () => { mockSelectDataDefault = []; await expect(tagEngine.deleteTag('non-existent')).rejects.toThrow('Tag not found'); }); }); describe('mergeTags', () => { it('should merge multiple tags into one', async () => { // Drizzle ORM selects: source tag 1, source tag 2, target tag mockSelectDataQueue = [ [{ id: 'tag-1', name: 'js', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], [{ id: 'tag-2', name: 'javascript', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], [{ id: 'tag-3', name: 'ecmascript', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], ]; // Raw SQL for finding posts with tags mockLocalClient.execute .mockResolvedValueOnce({ rows: [{ id: 'post-1', tags: '["js"]' }] }) // Posts with source tag 1 .mockResolvedValueOnce({ rows: [{ id: 'post-2', tags: '["javascript"]' }] }); // Posts with source tag 2 const result = await tagEngine.mergeTags(['tag-1', 'tag-2'], 'tag-3'); expect(result.success).toBe(true); expect(result.postsUpdated).toBeGreaterThanOrEqual(0); expect(result.tagsDeleted).toBe(2); }); it('should emit tagsMerged event', async () => { mockSelectDataQueue = [ [{ id: 'tag-1', name: 'js', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], [{ id: 'tag-2', name: 'javascript', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], ]; mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); // No posts with source tag const handler = vi.fn(); tagEngine.on('tagsMerged', handler); await tagEngine.mergeTags(['tag-1'], 'tag-2'); expect(handler).toHaveBeenCalled(); }); it('should throw error when source tags array is empty', async () => { await expect(tagEngine.mergeTags([], 'tag-1')).rejects.toThrow('Source tags are required'); }); it('should throw error when target tag does not exist', async () => { mockSelectDataQueue = [ [{ id: 'tag-1', name: 'js', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], [], // Target not found ]; await expect(tagEngine.mergeTags(['tag-1'], 'non-existent')).rejects.toThrow('Target tag not found'); }); }); describe('renameTags (batch rename)', () => { it('should rename multiple tags and update posts', async () => { // First call: get existing tag, Second call: check for duplicate mockSelectDataQueue = [ [{ id: 'tag-1', name: 'old-name', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], [], // no duplicate ]; // Raw SQL for finding posts with the tag mockLocalClient.execute.mockResolvedValueOnce({ rows: [{ id: 'post-1', tags: '["old-name"]' }] }); const result = await tagEngine.renameTag('tag-1', 'new-name'); expect(result.success).toBe(true); expect(result.postsUpdated).toBeGreaterThanOrEqual(0); }); it('should emit tagRenamed event', async () => { mockSelectDataQueue = [ [{ id: 'tag-1', name: 'old-name', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], [], // no duplicate ]; mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); // no posts to update const handler = vi.fn(); tagEngine.on('tagRenamed', handler); await tagEngine.renameTag('tag-1', 'new-name'); expect(handler).toHaveBeenCalledWith(expect.objectContaining({ oldName: 'old-name', newName: 'new-name', })); }); }); describe('getTag', () => { it('should return tag by ID', async () => { // Set up mock data for Drizzle select (camelCase properties) mockSelectDataDefault = [{ id: 'tag-1', name: 'react', color: '#61dafb', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }]; const result = await tagEngine.getTag('tag-1'); expect(result).toBeDefined(); expect(result?.name).toBe('react'); expect(result?.color).toBe('#61dafb'); }); it('should return null for non-existent tag', async () => { mockSelectDataDefault = []; const result = await tagEngine.getTag('non-existent'); expect(result).toBeNull(); }); }); describe('getTagByName', () => { it('should return tag by name', async () => { mockSelectDataDefault = [{ id: 'tag-1', name: 'react', color: '#61dafb', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }]; const result = await tagEngine.getTagByName('react'); expect(result).toBeDefined(); expect(result?.id).toBe('tag-1'); }); it('should be case-insensitive', async () => { mockSelectDataDefault = [{ id: 'tag-1', name: 'react', color: null, projectId: 'default', createdAt: new Date(), updatedAt: new Date() }]; const result = await tagEngine.getTagByName('REACT'); expect(result).toBeDefined(); }); }); describe('getAllTags', () => { it('should return all tags for the current project', async () => { mockSelectDataDefault = [ { id: 'tag-1', name: 'react', color: null, projectId: 'default', createdAt: new Date(), updatedAt: new Date() }, { id: 'tag-2', name: 'vue', color: '#42b883', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }, ]; const result = await tagEngine.getAllTags(); expect(result).toHaveLength(2); expect(result.map(t => t.name)).toContain('react'); expect(result.map(t => t.name)).toContain('vue'); }); }); describe('getPostsWithTag', () => { it('should return post IDs that have the specified tag', async () => { // First call: Drizzle ORM to get tag name from id mockSelectDataQueue = [[{ name: 'react' }]]; // Second call: raw SQL to find posts with this tag mockLocalClient.execute.mockImplementationOnce(async () => ({ rows: [ { id: 'post-1', tags: '["react", "typescript"]' }, { id: 'post-2', tags: '["react"]' }, ], })); const result = await tagEngine.getPostsWithTag('tag-1'); expect(result).toHaveLength(2); expect(result).toContain('post-1'); expect(result).toContain('post-2'); }); }); describe('color validation', () => { it('should accept valid hex color codes', async () => { const result = await tagEngine.createTag({ name: 'test', color: '#ff0000' }); expect(result.color).toBe('#ff0000'); }); it('should accept short hex color codes', async () => { const result = await tagEngine.createTag({ name: 'test', color: '#f00' }); expect(result.color).toBe('#f00'); }); it('should reject invalid color codes', async () => { await expect(tagEngine.createTag({ name: 'test', color: 'red' })) .rejects.toThrow('Invalid color format'); }); it('should allow null/undefined color', async () => { const result = await tagEngine.createTag({ name: 'test' }); expect(result.color).toBeUndefined(); }); }); describe('syncTagsFromPosts', () => { it('should discover tags from existing posts and add missing ones', async () => { // First call: get posts' tags, Second call: get existing tags mockSelectDataQueue = [ [{ tags: '["react", "javascript"]' }, { tags: '["vue", "typescript"]' }], [{ name: 'react' }], // existing tags ]; const result = await tagEngine.syncTagsFromPosts(); expect(result.discovered).toBeGreaterThanOrEqual(0); }); }); describe('loadTagsFromFile', () => { it('should load tags from filesystem in portable format', async () => { // Mock fs.readFile to return valid JSON const fs = await import('fs/promises'); vi.mocked(fs.readFile).mockResolvedValueOnce( JSON.stringify([ { name: 'tag1', color: '#ff0000' }, { name: 'tag2' }, ]) ); // Mock select to return empty (no existing tags) mockSelectDataQueue = [[], []]; await tagEngine.loadTagsFromFile(); // Verify insert was called for the new tags expect(mockLocalDb.insert).toHaveBeenCalled(); }); it('should handle ENOENT error gracefully (file not found)', async () => { const fs = await import('fs/promises'); const error = new Error('ENOENT'); (error as NodeJS.ErrnoException).code = 'ENOENT'; vi.mocked(fs.readFile).mockRejectedValueOnce(error); // Should not throw await expect(tagEngine.loadTagsFromFile()).resolves.toBeUndefined(); }); it('should log non-ENOENT errors when loading', async () => { const fs = await import('fs/promises'); const error = new Error('Permission denied'); (error as NodeJS.ErrnoException).code = 'EACCES'; vi.mocked(fs.readFile).mockRejectedValueOnce(error); // Should not throw, but should log error await expect(tagEngine.loadTagsFromFile()).resolves.toBeUndefined(); }); it('should skip tags with empty names', async () => { const fs = await import('fs/promises'); vi.mocked(fs.readFile).mockResolvedValueOnce( JSON.stringify([ { name: '' }, { name: ' ' }, { name: 'valid' }, ]) ); mockSelectDataQueue = [[]]; // no existing tags await tagEngine.loadTagsFromFile(); // Only 'valid' should be processed const insertedTags = mockLocalDb.insert.mock.calls; expect(insertedTags.length).toBeGreaterThan(0); }); it('should update color for existing tag when loading', async () => { const fs = await import('fs/promises'); vi.mocked(fs.readFile).mockResolvedValueOnce( JSON.stringify([ { name: 'existing-tag', color: '#ff0000' }, ]) ); // Existing tag found mockSelectDataQueue = [[{ id: 'tag-1' }]]; await tagEngine.loadTagsFromFile(); // Should update existing tag with color expect(mockLocalDb.update).toHaveBeenCalled(); }); }); describe('renameTag error cases', () => { it('should throw error when new name is empty', async () => { await expect(tagEngine.renameTag('tag-1', '')).rejects.toThrow('New name is required'); }); it('should throw error when new name is whitespace only', async () => { await expect(tagEngine.renameTag('tag-1', ' ')).rejects.toThrow('New name is required'); }); it('should throw error when tag not found', async () => { mockSelectDataDefault = []; await expect(tagEngine.renameTag('non-existent', 'new-name')).rejects.toThrow('Tag not found'); }); it('should return success with 0 posts updated when renaming to same name', async () => { mockSelectDataQueue = [ [{ id: 'tag-1', name: 'same-name', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], ]; const result = await tagEngine.renameTag('tag-1', 'same-name'); expect(result.success).toBe(true); expect(result.postsUpdated).toBe(0); expect(result.oldName).toBe('same-name'); expect(result.newName).toBe('same-name'); }); it('should throw error when target name already exists', async () => { mockSelectDataQueue = [ [{ id: 'tag-1', name: 'old-name', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], [{ id: 'tag-2' }], // duplicate found ]; await expect(tagEngine.renameTag('tag-1', 'existing-name')).rejects.toThrow('already exists'); }); }); describe('getPostsWithTag edge cases', () => { it('should return empty array when tag not found', async () => { mockSelectDataQueue = [[]]; // tag not found const result = await tagEngine.getPostsWithTag('non-existent-tag'); expect(result).toEqual([]); }); it('should return empty array when client is not available', async () => { // Store original mock implementation const originalMock = vi.mocked(getDatabase).getMockImplementation(); // Mock getClient to return null vi.mocked(getDatabase).mockReturnValue({ getLocal: vi.fn(() => mockLocalDb), getLocalClient: vi.fn().mockReturnValue(null), } as any); const result = await tagEngine.getPostsWithTag('tag-1'); expect(result).toEqual([]); // Restore original mock if (originalMock) { vi.mocked(getDatabase).mockImplementation(originalMock); } else { // Restore to standard mock vi.mocked(getDatabase).mockReturnValue({ 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(), } as any); } }); }); });