Files
bDS/tests/engine/MetaEngine.test.ts
2026-02-11 10:37:59 +01:00

572 lines
19 KiB
TypeScript

/**
* MetaEngine Unit Tests
*
* Tests the REAL MetaEngine class with mocked dependencies.
* Following TDD best practices: mock external dependencies, test real implementation.
*
* MetaEngine manages project metadata like available tags and categories,
* keeping them in sync between the database (derived from posts) and
* filesystem (meta/tags.json, meta/categories.json).
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// Mock data stores
const mockFiles = new Map<string, string>();
const mockDirs = new Set<string>();
let mockPosts: any[] = [];
let mockProject: any = null;
// Mock fs/promises
vi.mock('fs/promises', () => ({
readFile: vi.fn(async (filePath: string) => {
if (mockFiles.has(filePath)) {
return mockFiles.get(filePath);
}
const err = new Error(`ENOENT: no such file or directory, open '${filePath}'`) as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
}),
writeFile: vi.fn(async (filePath: string, content: string) => {
mockFiles.set(filePath, content);
}),
mkdir: vi.fn(async (dirPath: string) => {
mockDirs.add(dirPath);
}),
access: vi.fn(async (filePath: string) => {
if (!mockFiles.has(filePath) && !mockDirs.has(filePath)) {
const err = new Error(`ENOENT: no such file or directory, access '${filePath}'`) as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
}
}),
}));
// Mock electron app
vi.mock('electron', () => ({
app: {
getPath: vi.fn(() => '/mock/userData'),
},
}));
// Create chainable mock for Drizzle ORM
let lastQueriedTable: string | null = null;
function createSelectChain() {
const chain: any = {
from: vi.fn().mockImplementation((table) => {
// Drizzle table objects have [Symbol.for('drizzle:Name')] or _.name
lastQueriedTable = table?.[Symbol.for('drizzle:Name')] || table?._?.name || null;
return chain;
}),
where: vi.fn().mockReturnThis(),
all: vi.fn().mockImplementation(() => Promise.resolve(mockPosts)),
get: vi.fn().mockImplementation(() => {
// Return project data if querying projects table
if (lastQueriedTable === 'projects') {
return Promise.resolve(mockProject);
}
return Promise.resolve(undefined);
}),
};
chain.where = vi.fn().mockReturnValue(chain);
return chain;
}
const mockLocalDb = {
select: vi.fn(() => createSelectChain()),
};
// Mock the database module
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => mockLocalDb),
})),
}));
// Import after mocks are set up
import { MetaEngine } from '../../src/main/engine/MetaEngine';
import * as fs from 'fs/promises';
describe('MetaEngine', () => {
let metaEngine: MetaEngine;
// Default project for tests that call syncOnStartup
const defaultMockProject = {
id: 'test-project',
name: 'Test Project',
description: 'A test project',
slug: 'test-project',
createdAt: new Date(),
updatedAt: new Date(),
isActive: true,
};
beforeEach(() => {
vi.clearAllMocks();
mockFiles.clear();
mockDirs.clear();
mockPosts = [];
mockProject = defaultMockProject; // Default to valid project
lastQueriedTable = null;
metaEngine = new MetaEngine();
metaEngine.setProjectContext('test-project');
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Project Context', () => {
it('should set and get project context', () => {
metaEngine.setProjectContext('my-project');
expect(metaEngine.getProjectContext()).toBe('my-project');
});
it('should return correct meta directory path', () => {
metaEngine.setProjectContext('blog-project');
const metaDir = metaEngine.getMetaDir();
expect(metaDir).toContain('projects');
expect(metaDir).toContain('blog-project');
expect(metaDir).toContain('meta');
});
});
describe('Tags Management', () => {
it('should return empty array when no tags exist', async () => {
const tags = await metaEngine.getTags();
expect(tags).toEqual([]);
});
it('should add a new tag', async () => {
await metaEngine.addTag('javascript');
const tags = await metaEngine.getTags();
expect(tags).toContain('javascript');
});
it('should not add duplicate tags', async () => {
await metaEngine.addTag('typescript');
await metaEngine.addTag('typescript');
const tags = await metaEngine.getTags();
expect(tags.filter(t => t === 'typescript')).toHaveLength(1);
});
it('should remove a tag', async () => {
await metaEngine.addTag('react');
await metaEngine.addTag('vue');
await metaEngine.removeTag('react');
const tags = await metaEngine.getTags();
expect(tags).not.toContain('react');
expect(tags).toContain('vue');
});
it('should persist tags to filesystem', async () => {
await metaEngine.addTag('node');
await metaEngine.saveTags();
const metaDir = metaEngine.getMetaDir();
const tagsPath = `${metaDir}\\tags.json`;
expect(mockFiles.has(tagsPath) || mockFiles.has(tagsPath.replace(/\\/g, '/'))).toBe(true);
});
it('should load tags from filesystem', async () => {
const metaDir = metaEngine.getMetaDir();
const tagsPath = `${metaDir}\\tags.json`;
mockFiles.set(tagsPath, JSON.stringify(['saved-tag-1', 'saved-tag-2']));
await metaEngine.loadTags();
const tags = await metaEngine.getTags();
expect(tags).toContain('saved-tag-1');
expect(tags).toContain('saved-tag-2');
});
});
describe('Categories Management', () => {
it('should return empty array when no categories exist', async () => {
const categories = await metaEngine.getCategories();
expect(categories).toEqual([]);
});
it('should add a new category', async () => {
await metaEngine.addCategory('tutorials');
const categories = await metaEngine.getCategories();
expect(categories).toContain('tutorials');
});
it('should not add duplicate categories', async () => {
await metaEngine.addCategory('news');
await metaEngine.addCategory('news');
const categories = await metaEngine.getCategories();
expect(categories.filter(c => c === 'news')).toHaveLength(1);
});
it('should remove a category', async () => {
await metaEngine.addCategory('reviews');
await metaEngine.addCategory('guides');
await metaEngine.removeCategory('reviews');
const categories = await metaEngine.getCategories();
expect(categories).not.toContain('reviews');
expect(categories).toContain('guides');
});
it('should persist categories to filesystem', async () => {
await metaEngine.addCategory('tech');
await metaEngine.saveCategories();
const metaDir = metaEngine.getMetaDir();
const catPath = `${metaDir}\\categories.json`;
expect(mockFiles.has(catPath) || mockFiles.has(catPath.replace(/\\/g, '/'))).toBe(true);
});
it('should load categories from filesystem', async () => {
const metaDir = metaEngine.getMetaDir();
const catPath = `${metaDir}\\categories.json`;
mockFiles.set(catPath, JSON.stringify(['cat-1', 'cat-2']));
await metaEngine.loadCategories();
const categories = await metaEngine.getCategories();
expect(categories).toContain('cat-1');
expect(categories).toContain('cat-2');
});
});
describe('Sync on Startup', () => {
it('should export tags from posts to file if file does not exist', async () => {
// Setup: posts have tags but no meta file exists
mockPosts = [
{ tags: JSON.stringify(['tag1', 'tag2']) },
{ tags: JSON.stringify(['tag2', 'tag3']) },
];
await metaEngine.syncOnStartup();
const tags = await metaEngine.getTags();
expect(tags).toContain('tag1');
expect(tags).toContain('tag2');
expect(tags).toContain('tag3');
});
it('should export categories from posts to file if file does not exist', async () => {
mockPosts = [
{ categories: JSON.stringify(['cat1', 'cat2']) },
{ categories: JSON.stringify(['cat2', 'cat3']) },
];
await metaEngine.syncOnStartup();
const categories = await metaEngine.getCategories();
expect(categories).toContain('cat1');
expect(categories).toContain('cat2');
expect(categories).toContain('cat3');
});
it('should merge file tags with database tags', async () => {
// File has some tags
const metaDir = metaEngine.getMetaDir();
mockFiles.set(`${metaDir}\\tags.json`, JSON.stringify(['file-tag']));
// Posts have different tags
mockPosts = [
{ tags: JSON.stringify(['db-tag']) },
];
await metaEngine.syncOnStartup();
const tags = await metaEngine.getTags();
expect(tags).toContain('file-tag');
expect(tags).toContain('db-tag');
});
it('should merge file categories with database categories', async () => {
const metaDir = metaEngine.getMetaDir();
mockFiles.set(`${metaDir}\\categories.json`, JSON.stringify(['file-cat']));
mockPosts = [
{ categories: JSON.stringify(['db-cat']) },
];
await metaEngine.syncOnStartup();
const categories = await metaEngine.getCategories();
expect(categories).toContain('file-cat');
expect(categories).toContain('db-cat');
});
it('should create meta directory if it does not exist', async () => {
mockPosts = [{ tags: JSON.stringify(['test']), categories: JSON.stringify([]) }];
await metaEngine.syncOnStartup();
expect(fs.mkdir).toHaveBeenCalled();
});
it('should save merged results back to file', async () => {
const metaDir = metaEngine.getMetaDir();
mockFiles.set(`${metaDir}\\tags.json`, JSON.stringify(['existing']));
mockPosts = [{ tags: JSON.stringify(['new-from-db']), categories: JSON.stringify([]) }];
await metaEngine.syncOnStartup();
expect(fs.writeFile).toHaveBeenCalled();
});
});
describe('Tags/Categories from Posts', () => {
it('should collect all unique tags from posts', async () => {
mockPosts = [
{ tags: JSON.stringify(['a', 'b']) },
{ tags: JSON.stringify(['b', 'c']) },
{ tags: JSON.stringify(['c', 'd']) },
];
const tags = await metaEngine.collectTagsFromPosts();
expect(tags).toEqual(['a', 'b', 'c', 'd']);
});
it('should collect all unique categories from posts', async () => {
mockPosts = [
{ categories: JSON.stringify(['x', 'y']) },
{ categories: JSON.stringify(['y', 'z']) },
];
const categories = await metaEngine.collectCategoriesFromPosts();
expect(categories).toEqual(['x', 'y', 'z']);
});
it('should handle posts with null/empty tags', async () => {
mockPosts = [
{ tags: null },
{ tags: '' },
{ tags: JSON.stringify(['valid']) },
];
const tags = await metaEngine.collectTagsFromPosts();
expect(tags).toEqual(['valid']);
});
it('should handle posts with null/empty categories', async () => {
mockPosts = [
{ categories: null },
{ categories: '' },
{ categories: JSON.stringify(['valid']) },
];
const categories = await metaEngine.collectCategoriesFromPosts();
expect(categories).toEqual(['valid']);
});
});
describe('Event Emission', () => {
it('should emit tagsChanged event when tags are modified', async () => {
const handler = vi.fn();
metaEngine.on('tagsChanged', handler);
await metaEngine.addTag('new-tag');
expect(handler).toHaveBeenCalledWith(expect.arrayContaining(['new-tag']));
});
it('should emit categoriesChanged event when categories are modified', async () => {
const handler = vi.fn();
metaEngine.on('categoriesChanged', handler);
await metaEngine.addCategory('new-category');
expect(handler).toHaveBeenCalledWith(expect.arrayContaining(['new-category']));
});
});
describe('Project Metadata Management', () => {
it('should return null when no project metadata exists', async () => {
const metadata = await metaEngine.getProjectMetadata();
expect(metadata).toBeNull();
});
it('should set project metadata', async () => {
await metaEngine.setProjectMetadata({
name: 'My Blog',
description: 'A personal blog about technology',
});
const metadata = await metaEngine.getProjectMetadata();
expect(metadata).toEqual({
name: 'My Blog',
description: 'A personal blog about technology',
});
});
it('should update project name only', async () => {
await metaEngine.setProjectMetadata({
name: 'Original Name',
description: 'Original description',
});
await metaEngine.updateProjectMetadata({ name: 'Updated Name' });
const metadata = await metaEngine.getProjectMetadata();
expect(metadata?.name).toBe('Updated Name');
expect(metadata?.description).toBe('Original description');
});
it('should update project description only', async () => {
await metaEngine.setProjectMetadata({
name: 'My Blog',
description: 'Old description',
});
await metaEngine.updateProjectMetadata({ description: 'New description' });
const metadata = await metaEngine.getProjectMetadata();
expect(metadata?.name).toBe('My Blog');
expect(metadata?.description).toBe('New description');
});
it('should persist project metadata to filesystem', async () => {
await metaEngine.setProjectMetadata({
name: 'Test Project',
description: 'Test description',
});
const metaDir = metaEngine.getMetaDir();
const projectPath = `${metaDir}\\project.json`;
expect(mockFiles.has(projectPath) || mockFiles.has(projectPath.replace(/\\/g, '/'))).toBe(true);
// Verify content
const content = mockFiles.get(projectPath) || mockFiles.get(projectPath.replace(/\\/g, '/'));
const parsed = JSON.parse(content!);
expect(parsed.name).toBe('Test Project');
expect(parsed.description).toBe('Test description');
});
it('should load project metadata from filesystem', async () => {
const metaDir = metaEngine.getMetaDir();
const projectPath = `${metaDir}\\project.json`;
mockFiles.set(projectPath, JSON.stringify({
name: 'Loaded Project',
description: 'Loaded description',
}));
await metaEngine.loadProjectMetadata();
const metadata = await metaEngine.getProjectMetadata();
expect(metadata?.name).toBe('Loaded Project');
expect(metadata?.description).toBe('Loaded description');
});
it('should emit projectMetadataChanged event when metadata is modified', async () => {
const handler = vi.fn();
metaEngine.on('projectMetadataChanged', handler);
await metaEngine.setProjectMetadata({
name: 'Event Test',
description: 'Testing events',
});
expect(handler).toHaveBeenCalledWith({
name: 'Event Test',
description: 'Testing events',
});
});
it('should clear project metadata when project context changes', () => {
// Set some metadata first
metaEngine.setProjectContext('project-1');
// Change project context
metaEngine.setProjectContext('project-2');
// The in-memory cache should be cleared (metadata will be null until loaded)
// This is a synchronous operation, so we test the immediate state
expect(metaEngine.getProjectContext()).toBe('project-2');
});
it('should sync project metadata on startup from database', async () => {
// No file exists, should use default from project database
const metadata = await metaEngine.getProjectMetadata();
// Initially null before sync
expect(metadata).toBeNull();
});
it('should load project metadata during syncOnStartup if file exists', async () => {
const metaDir = metaEngine.getMetaDir();
mockFiles.set(`${metaDir}\\project.json`, JSON.stringify({
name: 'Synced Project',
description: 'Synced description',
}));
await metaEngine.syncOnStartup();
const metadata = await metaEngine.getProjectMetadata();
expect(metadata?.name).toBe('Synced Project');
expect(metadata?.description).toBe('Synced description');
});
it('should create project.json with data from database during syncOnStartup if file does not exist', async () => {
const metaDir = metaEngine.getMetaDir();
const projectPath = `${metaDir}\\project.json`;
// Setup mock project in database
mockProject = {
id: 'test-project',
name: 'My Awesome Blog',
description: 'A blog about programming',
slug: 'my-awesome-blog',
createdAt: new Date(),
updatedAt: new Date(),
isActive: true,
};
// Ensure no file exists
expect(mockFiles.has(projectPath)).toBe(false);
await metaEngine.syncOnStartup();
// File should be created
expect(mockFiles.has(projectPath) || mockFiles.has(projectPath.replace(/\\/g, '/'))).toBe(true);
// Should have metadata from database
const metadata = await metaEngine.getProjectMetadata();
expect(metadata).not.toBeNull();
expect(metadata?.name).toBe('My Awesome Blog');
expect(metadata?.description).toBe('A blog about programming');
});
it('should throw error if project not found in database during syncOnStartup', async () => {
// No project in database
mockProject = null;
await expect(metaEngine.syncOnStartup()).rejects.toThrow('Project not found');
});
it('should create categories.json with defaults for new project with no posts', async () => {
const metaDir = metaEngine.getMetaDir();
const catPath = `${metaDir}\\categories.json`;
// Setup mock project in database
mockProject = {
id: 'test-project',
name: 'New Blog',
description: 'A new blog',
slug: 'new-blog',
createdAt: new Date(),
updatedAt: new Date(),
isActive: true,
};
// No posts (so no categories from database)
mockPosts = [];
await metaEngine.syncOnStartup();
// File should be created with default categories
expect(mockFiles.has(catPath) || mockFiles.has(catPath.replace(/\\/g, '/'))).toBe(true);
const categories = await metaEngine.getCategories();
expect(categories).toContain('article');
expect(categories).toContain('picture');
expect(categories).toContain('aside');
expect(categories).toContain('page');
});
});
});