chore: moved to proper drizzle orm and migrations
This commit is contained in:
@@ -10,91 +10,58 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
// Store for mock data
|
||||
const mockDefinitions = new Map<string, any>();
|
||||
|
||||
const mockLocalClient = {
|
||||
execute: vi.fn(async (query: { sql: string; args: any[] }) => {
|
||||
const sql = query.sql.trim();
|
||||
// Create chainable mock for Drizzle ORM that is thenable (can be awaited)
|
||||
function createSelectChain(getData: () => any[]) {
|
||||
const chain: any = {
|
||||
from: vi.fn().mockImplementation(() => chain),
|
||||
where: vi.fn().mockImplementation(() => chain),
|
||||
orderBy: vi.fn().mockImplementation(() => chain),
|
||||
limit: 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(getData()).then(resolve, reject);
|
||||
},
|
||||
};
|
||||
return chain;
|
||||
}
|
||||
|
||||
// INSERT
|
||||
if (sql.startsWith('INSERT')) {
|
||||
const row = {
|
||||
id: query.args[0],
|
||||
project_id: query.args[1],
|
||||
name: query.args[2],
|
||||
wxr_file_path: query.args[3] ?? null,
|
||||
uploads_folder_path: query.args[4] ?? null,
|
||||
last_analysis_result: query.args[5] ?? null,
|
||||
created_at: query.args[6],
|
||||
updated_at: query.args[7],
|
||||
};
|
||||
mockDefinitions.set(row.id, row);
|
||||
return { rows: [] };
|
||||
}
|
||||
// Track what data Drizzle queries should return
|
||||
let mockDrizzleSelectResults: any[][] = [];
|
||||
|
||||
// SELECT by id
|
||||
if (sql.startsWith('SELECT') && sql.includes('WHERE id = ?') && sql.includes('project_id = ?')) {
|
||||
const id = query.args[0];
|
||||
const projectId = query.args[1];
|
||||
const def = mockDefinitions.get(id);
|
||||
if (def && def.project_id === projectId) {
|
||||
return { rows: [def] };
|
||||
const mockLocalDb = {
|
||||
select: vi.fn(() => createSelectChain(() => mockDrizzleSelectResults.shift() || [])),
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn((data: any) => {
|
||||
if (data && data.id) {
|
||||
mockDefinitions.set(data.id, {
|
||||
id: data.id,
|
||||
projectId: data.projectId,
|
||||
name: data.name,
|
||||
wxrFilePath: data.wxrFilePath,
|
||||
uploadsFolderPath: data.uploadsFolderPath,
|
||||
lastAnalysisResult: data.lastAnalysisResult,
|
||||
createdAt: data.createdAt,
|
||||
updatedAt: data.updatedAt,
|
||||
});
|
||||
}
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
// SELECT all for project
|
||||
if (sql.startsWith('SELECT') && sql.includes('WHERE project_id = ?') && sql.includes('ORDER BY')) {
|
||||
const projectId = query.args[0];
|
||||
const rows = Array.from(mockDefinitions.values())
|
||||
.filter(d => d.project_id === projectId)
|
||||
.sort((a, b) => b.updated_at - a.updated_at);
|
||||
return { rows };
|
||||
}
|
||||
|
||||
// UPDATE
|
||||
if (sql.startsWith('UPDATE')) {
|
||||
// Find the id in args (last two args are id and project_id in WHERE)
|
||||
const id = query.args[query.args.length - 2];
|
||||
const projectId = query.args[query.args.length - 1];
|
||||
const def = mockDefinitions.get(id);
|
||||
if (def && def.project_id === projectId) {
|
||||
// Apply updates based on the SET clause
|
||||
// Parse set fields from the sql
|
||||
const setMatch = sql.match(/SET (.+?) WHERE/);
|
||||
if (setMatch) {
|
||||
const setParts = setMatch[1].split(', ');
|
||||
let argIdx = 0;
|
||||
for (const part of setParts) {
|
||||
const field = part.split(' = ')[0].trim();
|
||||
def[field] = query.args[argIdx];
|
||||
argIdx++;
|
||||
}
|
||||
}
|
||||
return { rowsAffected: 1, rows: [] };
|
||||
}
|
||||
return { rowsAffected: 0, rows: [] };
|
||||
}
|
||||
|
||||
// DELETE
|
||||
if (sql.startsWith('DELETE')) {
|
||||
const id = query.args[0];
|
||||
const projectId = query.args[1];
|
||||
const def = mockDefinitions.get(id);
|
||||
if (def && def.project_id === projectId) {
|
||||
mockDefinitions.delete(id);
|
||||
return { rowsAffected: 1, rows: [] };
|
||||
}
|
||||
return { rowsAffected: 0, rows: [] };
|
||||
}
|
||||
|
||||
return { rows: [] };
|
||||
}),
|
||||
return Promise.resolve();
|
||||
}),
|
||||
})),
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({
|
||||
where: vi.fn(() => Promise.resolve()),
|
||||
})),
|
||||
})),
|
||||
delete: vi.fn(() => ({
|
||||
where: vi.fn(() => Promise.resolve()),
|
||||
})),
|
||||
};
|
||||
|
||||
// Mock the database module
|
||||
vi.mock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => ({
|
||||
getLocal: vi.fn(() => null),
|
||||
getLocalClient: vi.fn(() => mockLocalClient),
|
||||
getLocal: vi.fn(() => mockLocalDb),
|
||||
getLocalClient: vi.fn(() => null),
|
||||
getRemote: vi.fn(() => null),
|
||||
getDataPaths: vi.fn(() => ({
|
||||
database: '/mock/userData/bds.db',
|
||||
@@ -122,6 +89,7 @@ describe('ImportDefinitionEngine', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDefinitions.clear();
|
||||
mockDrizzleSelectResults = [];
|
||||
engine = new ImportDefinitionEngine();
|
||||
engine.setProjectContext('test-project');
|
||||
});
|
||||
@@ -168,17 +136,24 @@ describe('ImportDefinitionEngine', () => {
|
||||
it('should insert into the database', async () => {
|
||||
await engine.createDefinition('Test Import');
|
||||
|
||||
expect(mockLocalClient.execute).toHaveBeenCalledTimes(1);
|
||||
const call = mockLocalClient.execute.mock.calls[0][0];
|
||||
expect(call.sql).toContain('INSERT INTO import_definitions');
|
||||
expect(call.args[2]).toBe('Test Import');
|
||||
expect(mockLocalDb.insert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefinition', () => {
|
||||
it('should return a definition by ID', async () => {
|
||||
const created = await engine.createDefinition('My Import');
|
||||
mockLocalClient.execute.mockClear();
|
||||
// Set up mock to return the created definition
|
||||
mockDrizzleSelectResults = [[{
|
||||
id: created.id,
|
||||
projectId: 'test-project',
|
||||
name: 'My Import',
|
||||
wxrFilePath: null,
|
||||
uploadsFolderPath: null,
|
||||
lastAnalysisResult: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}]];
|
||||
|
||||
const def = await engine.getDefinition(created.id);
|
||||
|
||||
@@ -188,6 +163,7 @@ describe('ImportDefinitionEngine', () => {
|
||||
});
|
||||
|
||||
it('should return null for non-existent ID', async () => {
|
||||
mockDrizzleSelectResults = [[]];
|
||||
const def = await engine.getDefinition('non-existent-id');
|
||||
|
||||
expect(def).toBeNull();
|
||||
@@ -196,6 +172,7 @@ describe('ImportDefinitionEngine', () => {
|
||||
it('should not return definitions from other projects', async () => {
|
||||
const created = await engine.createDefinition('My Import');
|
||||
engine.setProjectContext('other-project');
|
||||
mockDrizzleSelectResults = [[]]; // would be filtered by project
|
||||
|
||||
const def = await engine.getDefinition(created.id);
|
||||
|
||||
@@ -204,9 +181,17 @@ describe('ImportDefinitionEngine', () => {
|
||||
|
||||
it('should parse lastAnalysisResult JSON', async () => {
|
||||
const created = await engine.createDefinition('My Import');
|
||||
// Manually set analysis result in mock store
|
||||
const storedDef = mockDefinitions.get(created.id);
|
||||
storedDef.last_analysis_result = JSON.stringify({ posts: { total: 5 } });
|
||||
// Set up mock to return the definition with analysis result
|
||||
mockDrizzleSelectResults = [[{
|
||||
id: created.id,
|
||||
projectId: 'test-project',
|
||||
name: 'My Import',
|
||||
wxrFilePath: null,
|
||||
uploadsFolderPath: null,
|
||||
lastAnalysisResult: JSON.stringify({ posts: { total: 5 } }),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}]];
|
||||
|
||||
const def = await engine.getDefinition(created.id);
|
||||
|
||||
@@ -216,6 +201,7 @@ describe('ImportDefinitionEngine', () => {
|
||||
|
||||
describe('getAllForProject', () => {
|
||||
it('should return empty array when no definitions exist', async () => {
|
||||
mockDrizzleSelectResults = [[]];
|
||||
const defs = await engine.getAllForProject();
|
||||
|
||||
expect(defs).toEqual([]);
|
||||
@@ -224,6 +210,11 @@ describe('ImportDefinitionEngine', () => {
|
||||
it('should return all definitions for the current project', async () => {
|
||||
await engine.createDefinition('Import 1');
|
||||
await engine.createDefinition('Import 2');
|
||||
// Mock returning both definitions
|
||||
mockDrizzleSelectResults = [[
|
||||
{ id: 'id1', projectId: 'test-project', name: 'Import 1', wxrFilePath: null, uploadsFolderPath: null, lastAnalysisResult: null, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 'id2', projectId: 'test-project', name: 'Import 2', wxrFilePath: null, uploadsFolderPath: null, lastAnalysisResult: null, createdAt: new Date(), updatedAt: new Date() },
|
||||
]];
|
||||
|
||||
const defs = await engine.getAllForProject();
|
||||
|
||||
@@ -235,6 +226,10 @@ describe('ImportDefinitionEngine', () => {
|
||||
engine.setProjectContext('other-project');
|
||||
await engine.createDefinition('Import B');
|
||||
engine.setProjectContext('test-project');
|
||||
// Mock returning only the test-project definition
|
||||
mockDrizzleSelectResults = [[
|
||||
{ id: 'id1', projectId: 'test-project', name: 'Import A', wxrFilePath: null, uploadsFolderPath: null, lastAnalysisResult: null, createdAt: new Date(), updatedAt: new Date() },
|
||||
]];
|
||||
|
||||
const defs = await engine.getAllForProject();
|
||||
|
||||
@@ -243,10 +238,12 @@ describe('ImportDefinitionEngine', () => {
|
||||
});
|
||||
|
||||
it('should return definitions ordered by updatedAt DESC', async () => {
|
||||
await engine.createDefinition('Older');
|
||||
// Small delay to ensure different timestamps
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await engine.createDefinition('Newer');
|
||||
const olderDate = new Date('2024-01-01');
|
||||
const newerDate = new Date('2024-02-01');
|
||||
mockDrizzleSelectResults = [[
|
||||
{ id: 'id2', projectId: 'test-project', name: 'Newer', wxrFilePath: null, uploadsFolderPath: null, lastAnalysisResult: null, createdAt: newerDate, updatedAt: newerDate },
|
||||
{ id: 'id1', projectId: 'test-project', name: 'Older', wxrFilePath: null, uploadsFolderPath: null, lastAnalysisResult: null, createdAt: olderDate, updatedAt: olderDate },
|
||||
]];
|
||||
|
||||
const defs = await engine.getAllForProject();
|
||||
|
||||
@@ -258,6 +255,29 @@ describe('ImportDefinitionEngine', () => {
|
||||
describe('updateDefinition', () => {
|
||||
it('should update the name', async () => {
|
||||
const created = await engine.createDefinition('Old Name');
|
||||
// First call for getDefinition check, second for returning updated data
|
||||
mockDrizzleSelectResults = [
|
||||
[{ // getDefinition call inside updateDefinition
|
||||
id: created.id,
|
||||
projectId: 'test-project',
|
||||
name: 'Old Name',
|
||||
wxrFilePath: null,
|
||||
uploadsFolderPath: null,
|
||||
lastAnalysisResult: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}],
|
||||
[{ // return after update
|
||||
id: created.id,
|
||||
projectId: 'test-project',
|
||||
name: 'New Name',
|
||||
wxrFilePath: null,
|
||||
uploadsFolderPath: null,
|
||||
lastAnalysisResult: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}],
|
||||
];
|
||||
|
||||
const updated = await engine.updateDefinition(created.id, { name: 'New Name' });
|
||||
|
||||
@@ -267,6 +287,29 @@ describe('ImportDefinitionEngine', () => {
|
||||
|
||||
it('should update wxrFilePath', async () => {
|
||||
const created = await engine.createDefinition('Test');
|
||||
// First call for check, second for returning updated data
|
||||
mockDrizzleSelectResults = [
|
||||
[{ // getDefinition check
|
||||
id: created.id,
|
||||
projectId: 'test-project',
|
||||
name: 'Test',
|
||||
wxrFilePath: null,
|
||||
uploadsFolderPath: null,
|
||||
lastAnalysisResult: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}],
|
||||
[{ // return after update
|
||||
id: created.id,
|
||||
projectId: 'test-project',
|
||||
name: 'Test',
|
||||
wxrFilePath: '/path/to/export.xml',
|
||||
uploadsFolderPath: null,
|
||||
lastAnalysisResult: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}],
|
||||
];
|
||||
|
||||
const updated = await engine.updateDefinition(created.id, { wxrFilePath: '/path/to/export.xml' });
|
||||
|
||||
@@ -275,6 +318,28 @@ describe('ImportDefinitionEngine', () => {
|
||||
|
||||
it('should update uploadsFolderPath', async () => {
|
||||
const created = await engine.createDefinition('Test');
|
||||
mockDrizzleSelectResults = [
|
||||
[{ // getDefinition check
|
||||
id: created.id,
|
||||
projectId: 'test-project',
|
||||
name: 'Test',
|
||||
wxrFilePath: null,
|
||||
uploadsFolderPath: null,
|
||||
lastAnalysisResult: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}],
|
||||
[{ // return after update
|
||||
id: created.id,
|
||||
projectId: 'test-project',
|
||||
name: 'Test',
|
||||
wxrFilePath: null,
|
||||
uploadsFolderPath: '/path/to/uploads',
|
||||
lastAnalysisResult: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}],
|
||||
];
|
||||
|
||||
const updated = await engine.updateDefinition(created.id, { uploadsFolderPath: '/path/to/uploads' });
|
||||
|
||||
@@ -284,6 +349,28 @@ describe('ImportDefinitionEngine', () => {
|
||||
it('should update lastAnalysisResult as JSON', async () => {
|
||||
const created = await engine.createDefinition('Test');
|
||||
const report = { posts: { total: 10, new: 5 } };
|
||||
mockDrizzleSelectResults = [
|
||||
[{ // getDefinition check
|
||||
id: created.id,
|
||||
projectId: 'test-project',
|
||||
name: 'Test',
|
||||
wxrFilePath: null,
|
||||
uploadsFolderPath: null,
|
||||
lastAnalysisResult: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}],
|
||||
[{ // return after update
|
||||
id: created.id,
|
||||
projectId: 'test-project',
|
||||
name: 'Test',
|
||||
wxrFilePath: null,
|
||||
uploadsFolderPath: null,
|
||||
lastAnalysisResult: JSON.stringify(report),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}],
|
||||
];
|
||||
|
||||
const updated = await engine.updateDefinition(created.id, { lastAnalysisResult: JSON.stringify(report) });
|
||||
|
||||
@@ -291,6 +378,7 @@ describe('ImportDefinitionEngine', () => {
|
||||
});
|
||||
|
||||
it('should return null for non-existent definition', async () => {
|
||||
mockDrizzleSelectResults = [[]];
|
||||
const updated = await engine.updateDefinition('non-existent', { name: 'Test' });
|
||||
|
||||
expect(updated).toBeNull();
|
||||
@@ -299,6 +387,7 @@ describe('ImportDefinitionEngine', () => {
|
||||
it('should not update definitions from other projects', async () => {
|
||||
const created = await engine.createDefinition('Test');
|
||||
engine.setProjectContext('other-project');
|
||||
mockDrizzleSelectResults = [[]]; // Would be filtered by project
|
||||
|
||||
const updated = await engine.updateDefinition(created.id, { name: 'Hacked' });
|
||||
|
||||
@@ -309,6 +398,16 @@ describe('ImportDefinitionEngine', () => {
|
||||
describe('deleteDefinition', () => {
|
||||
it('should delete an existing definition', async () => {
|
||||
const created = await engine.createDefinition('To Delete');
|
||||
mockDrizzleSelectResults = [[{
|
||||
id: created.id,
|
||||
projectId: 'test-project',
|
||||
name: 'To Delete',
|
||||
wxrFilePath: null,
|
||||
uploadsFolderPath: null,
|
||||
lastAnalysisResult: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}]];
|
||||
|
||||
const result = await engine.deleteDefinition(created.id);
|
||||
|
||||
@@ -316,6 +415,7 @@ describe('ImportDefinitionEngine', () => {
|
||||
});
|
||||
|
||||
it('should return false for non-existent definition', async () => {
|
||||
mockDrizzleSelectResults = [[]];
|
||||
const result = await engine.deleteDefinition('non-existent');
|
||||
|
||||
expect(result).toBe(false);
|
||||
@@ -324,6 +424,7 @@ describe('ImportDefinitionEngine', () => {
|
||||
it('should not delete definitions from other projects', async () => {
|
||||
const created = await engine.createDefinition('Test');
|
||||
engine.setProjectContext('other-project');
|
||||
mockDrizzleSelectResults = [[]]; // Would be filtered by project
|
||||
|
||||
const result = await engine.deleteDefinition(created.id);
|
||||
|
||||
@@ -332,8 +433,21 @@ describe('ImportDefinitionEngine', () => {
|
||||
|
||||
it('should remove the definition from the database', async () => {
|
||||
const created = await engine.createDefinition('Test');
|
||||
// First call returns the definition for delete
|
||||
mockDrizzleSelectResults = [[{
|
||||
id: created.id,
|
||||
projectId: 'test-project',
|
||||
name: 'Test',
|
||||
wxrFilePath: null,
|
||||
uploadsFolderPath: null,
|
||||
lastAnalysisResult: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}]];
|
||||
await engine.deleteDefinition(created.id);
|
||||
|
||||
|
||||
// Second call returns empty for get
|
||||
mockDrizzleSelectResults = [[]];
|
||||
const def = await engine.getDefinition(created.id);
|
||||
expect(def).toBeNull();
|
||||
});
|
||||
|
||||
@@ -14,19 +14,34 @@ const mockTags = new Map<string, any>();
|
||||
const mockPosts = new Map<string, any>();
|
||||
let mockExecuteArgs: any[] = [];
|
||||
|
||||
// Create chainable mock for Drizzle ORM
|
||||
// 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() {
|
||||
return {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockImplementation(function(this: any) {
|
||||
return this;
|
||||
}),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
offset: vi.fn().mockReturnThis(),
|
||||
all: vi.fn().mockImplementation(() => Promise.resolve(Array.from(mockTags.values()))),
|
||||
get: vi.fn().mockImplementation(() => Promise.resolve(undefined)),
|
||||
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() {
|
||||
@@ -115,6 +130,8 @@ describe('TagEngine', () => {
|
||||
mockTags.clear();
|
||||
mockPosts.clear();
|
||||
mockExecuteArgs = [];
|
||||
mockSelectDataQueue = [];
|
||||
mockSelectDataDefault = [];
|
||||
resetMockCounters();
|
||||
tagEngine = new TagEngine();
|
||||
});
|
||||
@@ -198,9 +215,8 @@ describe('TagEngine', () => {
|
||||
});
|
||||
|
||||
it('should throw error for duplicate tag name', async () => {
|
||||
mockLocalClient.execute.mockResolvedValueOnce({
|
||||
rows: [{ id: 'existing', name: 'react' }],
|
||||
});
|
||||
// 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');
|
||||
});
|
||||
@@ -208,9 +224,7 @@ describe('TagEngine', () => {
|
||||
|
||||
describe('updateTag', () => {
|
||||
it('should update tag color', async () => {
|
||||
mockLocalClient.execute.mockResolvedValueOnce({
|
||||
rows: [{ id: 'tag-1', name: 'react', color: null }],
|
||||
});
|
||||
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' });
|
||||
|
||||
@@ -219,9 +233,7 @@ describe('TagEngine', () => {
|
||||
});
|
||||
|
||||
it('should emit tagUpdated event', async () => {
|
||||
mockLocalClient.execute.mockResolvedValueOnce({
|
||||
rows: [{ id: 'tag-1', name: 'react', color: null }],
|
||||
});
|
||||
mockSelectDataDefault = [{ id: 'tag-1', name: 'react', color: null, projectId: 'default', createdAt: new Date(), updatedAt: new Date() }];
|
||||
|
||||
const handler = vi.fn();
|
||||
tagEngine.on('tagUpdated', handler);
|
||||
@@ -232,7 +244,7 @@ describe('TagEngine', () => {
|
||||
});
|
||||
|
||||
it('should return null for non-existent tag', async () => {
|
||||
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
|
||||
mockSelectDataDefault = [];
|
||||
|
||||
const result = await tagEngine.updateTag('non-existent', { color: '#fff' });
|
||||
|
||||
@@ -242,15 +254,17 @@ describe('TagEngine', () => {
|
||||
|
||||
describe('deleteTag', () => {
|
||||
it('should delete tag and remove from posts as a background task', async () => {
|
||||
mockLocalClient.execute
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'react', color: null }] }) // Find tag
|
||||
.mockResolvedValueOnce({ rows: [
|
||||
// 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"]' },
|
||||
] }) // Posts with tag
|
||||
.mockResolvedValueOnce({ rows: [] }) // Update post-1
|
||||
.mockResolvedValueOnce({ rows: [] }) // Update post-2
|
||||
.mockResolvedValueOnce({ rows: [] }); // Delete tag
|
||||
],
|
||||
}));
|
||||
|
||||
const result = await tagEngine.deleteTag('tag-1');
|
||||
|
||||
@@ -259,10 +273,10 @@ describe('TagEngine', () => {
|
||||
});
|
||||
|
||||
it('should emit tagDeleted event', async () => {
|
||||
mockLocalClient.execute
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'react', color: null }] })
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockResolvedValueOnce({ rows: [] });
|
||||
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);
|
||||
@@ -273,7 +287,7 @@ describe('TagEngine', () => {
|
||||
});
|
||||
|
||||
it('should throw error for non-existent tag', async () => {
|
||||
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
|
||||
mockSelectDataDefault = [];
|
||||
|
||||
await expect(tagEngine.deleteTag('non-existent')).rejects.toThrow('Tag not found');
|
||||
});
|
||||
@@ -281,14 +295,16 @@ describe('TagEngine', () => {
|
||||
|
||||
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: 'tag-1', name: 'js' }] }) // Source tag 1
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'tag-2', name: 'javascript' }] }) // Source tag 2
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'tag-3', name: 'ecmascript' }] }) // Target tag
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'post-1' }, { id: 'post-2' }] }) // Posts with source tags
|
||||
.mockResolvedValueOnce({ rows: [] }) // Update posts
|
||||
.mockResolvedValueOnce({ rows: [] }) // Delete source tag 1
|
||||
.mockResolvedValueOnce({ rows: [] }); // Delete source tag 2
|
||||
.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');
|
||||
|
||||
@@ -298,11 +314,11 @@ describe('TagEngine', () => {
|
||||
});
|
||||
|
||||
it('should emit tagsMerged event', async () => {
|
||||
mockLocalClient.execute
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'js' }] })
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'tag-2', name: 'javascript' }] })
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockResolvedValueOnce({ rows: [] });
|
||||
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);
|
||||
@@ -317,9 +333,10 @@ describe('TagEngine', () => {
|
||||
});
|
||||
|
||||
it('should throw error when target tag does not exist', async () => {
|
||||
mockLocalClient.execute
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'js' }] })
|
||||
.mockResolvedValueOnce({ rows: [] }); // Target not found
|
||||
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');
|
||||
});
|
||||
@@ -327,12 +344,13 @@ describe('TagEngine', () => {
|
||||
|
||||
describe('renameTags (batch rename)', () => {
|
||||
it('should rename multiple tags and update posts', async () => {
|
||||
mockLocalClient.execute
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'old-name' }] })
|
||||
.mockResolvedValueOnce({ rows: [] }) // Check no duplicate
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'post-1' }] }) // Posts with tag
|
||||
.mockResolvedValueOnce({ rows: [] }) // Update posts
|
||||
.mockResolvedValueOnce({ rows: [] }); // Update tag name
|
||||
// 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');
|
||||
|
||||
@@ -341,11 +359,11 @@ describe('TagEngine', () => {
|
||||
});
|
||||
|
||||
it('should emit tagRenamed event', async () => {
|
||||
mockLocalClient.execute
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'old-name' }] })
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockResolvedValueOnce({ rows: [] });
|
||||
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);
|
||||
@@ -361,9 +379,8 @@ describe('TagEngine', () => {
|
||||
|
||||
describe('getTag', () => {
|
||||
it('should return tag by ID', async () => {
|
||||
mockLocalClient.execute.mockResolvedValueOnce({
|
||||
rows: [{ id: 'tag-1', name: 'react', color: '#61dafb', project_id: 'default', created_at: Date.now(), updated_at: Date.now() }],
|
||||
});
|
||||
// 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');
|
||||
|
||||
@@ -373,7 +390,7 @@ describe('TagEngine', () => {
|
||||
});
|
||||
|
||||
it('should return null for non-existent tag', async () => {
|
||||
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
|
||||
mockSelectDataDefault = [];
|
||||
|
||||
const result = await tagEngine.getTag('non-existent');
|
||||
|
||||
@@ -383,9 +400,7 @@ describe('TagEngine', () => {
|
||||
|
||||
describe('getTagByName', () => {
|
||||
it('should return tag by name', async () => {
|
||||
mockLocalClient.execute.mockResolvedValueOnce({
|
||||
rows: [{ id: 'tag-1', name: 'react', color: '#61dafb', project_id: 'default', created_at: Date.now(), updated_at: Date.now() }],
|
||||
});
|
||||
mockSelectDataDefault = [{ id: 'tag-1', name: 'react', color: '#61dafb', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }];
|
||||
|
||||
const result = await tagEngine.getTagByName('react');
|
||||
|
||||
@@ -394,9 +409,7 @@ describe('TagEngine', () => {
|
||||
});
|
||||
|
||||
it('should be case-insensitive', async () => {
|
||||
mockLocalClient.execute.mockResolvedValueOnce({
|
||||
rows: [{ id: 'tag-1', name: 'react', color: null }],
|
||||
});
|
||||
mockSelectDataDefault = [{ id: 'tag-1', name: 'react', color: null, projectId: 'default', createdAt: new Date(), updatedAt: new Date() }];
|
||||
|
||||
const result = await tagEngine.getTagByName('REACT');
|
||||
|
||||
@@ -406,12 +419,10 @@ describe('TagEngine', () => {
|
||||
|
||||
describe('getAllTags', () => {
|
||||
it('should return all tags for the current project', async () => {
|
||||
mockLocalClient.execute.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{ id: 'tag-1', name: 'react', color: null, project_id: 'default', created_at: Date.now(), updated_at: Date.now() },
|
||||
{ id: 'tag-2', name: 'vue', color: '#42b883', project_id: 'default', created_at: Date.now(), updated_at: Date.now() },
|
||||
],
|
||||
});
|
||||
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();
|
||||
|
||||
@@ -423,17 +434,15 @@ describe('TagEngine', () => {
|
||||
|
||||
describe('getPostsWithTag', () => {
|
||||
it('should return post IDs that have the specified tag', async () => {
|
||||
// First call: get tag name from id
|
||||
mockLocalClient.execute.mockResolvedValueOnce({
|
||||
rows: [{ name: 'react' }],
|
||||
});
|
||||
// Second call: find posts with this tag
|
||||
mockLocalClient.execute.mockResolvedValueOnce({
|
||||
// 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');
|
||||
|
||||
@@ -467,12 +476,11 @@ describe('TagEngine', () => {
|
||||
|
||||
describe('syncTagsFromPosts', () => {
|
||||
it('should discover tags from existing posts and add missing ones', async () => {
|
||||
mockLocalClient.execute
|
||||
.mockResolvedValueOnce({ rows: [{ tags: '["react", "javascript"]' }, { tags: '["vue", "typescript"]' }] }) // Get posts
|
||||
.mockResolvedValueOnce({ rows: [{ name: 'react' }] }) // Existing tags
|
||||
.mockResolvedValueOnce({ rows: [] }) // Insert missing tags
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockResolvedValueOnce({ rows: [] });
|
||||
// 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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user