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

769 lines
25 KiB
TypeScript

/**
* PostMediaEngine Unit Tests
*
* Tests the REAL PostMediaEngine class with mocked dependencies.
* Following TDD best practices: mock external dependencies, test real implementation.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { resetMockCounters, createMockMedia } from '../utils/factories';
// Mock electron BEFORE importing engine
vi.mock('electron', () => ({
app: {
getPath: vi.fn((name: string) => {
const paths: Record<string, string> = {
userData: '/mock/userData',
appData: '/mock/appData',
temp: '/mock/temp',
};
return paths[name] || '/mock/unknown';
}),
},
}));
// Create mock data stores
const mockPostMedia = new Map<string, any>();
// MediaEngine mock functions - defined at module level
const mockGetMedia = vi.fn();
const mockUpdateMedia = vi.fn();
const mockGetAllMedia = vi.fn();
const mockImportMedia = vi.fn();
// Aggregated mock MediaEngine object for constructor injection
const mockMediaEngineForPostMedia = {
getMedia: mockGetMedia,
updateMedia: mockUpdateMedia,
getAllMedia: mockGetAllMedia,
importMedia: mockImportMedia,
};
// Mock MediaEngine
vi.mock('../../src/main/engine/MediaEngine', () => ({
getMediaEngine: vi.fn(() => ({
getMedia: mockGetMedia,
updateMedia: mockUpdateMedia,
getAllMedia: mockGetAllMedia,
importMedia: mockImportMedia,
})),
}));
// Create chainable mock for Drizzle ORM
// Drizzle query chains are "thenable" - they resolve to results when awaited
function createSelectChain(mockData: 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(mockData),
get: vi.fn().mockResolvedValue(mockData[0] || undefined),
// Make chain "thenable" so it can be awaited
then: (resolve: any, reject: any) => Promise.resolve(mockData).then(resolve, reject),
};
return chain;
}
// Track database operations
let insertedValues: any[] = [];
let updateCalls: any[] = [];
let deleteCalled = false;
let deleteCallCount = 0;
let selectMockData: any[] = [];
function createDrizzleMock() {
return {
select: vi.fn(() => createSelectChain(selectMockData)),
insert: vi.fn(() => ({
values: vi.fn((data: any) => {
if (data && data.id) {
mockPostMedia.set(data.id, data);
insertedValues.push(data);
}
return Promise.resolve();
}),
})),
update: vi.fn(() => ({
set: vi.fn((data: any) => ({
where: vi.fn(() => {
updateCalls.push(data);
return Promise.resolve();
}),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(() => {
deleteCalled = true;
deleteCallCount++;
return Promise.resolve();
}),
})),
};
}
const mockLocalDb = createDrizzleMock();
// Mock the database module
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => mockLocalDb),
getLocalClient: vi.fn(() => ({
execute: vi.fn().mockResolvedValue({ rows: [] }),
})),
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(),
})),
}));
// Import after mocks are set up
import { PostMediaEngine } from '../../src/main/engine/PostMediaEngine';
describe('PostMediaEngine', () => {
let engine: PostMediaEngine;
beforeEach(() => {
vi.clearAllMocks();
mockPostMedia.clear();
insertedValues = [];
updateCalls = [];
deleteCalled = false;
deleteCallCount = 0;
selectMockData = [];
resetMockCounters();
// Reset MediaEngine mocks
mockGetMedia.mockReset();
mockUpdateMedia.mockReset();
mockGetAllMedia.mockReset();
mockImportMedia.mockReset();
// Default implementations
mockGetMedia.mockResolvedValue(null);
mockUpdateMedia.mockResolvedValue(undefined);
mockGetAllMedia.mockResolvedValue([]);
mockImportMedia.mockResolvedValue({ id: 'imported-media-id' });
engine = new PostMediaEngine(mockMediaEngineForPostMedia as any);
engine.setProjectContext('test-project');
});
describe('Project Context', () => {
it('should set project context', () => {
engine.setProjectContext('my-blog');
expect(true).toBe(true);
});
it('should avoid duplicate context log when context is unchanged', () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
engine.setProjectContext('same-project');
engine.setProjectContext('same-project');
expect(consoleLogSpy).toHaveBeenCalledTimes(1);
consoleLogSpy.mockRestore();
});
it('should allow changing project context multiple times', () => {
engine.setProjectContext('blog-1');
engine.setProjectContext('blog-2');
expect(true).toBe(true);
});
});
describe('linkMediaToPost', () => {
it('should create a new link between media and post', async () => {
const postId = 'post-1';
const mediaId = 'media-1';
// Setup mock media
const mockMediaData = createMockMedia({ id: mediaId, linkedPostIds: [] });
mockGetMedia.mockResolvedValue(mockMediaData);
const result = await engine.linkMediaToPost(postId, mediaId);
expect(result).toBeDefined();
expect(result.postId).toBe(postId);
expect(result.mediaId).toBe(mediaId);
expect(result.sortOrder).toBe(0);
});
it('should update media sidecar with linkedPostIds', async () => {
const postId = 'post-1';
const mediaId = 'media-1';
const mockMediaData = createMockMedia({ id: mediaId, linkedPostIds: [] });
mockGetMedia.mockResolvedValue(mockMediaData);
await engine.linkMediaToPost(postId, mediaId);
expect(mockUpdateMedia).toHaveBeenCalledWith(
mediaId,
expect.objectContaining({
linkedPostIds: expect.arrayContaining([postId]),
})
);
});
it('should emit mediaLinked event', async () => {
const postId = 'post-1';
const mediaId = 'media-1';
mockGetMedia.mockResolvedValue(createMockMedia({ id: mediaId }));
const handler = vi.fn();
engine.on('mediaLinked', handler);
await engine.linkMediaToPost(postId, mediaId);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ postId, mediaId })
);
});
it('should not create a duplicate link when media is already linked to the post', async () => {
const postId = 'post-1';
const mediaId = 'media-1';
selectMockData = [
{ id: 'existing-link', projectId: 'test-project', postId, mediaId, sortOrder: 2, createdAt: new Date() },
];
mockGetMedia.mockResolvedValue(createMockMedia({ id: mediaId, linkedPostIds: [postId] }));
const result = await engine.linkMediaToPost(postId, mediaId);
expect(insertedValues).toHaveLength(0);
expect(result.id).toBe('existing-link');
expect(result.sortOrder).toBe(2);
});
});
describe('unlinkMediaFromPost', () => {
it('should remove link between media and post', async () => {
const postId = 'post-1';
const mediaId = 'media-1';
mockGetMedia.mockResolvedValue(
createMockMedia({ id: mediaId, linkedPostIds: [postId] })
);
await engine.unlinkMediaFromPost(postId, mediaId);
expect(deleteCalled).toBe(true);
expect(deleteCallCount).toBe(1);
});
it('should update media sidecar to remove postId', async () => {
const postId = 'post-1';
const mediaId = 'media-1';
mockGetMedia.mockResolvedValue(
createMockMedia({ id: mediaId, linkedPostIds: [postId, 'other-post'] })
);
await engine.unlinkMediaFromPost(postId, mediaId);
expect(mockUpdateMedia).toHaveBeenCalledWith(
mediaId,
expect.objectContaining({
linkedPostIds: ['other-post'],
})
);
});
it('should emit mediaUnlinked event', async () => {
const postId = 'post-1';
const mediaId = 'media-1';
mockGetMedia.mockResolvedValue(
createMockMedia({ id: mediaId, linkedPostIds: [postId] })
);
const handler = vi.fn();
engine.on('mediaUnlinked', handler);
await engine.unlinkMediaFromPost(postId, mediaId);
expect(handler).toHaveBeenCalledWith({ postId, mediaId });
});
});
describe('getLinkedMediaForPost', () => {
it('should return all media linked to a post in sort order', async () => {
const postId = 'post-1';
// Mock the database to return sorted links
selectMockData = [
{ id: 'link-2', projectId: 'test-project', postId, mediaId: 'media-2', sortOrder: 0, createdAt: new Date() },
{ id: 'link-1', projectId: 'test-project', postId, mediaId: 'media-1', sortOrder: 1, createdAt: new Date() },
];
const result = await engine.getLinkedMediaForPost(postId);
expect(result).toHaveLength(2);
expect(result[0].mediaId).toBe('media-2');
expect(result[1].mediaId).toBe('media-1');
});
it('should return empty array if no media linked', async () => {
selectMockData = [];
const result = await engine.getLinkedMediaForPost('post-with-no-media');
expect(result).toEqual([]);
});
});
describe('linkManyToPost', () => {
it('should link multiple media files to a post in a single batch', async () => {
const postId = 'post-1';
const mediaIds = ['media-1', 'media-2', 'media-3'];
// No existing links
selectMockData = [];
// Setup mock media for each
mockGetMedia.mockImplementation((id: string) =>
Promise.resolve(createMockMedia({ id, linkedPostIds: [] }))
);
const result = await engine.linkManyToPost(postId, mediaIds);
expect(result.linked).toHaveLength(3);
expect(result.skipped).toHaveLength(0);
expect(insertedValues).toHaveLength(3);
});
it('should skip already linked media and include them in skipped array', async () => {
const postId = 'post-1';
const mediaIds = ['media-1', 'media-2', 'media-3'];
// media-1 is already linked
selectMockData = [
{ id: 'link-1', projectId: 'test-project', postId, mediaId: 'media-1', sortOrder: 0, createdAt: new Date() },
];
mockGetMedia.mockImplementation((id: string) =>
Promise.resolve(createMockMedia({ id, linkedPostIds: id === 'media-1' ? [postId] : [] }))
);
const result = await engine.linkManyToPost(postId, mediaIds);
expect(result.linked).toHaveLength(2);
expect(result.linked).toContain('media-2');
expect(result.linked).toContain('media-3');
expect(result.skipped).toHaveLength(1);
expect(result.skipped).toContain('media-1');
});
it('should emit mediaBatchLinked event once at the end', async () => {
const postId = 'post-1';
const mediaIds = ['media-1', 'media-2'];
selectMockData = [];
mockGetMedia.mockResolvedValue(createMockMedia({ id: 'any', linkedPostIds: [] }));
const handler = vi.fn();
engine.on('mediaBatchLinked', handler);
// Should NOT emit individual mediaLinked events
const individualHandler = vi.fn();
engine.on('mediaLinked', individualHandler);
await engine.linkManyToPost(postId, mediaIds);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({ postId, mediaIds: expect.arrayContaining(['media-1', 'media-2']) });
expect(individualHandler).not.toHaveBeenCalled();
});
it('should not emit event if no media was linked', async () => {
const postId = 'post-1';
const mediaIds = ['media-1'];
// media-1 is already linked
selectMockData = [
{ id: 'link-1', projectId: 'test-project', postId, mediaId: 'media-1', sortOrder: 0, createdAt: new Date() },
];
mockGetMedia.mockResolvedValue(createMockMedia({ id: 'media-1', linkedPostIds: [postId] }));
const handler = vi.fn();
engine.on('mediaBatchLinked', handler);
await engine.linkManyToPost(postId, mediaIds);
expect(handler).not.toHaveBeenCalled();
});
it('should update sortOrder incrementally for batch-linked media', async () => {
const postId = 'post-1';
const mediaIds = ['media-1', 'media-2', 'media-3'];
selectMockData = [];
mockGetMedia.mockResolvedValue(createMockMedia({ linkedPostIds: [] }));
await engine.linkManyToPost(postId, mediaIds);
// Check that sort orders are sequential
const sortOrders = insertedValues.map(v => v.sortOrder);
expect(sortOrders).toEqual([0, 1, 2]);
});
it('should produce same persisted link state as single-link path for one media item', async () => {
const postId = 'post-1';
const mediaId = 'media-1';
selectMockData = [];
mockGetMedia.mockResolvedValue(createMockMedia({ id: mediaId, linkedPostIds: [] }));
await engine.linkMediaToPost(postId, mediaId);
const singleInsert = insertedValues[0];
const singleUpdateArgs = mockUpdateMedia.mock.calls[0][1];
insertedValues = [];
mockUpdateMedia.mockClear();
selectMockData = [];
const batchResult = await engine.linkManyToPost(postId, [mediaId]);
const batchInsert = insertedValues[0];
const batchUpdateArgs = mockUpdateMedia.mock.calls[0][1];
expect(batchResult).toEqual({ linked: [mediaId], skipped: [] });
expect(singleInsert.postId).toBe(batchInsert.postId);
expect(singleInsert.mediaId).toBe(batchInsert.mediaId);
expect(singleInsert.sortOrder).toBe(batchInsert.sortOrder);
expect(singleUpdateArgs).toEqual(batchUpdateArgs);
});
});
describe('unlinkManyFromPost', () => {
it('should unlink multiple media files from a post in a single batch', async () => {
const postId = 'post-1';
const mediaIds = ['media-1', 'media-2', 'media-3'];
mockGetMedia.mockImplementation((id: string) =>
Promise.resolve(createMockMedia({ id, linkedPostIds: [postId] }))
);
const result = await engine.unlinkManyFromPost(postId, mediaIds);
expect(result.unlinked).toHaveLength(3);
// deleteCalled flag is set to true when any delete is called
expect(deleteCalled).toBe(true);
expect(deleteCallCount).toBe(3);
});
it('should emit mediaBatchUnlinked event once at the end', async () => {
const postId = 'post-1';
const mediaIds = ['media-1', 'media-2'];
mockGetMedia.mockResolvedValue(createMockMedia({ linkedPostIds: [postId] }));
const handler = vi.fn();
engine.on('mediaBatchUnlinked', handler);
// Should NOT emit individual mediaUnlinked events
const individualHandler = vi.fn();
engine.on('mediaUnlinked', individualHandler);
await engine.unlinkManyFromPost(postId, mediaIds);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({ postId, mediaIds: expect.arrayContaining(['media-1', 'media-2']) });
expect(individualHandler).not.toHaveBeenCalled();
});
it('should not emit event if no media was unlinked', async () => {
const postId = 'post-1';
const mediaIds: string[] = [];
const handler = vi.fn();
engine.on('mediaBatchUnlinked', handler);
await engine.unlinkManyFromPost(postId, mediaIds);
expect(handler).not.toHaveBeenCalled();
});
it('should update media sidecars to remove postId', async () => {
const postId = 'post-1';
const mediaIds = ['media-1', 'media-2'];
mockGetMedia.mockImplementation((id: string) =>
Promise.resolve(createMockMedia({ id, linkedPostIds: [postId, 'other-post'] }))
);
await engine.unlinkManyFromPost(postId, mediaIds);
// Both media should have their sidecars updated
expect(mockUpdateMedia).toHaveBeenCalledTimes(2);
expect(mockUpdateMedia).toHaveBeenCalledWith(
'media-1',
expect.objectContaining({ linkedPostIds: ['other-post'] })
);
expect(mockUpdateMedia).toHaveBeenCalledWith(
'media-2',
expect.objectContaining({ linkedPostIds: ['other-post'] })
);
});
it('should process duplicate media IDs only once in a single batch', async () => {
const postId = 'post-1';
const mediaIds = ['media-1', 'media-1', 'media-2'];
mockGetMedia.mockImplementation((id: string) =>
Promise.resolve(createMockMedia({ id, linkedPostIds: [postId, 'other-post'] }))
);
const result = await engine.unlinkManyFromPost(postId, mediaIds);
expect(result.unlinked).toEqual(['media-1', 'media-2']);
expect(deleteCallCount).toBe(2);
expect(mockUpdateMedia).toHaveBeenCalledTimes(2);
});
it('should produce same sidecar and delete effects as single-unlink path for one media item', async () => {
const postId = 'post-1';
const mediaId = 'media-1';
mockGetMedia.mockResolvedValue(createMockMedia({ id: mediaId, linkedPostIds: [postId, 'other-post'] }));
await engine.unlinkMediaFromPost(postId, mediaId);
const singleDeleteCount = deleteCallCount;
const singleUpdateArgs = mockUpdateMedia.mock.calls[0][1];
deleteCallCount = 0;
deleteCalled = false;
mockUpdateMedia.mockClear();
const batchResult = await engine.unlinkManyFromPost(postId, [mediaId]);
const batchDeleteCount = deleteCallCount;
const batchUpdateArgs = mockUpdateMedia.mock.calls[0][1];
expect(batchResult).toEqual({ unlinked: [mediaId] });
expect(singleDeleteCount).toBe(1);
expect(batchDeleteCount).toBe(1);
expect(singleUpdateArgs).toEqual(batchUpdateArgs);
});
});
describe('getLinkedPostsForMedia', () => {
it('should return all posts linked to a media file', async () => {
const mediaId = 'media-1';
selectMockData = [
{ id: 'link-1', projectId: 'test-project', postId: 'post-1', mediaId, sortOrder: 0, createdAt: new Date() },
{ id: 'link-2', projectId: 'test-project', postId: 'post-2', mediaId, sortOrder: 0, createdAt: new Date() },
];
const result = await engine.getLinkedPostsForMedia(mediaId);
expect(result).toHaveLength(2);
expect(result.map(l => l.postId)).toContain('post-1');
expect(result.map(l => l.postId)).toContain('post-2');
});
});
describe('reorderMediaForPost', () => {
it('should update sortOrder for all media in new order', async () => {
const postId = 'post-1';
const newOrder = ['media-2', 'media-3', 'media-1'];
await engine.reorderMediaForPost(postId, newOrder);
expect(updateCalls).toHaveLength(3);
});
it('should emit mediaReordered event', async () => {
const postId = 'post-1';
const newOrder = ['media-2', 'media-1'];
const handler = vi.fn();
engine.on('mediaReordered', handler);
await engine.reorderMediaForPost(postId, newOrder);
expect(handler).toHaveBeenCalledWith({ postId, mediaIds: newOrder });
});
});
describe('rebuildFromSidecars', () => {
it('should rebuild junction table from media sidecar linkedPostIds', async () => {
const media1 = createMockMedia({
id: 'media-1',
linkedPostIds: ['post-1', 'post-2']
});
const media2 = createMockMedia({
id: 'media-2',
linkedPostIds: ['post-1']
});
mockGetAllMedia.mockResolvedValue([media1, media2]);
await engine.rebuildFromSidecars();
// Should have deleted existing links first
expect(deleteCalled).toBe(true);
// Should have created 3 links total (2 for media-1 + 1 for media-2)
expect(insertedValues).toHaveLength(3);
});
it('should emit rebuilt event when complete', async () => {
mockGetAllMedia.mockResolvedValue([]);
const handler = vi.fn();
engine.on('rebuilt', handler);
await engine.rebuildFromSidecars();
expect(handler).toHaveBeenCalled();
});
});
describe('isMediaLinkedToPost', () => {
it('should return true when media is linked to post', async () => {
selectMockData = [
{ id: 'link-1', postId: 'post-1', mediaId: 'media-1' }
];
const result = await engine.isMediaLinkedToPost('post-1', 'media-1');
expect(result).toBe(true);
});
it('should return false when media is not linked to post', async () => {
selectMockData = [];
const result = await engine.isMediaLinkedToPost('post-1', 'media-1');
expect(result).toBe(false);
});
});
describe('importMediaForPost', () => {
it('should import media and link it to the post', async () => {
const postId = 'post-1';
const sourcePath = '/path/to/image.jpg';
const importedMediaId = 'imported-media-123';
mockImportMedia.mockResolvedValue({ id: importedMediaId });
mockGetMedia.mockResolvedValue(createMockMedia({ id: importedMediaId, linkedPostIds: [] }));
const result = await engine.importMediaForPost(postId, sourcePath);
expect(mockImportMedia).toHaveBeenCalledWith(sourcePath);
expect(result.postId).toBe(postId);
expect(result.mediaId).toBe(importedMediaId);
});
});
describe('getLinkedMediaDataForPost', () => {
it('should return linked media with full media data', async () => {
const postId = 'post-1';
const media1 = createMockMedia({ id: 'media-1', title: 'Image 1' });
const media2 = createMockMedia({ id: 'media-2', title: 'Image 2' });
selectMockData = [
{ id: 'link-1', projectId: 'test-project', postId, mediaId: 'media-1', sortOrder: 0, createdAt: new Date() },
{ id: 'link-2', projectId: 'test-project', postId, mediaId: 'media-2', sortOrder: 1, createdAt: new Date() },
];
mockGetMedia.mockImplementation((id: string) => {
if (id === 'media-1') return Promise.resolve(media1);
if (id === 'media-2') return Promise.resolve(media2);
return Promise.resolve(null);
});
const result = await engine.getLinkedMediaDataForPost(postId);
expect(result).toHaveLength(2);
expect(result[0].media.title).toBe('Image 1');
expect(result[1].media.title).toBe('Image 2');
});
it('should skip links where media is not found', async () => {
const postId = 'post-1';
const media1 = createMockMedia({ id: 'media-1', title: 'Image 1' });
selectMockData = [
{ id: 'link-1', projectId: 'test-project', postId, mediaId: 'media-1', sortOrder: 0, createdAt: new Date() },
{ id: 'link-2', projectId: 'test-project', postId, mediaId: 'media-deleted', sortOrder: 1, createdAt: new Date() },
];
mockGetMedia.mockImplementation((id: string) => {
if (id === 'media-1') return Promise.resolve(media1);
return Promise.resolve(null); // media-deleted not found
});
const result = await engine.getLinkedMediaDataForPost(postId);
expect(result).toHaveLength(1);
expect(result[0].media.title).toBe('Image 1');
});
it('should return empty array when no links exist', async () => {
selectMockData = [];
const result = await engine.getLinkedMediaDataForPost('post-no-links');
expect(result).toEqual([]);
});
});
describe('edge cases for linkMediaToPost', () => {
it('should not add duplicate postId to linkedPostIds', async () => {
const postId = 'post-1';
const mediaId = 'media-1';
// Media already has this post linked
mockGetMedia.mockResolvedValue(createMockMedia({
id: mediaId,
linkedPostIds: [postId] // Already linked
}));
await engine.linkMediaToPost(postId, mediaId);
// updateMedia should not be called since post is already in linkedPostIds
expect(mockUpdateMedia).not.toHaveBeenCalled();
});
it('should calculate correct sortOrder when existing links present', async () => {
const postId = 'post-1';
const mediaId = 'media-new';
// Existing links with specific sort orders
selectMockData = [
{ id: 'link-1', projectId: 'test-project', postId, mediaId: 'media-1', sortOrder: 5, createdAt: new Date() },
{ id: 'link-2', projectId: 'test-project', postId, mediaId: 'media-2', sortOrder: 10, createdAt: new Date() },
];
mockGetMedia.mockResolvedValue(createMockMedia({ id: mediaId, linkedPostIds: [] }));
const result = await engine.linkMediaToPost(postId, mediaId);
// sortOrder should be max + 1 = 11
expect(result.sortOrder).toBe(11);
});
it('should handle null media when linking', async () => {
const postId = 'post-1';
const mediaId = 'media-nonexistent';
// Media not found
mockGetMedia.mockResolvedValue(null);
const result = await engine.linkMediaToPost(postId, mediaId);
// Should still create the link
expect(result.postId).toBe(postId);
expect(result.mediaId).toBe(mediaId);
// But updateMedia shouldn't be called
expect(mockUpdateMedia).not.toHaveBeenCalled();
});
});
});