1501 lines
50 KiB
TypeScript
1501 lines
50 KiB
TypeScript
/**
|
|
* MediaEngine Unit Tests
|
|
*
|
|
* Tests the REAL MediaEngine class with mocked dependencies.
|
|
* Following TDD best practices: mock external dependencies, test real implementation.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { resetMockCounters } from '../utils/factories';
|
|
|
|
// Mock electron BEFORE importing MediaEngine (it uses require('electron') internally)
|
|
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';
|
|
}),
|
|
},
|
|
}));
|
|
|
|
// Import MediaEngine after mocks are set up
|
|
import { MediaEngine, MediaData } from '../../src/main/engine/MediaEngine';
|
|
|
|
// Create mock data stores
|
|
const mockMedia = new Map<string, any>();
|
|
const mockPostMedia = new Map<string, any>();
|
|
const mockFiles = new Map<string, Buffer | string>();
|
|
|
|
// Track database operations for testing
|
|
let mediaDeleteCalled = false;
|
|
let postMediaDeleteCalled = false;
|
|
let postMediaInserts: any[] = [];
|
|
|
|
// Create chainable mock for Drizzle ORM
|
|
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(mockMedia.values()))),
|
|
get: vi.fn().mockImplementation(() => Promise.resolve(undefined)),
|
|
};
|
|
}
|
|
|
|
function createDrizzleMock() {
|
|
return {
|
|
select: vi.fn(() => createSelectChain()),
|
|
insert: vi.fn((table: any) => ({
|
|
values: vi.fn((data: any) => {
|
|
// Check if this is an insert to postMedia table by looking at inserted data structure
|
|
if (data && data.postId && data.mediaId && !data.filename) {
|
|
// This is a postMedia insert
|
|
mockPostMedia.set(data.id, data);
|
|
postMediaInserts.push(data);
|
|
} else if (data && data.id) {
|
|
// This is a media insert
|
|
mockMedia.set(data.id, data);
|
|
}
|
|
return Promise.resolve();
|
|
}),
|
|
})),
|
|
update: vi.fn(() => ({
|
|
set: vi.fn(() => ({
|
|
where: vi.fn(() => Promise.resolve()),
|
|
})),
|
|
})),
|
|
delete: vi.fn((table: any) => ({
|
|
where: vi.fn((condition: any) => {
|
|
// Track which table is being deleted from
|
|
// We detect by the condition - if it involves projectId and no filename, it's likely postMedia
|
|
// This is a simplified heuristic for testing
|
|
if (table && table.postId !== undefined) {
|
|
postMediaDeleteCalled = true;
|
|
mockPostMedia.clear();
|
|
} else {
|
|
mediaDeleteCalled = true;
|
|
mockMedia.clear();
|
|
}
|
|
return Promise.resolve();
|
|
}),
|
|
})),
|
|
};
|
|
}
|
|
|
|
const mockLocalDb = createDrizzleMock();
|
|
|
|
// Track FTS operations
|
|
let ftsExecuteCalls: { sql: string; args?: any[] }[] = [];
|
|
|
|
// Mock the database module
|
|
vi.mock('../../src/main/database', () => ({
|
|
getDatabase: vi.fn(() => ({
|
|
getLocal: vi.fn(() => mockLocalDb),
|
|
getLocalClient: vi.fn(() => ({
|
|
execute: vi.fn(async (query: { sql: string; args?: any[] } | string) => {
|
|
const sqlObj = typeof query === 'string' ? { sql: query } : query;
|
|
ftsExecuteCalls.push(sqlObj);
|
|
|
|
// Mock FTS search results
|
|
if (sqlObj.sql.includes('media_fts MATCH')) {
|
|
return { rows: [] }; // Return empty results by default
|
|
}
|
|
return { 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(),
|
|
})),
|
|
}));
|
|
|
|
// Mock fs/promises
|
|
vi.mock('fs/promises', () => ({
|
|
readFile: vi.fn(async (path: string) => {
|
|
const content = mockFiles.get(path);
|
|
if (!content) {
|
|
const error = new Error(`ENOENT: no such file or directory, open '${path}'`);
|
|
(error as any).code = 'ENOENT';
|
|
throw error;
|
|
}
|
|
return content;
|
|
}),
|
|
writeFile: vi.fn(async (path: string, content: Buffer | string) => {
|
|
mockFiles.set(path, content);
|
|
}),
|
|
unlink: vi.fn(async (path: string) => {
|
|
mockFiles.delete(path);
|
|
}),
|
|
mkdir: vi.fn(async () => {}),
|
|
readdir: vi.fn(async () => []),
|
|
stat: vi.fn(async (path: string) => ({
|
|
isFile: () => mockFiles.has(path),
|
|
isDirectory: () => !mockFiles.has(path),
|
|
size: mockFiles.get(path)?.length || 0,
|
|
})),
|
|
access: vi.fn(async (path: string) => {
|
|
if (!mockFiles.has(path)) {
|
|
const error = new Error(`ENOENT`);
|
|
(error as any).code = 'ENOENT';
|
|
throw error;
|
|
}
|
|
}),
|
|
copyFile: vi.fn(async (src: string, dest: string) => {
|
|
const content = mockFiles.get(src);
|
|
if (content) {
|
|
mockFiles.set(dest, content);
|
|
}
|
|
}),
|
|
}));
|
|
|
|
// Mock uuid
|
|
vi.mock('uuid', () => ({
|
|
v4: vi.fn(() => 'mock-media-uuid-' + Math.random().toString(36).substr(2, 9)),
|
|
}));
|
|
|
|
describe('MediaEngine', () => {
|
|
let mediaEngine: MediaEngine;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockMedia.clear();
|
|
mockPostMedia.clear();
|
|
mockFiles.clear();
|
|
mediaDeleteCalled = false;
|
|
postMediaDeleteCalled = false;
|
|
postMediaInserts = [];
|
|
ftsExecuteCalls = [];
|
|
resetMockCounters();
|
|
|
|
// Reset the mock implementations
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain());
|
|
|
|
mediaEngine = new MediaEngine();
|
|
});
|
|
|
|
describe('Constructor and Initialization', () => {
|
|
it('should create a MediaEngine instance', () => {
|
|
expect(mediaEngine).toBeInstanceOf(MediaEngine);
|
|
});
|
|
|
|
it('should extend EventEmitter', () => {
|
|
expect(typeof mediaEngine.on).toBe('function');
|
|
expect(typeof mediaEngine.emit).toBe('function');
|
|
});
|
|
|
|
it('should have default project context', () => {
|
|
expect(mediaEngine.getProjectContext()).toBe('default');
|
|
});
|
|
|
|
it('should have default search language', () => {
|
|
expect(mediaEngine.getSearchLanguage()).toBe('english');
|
|
});
|
|
});
|
|
|
|
describe('Search Language', () => {
|
|
it('should set search language', () => {
|
|
mediaEngine.setSearchLanguage('german');
|
|
expect(mediaEngine.getSearchLanguage()).toBe('german');
|
|
});
|
|
|
|
it('should allow changing search language multiple times', () => {
|
|
mediaEngine.setSearchLanguage('french');
|
|
expect(mediaEngine.getSearchLanguage()).toBe('french');
|
|
|
|
mediaEngine.setSearchLanguage('spanish');
|
|
expect(mediaEngine.getSearchLanguage()).toBe('spanish');
|
|
});
|
|
});
|
|
|
|
describe('Project Context', () => {
|
|
it('should set project context', () => {
|
|
mediaEngine.setProjectContext('my-blog');
|
|
expect(mediaEngine.getProjectContext()).toBe('my-blog');
|
|
});
|
|
|
|
it('should allow changing project context multiple times', () => {
|
|
mediaEngine.setProjectContext('blog-1');
|
|
expect(mediaEngine.getProjectContext()).toBe('blog-1');
|
|
|
|
mediaEngine.setProjectContext('blog-2');
|
|
expect(mediaEngine.getProjectContext()).toBe('blog-2');
|
|
});
|
|
});
|
|
|
|
describe('Media Import', () => {
|
|
beforeEach(() => {
|
|
// Setup a source file for import
|
|
const imageBuffer = Buffer.from('fake-image-data');
|
|
mockFiles.set('/source/image.jpg', imageBuffer);
|
|
});
|
|
|
|
it('should import media from source path', async () => {
|
|
const media = await mediaEngine.importMedia('/source/image.jpg');
|
|
|
|
expect(media).toBeDefined();
|
|
expect(media.id).toBeDefined();
|
|
expect(media.originalName).toBe('image.jpg');
|
|
});
|
|
|
|
it('should detect mime type from file extension', async () => {
|
|
const jpgMedia = await mediaEngine.importMedia('/source/image.jpg');
|
|
expect(jpgMedia.mimeType).toBe('image/jpeg');
|
|
});
|
|
|
|
it('should set file size from source', async () => {
|
|
const media = await mediaEngine.importMedia('/source/image.jpg');
|
|
expect(media.size).toBe(Buffer.from('fake-image-data').length);
|
|
});
|
|
|
|
it('should use provided metadata', async () => {
|
|
const media = await mediaEngine.importMedia('/source/image.jpg', {
|
|
alt: 'A beautiful sunset',
|
|
caption: 'Sunset over the ocean',
|
|
tags: ['nature', 'sunset'],
|
|
});
|
|
|
|
expect(media.alt).toBe('A beautiful sunset');
|
|
expect(media.caption).toBe('Sunset over the ocean');
|
|
expect(media.tags).toEqual(['nature', 'sunset']);
|
|
});
|
|
|
|
it('should set createdAt and updatedAt timestamps', async () => {
|
|
const before = new Date();
|
|
const media = await mediaEngine.importMedia('/source/image.jpg');
|
|
const after = new Date();
|
|
|
|
expect(media.createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
|
expect(media.createdAt.getTime()).toBeLessThanOrEqual(after.getTime());
|
|
expect(media.updatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
|
expect(media.updatedAt.getTime()).toBeLessThanOrEqual(after.getTime());
|
|
});
|
|
|
|
it('should use provided createdAt date when specified', async () => {
|
|
const historicalDate = new Date('2018-05-15T10:30:00Z');
|
|
const media = await mediaEngine.importMedia('/source/image.jpg', {
|
|
createdAt: historicalDate,
|
|
updatedAt: historicalDate,
|
|
});
|
|
|
|
expect(media.createdAt.getTime()).toBe(historicalDate.getTime());
|
|
expect(media.updatedAt.getTime()).toBe(historicalDate.getTime());
|
|
});
|
|
|
|
it('should emit mediaImported event', async () => {
|
|
const handler = vi.fn();
|
|
mediaEngine.on('mediaImported', handler);
|
|
|
|
const media = await mediaEngine.importMedia('/source/image.jpg');
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(handler).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
originalName: 'image.jpg',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should create media directory if it does not exist', async () => {
|
|
const fs = await import('fs/promises');
|
|
await mediaEngine.importMedia('/source/image.jpg');
|
|
|
|
expect(fs.mkdir).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should copy media file to destination', async () => {
|
|
const fs = await import('fs/promises');
|
|
await mediaEngine.importMedia('/source/image.jpg');
|
|
|
|
expect(fs.copyFile).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should insert media record into database', async () => {
|
|
await mediaEngine.importMedia('/source/image.jpg');
|
|
|
|
expect(mockLocalDb.insert).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should write sidecar metadata file', async () => {
|
|
const fs = await import('fs/promises');
|
|
await mediaEngine.importMedia('/source/image.jpg');
|
|
|
|
// Should copy the media file and write the sidecar file
|
|
expect(vi.mocked(fs.copyFile).mock.calls.length).toBeGreaterThanOrEqual(1);
|
|
expect(vi.mocked(fs.writeFile).mock.calls.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
describe('MIME Type Detection', () => {
|
|
beforeEach(() => {
|
|
// Setup various file types
|
|
mockFiles.set('/source/photo.jpg', Buffer.from('jpg-data'));
|
|
mockFiles.set('/source/photo.jpeg', Buffer.from('jpeg-data'));
|
|
mockFiles.set('/source/image.png', Buffer.from('png-data'));
|
|
mockFiles.set('/source/animation.gif', Buffer.from('gif-data'));
|
|
mockFiles.set('/source/modern.webp', Buffer.from('webp-data'));
|
|
mockFiles.set('/source/vector.svg', Buffer.from('svg-data'));
|
|
mockFiles.set('/source/unknown.xyz', Buffer.from('unknown-data'));
|
|
});
|
|
|
|
it('should detect image/jpeg for .jpg files', async () => {
|
|
const media = await mediaEngine.importMedia('/source/photo.jpg');
|
|
expect(media.mimeType).toBe('image/jpeg');
|
|
});
|
|
|
|
it('should detect image/jpeg for .jpeg files', async () => {
|
|
const media = await mediaEngine.importMedia('/source/photo.jpeg');
|
|
expect(media.mimeType).toBe('image/jpeg');
|
|
});
|
|
|
|
it('should detect image/png for .png files', async () => {
|
|
const media = await mediaEngine.importMedia('/source/image.png');
|
|
expect(media.mimeType).toBe('image/png');
|
|
});
|
|
|
|
it('should detect image/gif for .gif files', async () => {
|
|
const media = await mediaEngine.importMedia('/source/animation.gif');
|
|
expect(media.mimeType).toBe('image/gif');
|
|
});
|
|
|
|
it('should detect image/webp for .webp files', async () => {
|
|
const media = await mediaEngine.importMedia('/source/modern.webp');
|
|
expect(media.mimeType).toBe('image/webp');
|
|
});
|
|
|
|
it('should detect image/svg+xml for .svg files', async () => {
|
|
const media = await mediaEngine.importMedia('/source/vector.svg');
|
|
expect(media.mimeType).toBe('image/svg+xml');
|
|
});
|
|
|
|
it('should fallback to application/octet-stream for unknown types', async () => {
|
|
const media = await mediaEngine.importMedia('/source/unknown.xyz');
|
|
expect(media.mimeType).toBe('application/octet-stream');
|
|
});
|
|
});
|
|
|
|
describe('Media with Image Dimensions', () => {
|
|
beforeEach(() => {
|
|
mockFiles.set('/source/image.jpg', Buffer.from('image-data'));
|
|
});
|
|
|
|
it('should store width and height when provided', async () => {
|
|
const media = await mediaEngine.importMedia('/source/image.jpg', {
|
|
width: 1920,
|
|
height: 1080,
|
|
});
|
|
|
|
expect(media.width).toBe(1920);
|
|
expect(media.height).toBe(1080);
|
|
});
|
|
|
|
it('should handle media without dimensions', async () => {
|
|
const media = await mediaEngine.importMedia('/source/image.jpg');
|
|
|
|
expect(media.width).toBeUndefined();
|
|
expect(media.height).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Media Tags', () => {
|
|
beforeEach(() => {
|
|
mockFiles.set('/source/image.jpg', Buffer.from('image-data'));
|
|
});
|
|
|
|
it('should store tags', async () => {
|
|
const media = await mediaEngine.importMedia('/source/image.jpg', {
|
|
tags: ['landscape', 'outdoor', 'nature'],
|
|
});
|
|
|
|
expect(media.tags).toEqual(['landscape', 'outdoor', 'nature']);
|
|
});
|
|
|
|
it('should default to empty tags array', async () => {
|
|
const media = await mediaEngine.importMedia('/source/image.jpg');
|
|
|
|
expect(media.tags).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('Event Emission', () => {
|
|
it('should be an EventEmitter', () => {
|
|
expect(mediaEngine.on).toBeDefined();
|
|
expect(mediaEngine.emit).toBeDefined();
|
|
expect(mediaEngine.removeListener).toBeDefined();
|
|
});
|
|
|
|
it('should allow adding event listeners', () => {
|
|
const listener = vi.fn();
|
|
mediaEngine.on('testEvent', listener);
|
|
mediaEngine.emit('testEvent', { data: 'test' });
|
|
|
|
expect(listener).toHaveBeenCalledWith({ data: 'test' });
|
|
});
|
|
|
|
it('should allow removing event listeners', () => {
|
|
const listener = vi.fn();
|
|
mediaEngine.on('testEvent', listener);
|
|
mediaEngine.removeListener('testEvent', listener);
|
|
mediaEngine.emit('testEvent', { data: 'test' });
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('getMediaPath', () => {
|
|
it('should return path in media directory', () => {
|
|
const path = mediaEngine.getMediaPath('test-id');
|
|
expect(path).toContain('test-id');
|
|
expect(path).toContain('media');
|
|
});
|
|
});
|
|
|
|
describe('getRelativePath', () => {
|
|
it('should return relative path from dataDir for a media item', async () => {
|
|
mediaEngine.setProjectContext('test-project', '/projects/my-blog');
|
|
|
|
const selectChain = createSelectChain();
|
|
selectChain.get.mockResolvedValue({
|
|
id: 'abc-123',
|
|
filePath: '/projects/my-blog/media/2025/01/abc-123.jpg',
|
|
});
|
|
vi.mocked(mockLocalDb.select).mockReturnValue(selectChain as any);
|
|
|
|
const result = await mediaEngine.getRelativePath('abc-123');
|
|
|
|
expect(result).toBe('media/2025/01/abc-123.jpg');
|
|
});
|
|
|
|
it('should return null when media item is not found', async () => {
|
|
mediaEngine.setProjectContext('test-project', '/projects/my-blog');
|
|
|
|
const selectChain = createSelectChain();
|
|
selectChain.get.mockResolvedValue(undefined);
|
|
vi.mocked(mockLocalDb.select).mockReturnValue(selectChain as any);
|
|
|
|
const result = await mediaEngine.getRelativePath('nonexistent');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return null when filePath is empty', async () => {
|
|
mediaEngine.setProjectContext('test-project', '/projects/my-blog');
|
|
|
|
const selectChain = createSelectChain();
|
|
selectChain.get.mockResolvedValue({ id: 'abc-123', filePath: '' });
|
|
vi.mocked(mockLocalDb.select).mockReturnValue(selectChain as any);
|
|
|
|
const result = await mediaEngine.getRelativePath('abc-123');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Multiple Media Import', () => {
|
|
beforeEach(() => {
|
|
mockFiles.set('/source/image1.jpg', Buffer.from('image1-data'));
|
|
mockFiles.set('/source/image2.jpg', Buffer.from('image2-data'));
|
|
mockFiles.set('/source/image3.png', Buffer.from('image3-data'));
|
|
});
|
|
|
|
it('should import multiple media with unique IDs', async () => {
|
|
const media1 = await mediaEngine.importMedia('/source/image1.jpg');
|
|
const media2 = await mediaEngine.importMedia('/source/image2.jpg');
|
|
|
|
expect(media1.id).toBeDefined();
|
|
expect(media2.id).toBeDefined();
|
|
expect(media1.id).not.toBe(media2.id);
|
|
});
|
|
|
|
it('should handle different file types', async () => {
|
|
const jpg = await mediaEngine.importMedia('/source/image1.jpg');
|
|
const png = await mediaEngine.importMedia('/source/image3.png');
|
|
|
|
expect(jpg.mimeType).toBe('image/jpeg');
|
|
expect(png.mimeType).toBe('image/png');
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
it('should throw when source file does not exist', async () => {
|
|
await expect(mediaEngine.importMedia('/source/nonexistent.jpg')).rejects.toThrow('ENOENT');
|
|
});
|
|
});
|
|
|
|
describe('Title, Alt Text and Caption', () => {
|
|
beforeEach(() => {
|
|
mockFiles.set('/source/image.jpg', Buffer.from('image-data'));
|
|
});
|
|
|
|
it('should store title for display in lists and search', async () => {
|
|
const media = await mediaEngine.importMedia('/source/image.jpg', {
|
|
title: 'Mountain Sunrise Photo',
|
|
});
|
|
|
|
expect(media.title).toBe('Mountain Sunrise Photo');
|
|
});
|
|
|
|
it('should store alt text for accessibility', async () => {
|
|
const media = await mediaEngine.importMedia('/source/image.jpg', {
|
|
alt: 'A scenic mountain view',
|
|
});
|
|
|
|
expect(media.alt).toBe('A scenic mountain view');
|
|
});
|
|
|
|
it('should store caption for display', async () => {
|
|
const media = await mediaEngine.importMedia('/source/image.jpg', {
|
|
caption: 'Photo taken at Mt. Rainier, 2024',
|
|
});
|
|
|
|
expect(media.caption).toBe('Photo taken at Mt. Rainier, 2024');
|
|
});
|
|
|
|
it('should handle media without title, alt or caption', async () => {
|
|
const media = await mediaEngine.importMedia('/source/image.jpg');
|
|
|
|
expect(media.title).toBeUndefined();
|
|
expect(media.alt).toBeUndefined();
|
|
expect(media.caption).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Date-based folder structure', () => {
|
|
beforeEach(() => {
|
|
mockFiles.set('/source/dated-image.jpg', Buffer.from('image-data'));
|
|
});
|
|
|
|
it('should store media in YYYY/MM folder based on createdAt date', async () => {
|
|
const fs = await import('fs/promises');
|
|
|
|
const media = await mediaEngine.importMedia('/source/dated-image.jpg');
|
|
|
|
const copyCall = vi.mocked(fs.copyFile).mock.calls[0];
|
|
expect(copyCall).toBeDefined();
|
|
|
|
const destPath = copyCall[1] as string;
|
|
const year = media.createdAt.getFullYear();
|
|
const month = (media.createdAt.getMonth() + 1).toString().padStart(2, '0');
|
|
|
|
// Path should contain YYYY/MM structure (handle both / and \ separators)
|
|
expect(destPath).toMatch(new RegExp(`[/\\\\]${year}[/\\\\]${month}[/\\\\]`));
|
|
});
|
|
|
|
it('should create nested year/month directories on media import', async () => {
|
|
const fs = await import('fs/promises');
|
|
|
|
await mediaEngine.importMedia('/source/dated-image.jpg');
|
|
|
|
// mkdir should be called with recursive: true
|
|
expect(fs.mkdir).toHaveBeenCalled();
|
|
const mkdirCalls = vi.mocked(fs.mkdir).mock.calls;
|
|
|
|
// Should have created directory containing year/month structure
|
|
const yearMonthDirCall = mkdirCalls.find((call) => {
|
|
const dirPath = call[0] as string;
|
|
return dirPath.match(/[/\\]\d{4}[/\\]\d{2}$/);
|
|
});
|
|
expect(yearMonthDirCall).toBeDefined();
|
|
});
|
|
|
|
it('should return correct path via getMediaPath method', async () => {
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = (now.getMonth() + 1).toString().padStart(2, '0');
|
|
|
|
const mediaPath = mediaEngine.getMediaPathForDate('test-uuid', 'jpg', now);
|
|
|
|
// Handle both Windows (\) and Unix (/) path separators
|
|
expect(mediaPath).toMatch(new RegExp(`[/\\\\]${year}[/\\\\]${month}[/\\\\]`));
|
|
expect(mediaPath).toContain('test-uuid.jpg');
|
|
});
|
|
|
|
it('should handle media from previous years correctly', async () => {
|
|
const oldDate = new Date('2021-06-20');
|
|
const mediaPath = mediaEngine.getMediaPathForDate('old-id', 'png', oldDate);
|
|
|
|
expect(mediaPath).toMatch(/[/\\]2021[/\\]06[/\\]/);
|
|
expect(mediaPath).toContain('old-id.png');
|
|
});
|
|
|
|
it('should use zero-padded month numbers (01-12)', async () => {
|
|
const january = new Date('2024-01-15');
|
|
const december = new Date('2024-12-15');
|
|
|
|
const januaryPath = mediaEngine.getMediaPathForDate('jan-id', 'jpg', january);
|
|
const decemberPath = mediaEngine.getMediaPathForDate('dec-id', 'jpg', december);
|
|
|
|
expect(januaryPath).toMatch(/[/\\]2024[/\\]01[/\\]/);
|
|
expect(decemberPath).toMatch(/[/\\]2024[/\\]12[/\\]/);
|
|
});
|
|
});
|
|
|
|
describe('rebuildDatabaseFromFiles', () => {
|
|
beforeEach(() => {
|
|
mediaEngine.setProjectContext('test-project');
|
|
});
|
|
|
|
it('should delete post-media links for the project during rebuild', async () => {
|
|
const fs = await import('fs/promises');
|
|
|
|
// Mock readdir to return media with sidecar
|
|
vi.mocked(fs.readdir).mockImplementation(async (dir: string, options?: any) => {
|
|
if (typeof dir === 'string' && dir.includes('media')) {
|
|
return [
|
|
{ name: 'media-1.jpg', isFile: () => true, isDirectory: () => false },
|
|
{ name: 'media-1.jpg.meta', isFile: () => true, isDirectory: () => false },
|
|
] as any;
|
|
}
|
|
return [];
|
|
});
|
|
|
|
// Set up sidecar file with linkedPostIds
|
|
const sidecarContent = `id: media-1
|
|
originalName: test-image.jpg
|
|
mimeType: image/jpeg
|
|
size: 1024
|
|
createdAt: 2024-01-15T10:00:00.000Z
|
|
updatedAt: 2024-01-15T10:00:00.000Z
|
|
linkedPostIds: ["post-1", "post-2"]`;
|
|
mockFiles.set('/mock/userData/projects/test-project/media/media-1.jpg.meta', sidecarContent);
|
|
mockFiles.set('/mock/userData/projects/test-project/media/media-1.jpg', Buffer.from('image-data'));
|
|
|
|
await mediaEngine.rebuildDatabaseFromFiles();
|
|
|
|
expect(postMediaDeleteCalled).toBe(true);
|
|
});
|
|
|
|
it('should insert post-media links based on linkedPostIds from sidecar files', async () => {
|
|
const fs = await import('fs/promises');
|
|
|
|
// Mock readdir to simulate directory traversal
|
|
vi.mocked(fs.readdir).mockImplementation(async (dir: string, options?: any) => {
|
|
if (typeof dir === 'string' && dir.includes('media')) {
|
|
return [
|
|
{ name: 'media-1.jpg', isFile: () => true, isDirectory: () => false },
|
|
{ name: 'media-1.jpg.meta', isFile: () => true, isDirectory: () => false },
|
|
] as any;
|
|
}
|
|
return [];
|
|
});
|
|
|
|
// Set up sidecar file with linkedPostIds
|
|
const sidecarContent = `id: media-1
|
|
originalName: test-image.jpg
|
|
mimeType: image/jpeg
|
|
size: 1024
|
|
createdAt: 2024-01-15T10:00:00.000Z
|
|
updatedAt: 2024-01-15T10:00:00.000Z
|
|
linkedPostIds: ["post-1", "post-2"]`;
|
|
mockFiles.set('/mock/userData/projects/test-project/media/media-1.jpg.meta', sidecarContent);
|
|
mockFiles.set('/mock/userData/projects/test-project/media/media-1.jpg', Buffer.from('image-data'));
|
|
|
|
await mediaEngine.rebuildDatabaseFromFiles();
|
|
|
|
// Should have inserted 2 post-media links
|
|
expect(postMediaInserts).toHaveLength(2);
|
|
expect(postMediaInserts[0].postId).toBe('post-1');
|
|
expect(postMediaInserts[0].mediaId).toBe('media-1');
|
|
expect(postMediaInserts[1].postId).toBe('post-2');
|
|
expect(postMediaInserts[1].mediaId).toBe('media-1');
|
|
});
|
|
|
|
it('should handle media without linkedPostIds', async () => {
|
|
const fs = await import('fs/promises');
|
|
|
|
vi.mocked(fs.readdir).mockImplementation(async (dir: string, options?: any) => {
|
|
if (typeof dir === 'string' && dir.includes('media')) {
|
|
return [
|
|
{ name: 'media-1.jpg', isFile: () => true, isDirectory: () => false },
|
|
{ name: 'media-1.jpg.meta', isFile: () => true, isDirectory: () => false },
|
|
] as any;
|
|
}
|
|
return [];
|
|
});
|
|
|
|
// Sidecar without linkedPostIds
|
|
const sidecarContent = `id: media-1
|
|
originalName: test-image.jpg
|
|
mimeType: image/jpeg
|
|
size: 1024
|
|
createdAt: 2024-01-15T10:00:00.000Z
|
|
updatedAt: 2024-01-15T10:00:00.000Z`;
|
|
mockFiles.set('/mock/userData/projects/test-project/media/media-1.jpg.meta', sidecarContent);
|
|
mockFiles.set('/mock/userData/projects/test-project/media/media-1.jpg', Buffer.from('image-data'));
|
|
|
|
await mediaEngine.rebuildDatabaseFromFiles();
|
|
|
|
// Should not insert any post-media links
|
|
expect(postMediaInserts).toHaveLength(0);
|
|
});
|
|
|
|
it('should set correct sortOrder for post-media links', async () => {
|
|
const fs = await import('fs/promises');
|
|
|
|
vi.mocked(fs.readdir).mockImplementation(async (dir: string, options?: any) => {
|
|
if (typeof dir === 'string' && dir.includes('media')) {
|
|
return [
|
|
{ name: 'media-1.jpg', isFile: () => true, isDirectory: () => false },
|
|
{ name: 'media-1.jpg.meta', isFile: () => true, isDirectory: () => false },
|
|
] as any;
|
|
}
|
|
return [];
|
|
});
|
|
|
|
// Sidecar with multiple linked posts
|
|
const sidecarContent = `id: media-1
|
|
originalName: test-image.jpg
|
|
mimeType: image/jpeg
|
|
size: 1024
|
|
createdAt: 2024-01-15T10:00:00.000Z
|
|
updatedAt: 2024-01-15T10:00:00.000Z
|
|
linkedPostIds: ["post-a", "post-b", "post-c"]`;
|
|
mockFiles.set('/mock/userData/projects/test-project/media/media-1.jpg.meta', sidecarContent);
|
|
mockFiles.set('/mock/userData/projects/test-project/media/media-1.jpg', Buffer.from('image-data'));
|
|
|
|
await mediaEngine.rebuildDatabaseFromFiles();
|
|
|
|
// Verify sortOrder is set correctly (0, 1, 2)
|
|
expect(postMediaInserts).toHaveLength(3);
|
|
expect(postMediaInserts[0].sortOrder).toBe(0);
|
|
expect(postMediaInserts[1].sortOrder).toBe(1);
|
|
expect(postMediaInserts[2].sortOrder).toBe(2);
|
|
});
|
|
|
|
it('should restore title from sidecar metadata during rebuild', async () => {
|
|
const fs = await import('fs/promises');
|
|
|
|
vi.mocked(fs.readdir).mockImplementation(async (dir: string, options?: any) => {
|
|
if (typeof dir === 'string' && dir.includes('media')) {
|
|
return [
|
|
{ name: 'media-1.jpg', isFile: () => true, isDirectory: () => false },
|
|
{ name: 'media-1.jpg.meta', isFile: () => true, isDirectory: () => false },
|
|
] as any;
|
|
}
|
|
return [];
|
|
});
|
|
|
|
// Sidecar with title field
|
|
const sidecarContent = `id: media-1
|
|
originalName: test-image.jpg
|
|
mimeType: image/jpeg
|
|
size: 1024
|
|
title: My Beautiful Sunset Photo
|
|
alt: A sunset over the ocean
|
|
caption: Taken during vacation
|
|
createdAt: 2024-01-15T10:00:00.000Z
|
|
updatedAt: 2024-01-15T10:00:00.000Z
|
|
tags: ["nature", "sunset"]`;
|
|
mockFiles.set('/mock/userData/projects/test-project/media/media-1.jpg.meta', sidecarContent);
|
|
mockFiles.set('/mock/userData/projects/test-project/media/media-1.jpg', Buffer.from('image-data'));
|
|
|
|
await mediaEngine.rebuildDatabaseFromFiles();
|
|
|
|
// Verify title is stored in database
|
|
const insertedMedia = mockMedia.get('media-1');
|
|
expect(insertedMedia).toBeDefined();
|
|
expect(insertedMedia.title).toBe('My Beautiful Sunset Photo');
|
|
expect(insertedMedia.alt).toBe('A sunset over the ocean');
|
|
expect(insertedMedia.caption).toBe('Taken during vacation');
|
|
});
|
|
});
|
|
|
|
describe('Full-Text Search', () => {
|
|
it('should search media using FTS with stemmed query', async () => {
|
|
// Search for media
|
|
await mediaEngine.searchMedia('beautiful sunset');
|
|
|
|
// Should have called the FTS search
|
|
const ftsSearchCall = ftsExecuteCalls.find(c => c.sql.includes('media_fts MATCH'));
|
|
expect(ftsSearchCall).toBeDefined();
|
|
expect(ftsSearchCall?.args?.[0]).toBe('default'); // project_id
|
|
// The query should be stemmed (e.g., 'beautiful sunset' -> 'beauti sunset')
|
|
expect(ftsSearchCall?.args?.[1]).toBeDefined();
|
|
});
|
|
|
|
it('should filter search by project_id', async () => {
|
|
mediaEngine.setProjectContext('my-project');
|
|
await mediaEngine.searchMedia('test');
|
|
|
|
const ftsSearchCall = ftsExecuteCalls.find(c => c.sql.includes('media_fts MATCH'));
|
|
expect(ftsSearchCall?.args?.[0]).toBe('my-project');
|
|
});
|
|
|
|
it('should return empty array when no results found', async () => {
|
|
const results = await mediaEngine.searchMedia('nonexistent');
|
|
expect(results).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('getMedia', () => {
|
|
it('should return null for non-existent media', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
get: vi.fn().mockResolvedValue(null),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
const result = await mediaEngine.getMedia('non-existent-id');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return media by ID', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
get: vi.fn().mockResolvedValue({
|
|
id: 'media-id',
|
|
projectId: 'default',
|
|
originalName: 'test.jpg',
|
|
mimeType: 'image/jpeg',
|
|
size: 1024,
|
|
filePath: '/mock/media/test.jpg',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
const result = await mediaEngine.getMedia('media-id');
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.id).toBe('media-id');
|
|
expect(result?.originalName).toBe('test.jpg');
|
|
});
|
|
});
|
|
|
|
describe('updateMedia', () => {
|
|
it('should return null for non-existent media', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
get: vi.fn().mockResolvedValue(null),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
const result = await mediaEngine.updateMedia('non-existent-id', { title: 'New title' });
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should update media title', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
get: vi.fn().mockResolvedValue({
|
|
id: 'media-id',
|
|
projectId: 'default',
|
|
originalName: 'test.jpg',
|
|
mimeType: 'image/jpeg',
|
|
size: 1024,
|
|
filePath: '/mock/media/test.jpg',
|
|
title: 'Old title',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
const result = await mediaEngine.updateMedia('media-id', { title: 'New title' });
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.title).toBe('New title');
|
|
});
|
|
|
|
it('should update media caption', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
get: vi.fn().mockResolvedValue({
|
|
id: 'media-id',
|
|
projectId: 'default',
|
|
originalName: 'test.jpg',
|
|
mimeType: 'image/jpeg',
|
|
size: 1024,
|
|
filePath: '/mock/media/test.jpg',
|
|
caption: 'Old caption',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
const result = await mediaEngine.updateMedia('media-id', { caption: 'New caption' });
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.caption).toBe('New caption');
|
|
});
|
|
|
|
it('should update media alt text', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
get: vi.fn().mockResolvedValue({
|
|
id: 'media-id',
|
|
projectId: 'default',
|
|
originalName: 'test.jpg',
|
|
mimeType: 'image/jpeg',
|
|
size: 1024,
|
|
filePath: '/mock/media/test.jpg',
|
|
alt: '',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
const result = await mediaEngine.updateMedia('media-id', { alt: 'Descriptive alt text' });
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.alt).toBe('Descriptive alt text');
|
|
});
|
|
|
|
it('should emit mediaUpdated event', async () => {
|
|
const handler = vi.fn();
|
|
mediaEngine.on('mediaUpdated', handler);
|
|
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
get: vi.fn().mockResolvedValue({
|
|
id: 'event-media-id',
|
|
projectId: 'default',
|
|
originalName: 'test.jpg',
|
|
mimeType: 'image/jpeg',
|
|
size: 1024,
|
|
filePath: '/mock/media/test.jpg',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
await mediaEngine.updateMedia('event-media-id', { caption: 'Updated' });
|
|
|
|
expect(handler).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: 'event-media-id' })
|
|
);
|
|
});
|
|
|
|
it('should update sidecar file', async () => {
|
|
const fs = await import('fs/promises');
|
|
const filePath = '/mock/media/test.jpg';
|
|
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
get: vi.fn().mockResolvedValue({
|
|
id: 'sidecar-media-id',
|
|
projectId: 'default',
|
|
originalName: 'test.jpg',
|
|
mimeType: 'image/jpeg',
|
|
size: 1024,
|
|
filePath,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
vi.mocked(fs.writeFile).mockClear();
|
|
await mediaEngine.updateMedia('sidecar-media-id', { caption: 'Test' });
|
|
|
|
// Should write to sidecar file (with optional encoding parameter)
|
|
expect(fs.writeFile).toHaveBeenCalledWith(
|
|
`${filePath}.meta`,
|
|
expect.any(String),
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
it('should update FTS index', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
get: vi.fn().mockResolvedValue({
|
|
id: 'fts-media-id',
|
|
projectId: 'default',
|
|
originalName: 'test.jpg',
|
|
mimeType: 'image/jpeg',
|
|
size: 1024,
|
|
filePath: '/mock/media/test.jpg',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
ftsExecuteCalls = [];
|
|
await mediaEngine.updateMedia('fts-media-id', { caption: 'Searchable caption' });
|
|
|
|
const ftsUpdate = ftsExecuteCalls.find(c => c.sql.includes('media_fts'));
|
|
expect(ftsUpdate).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('deleteMedia', () => {
|
|
it('should return false for non-existent media', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
get: vi.fn().mockResolvedValue(null),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
const result = await mediaEngine.deleteMedia('non-existent-id');
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should delete media file from filesystem', async () => {
|
|
const fs = await import('fs/promises');
|
|
const filePath = '/mock/media/delete-me.jpg';
|
|
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
get: vi.fn().mockResolvedValue({
|
|
id: 'delete-media-id',
|
|
filePath,
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
vi.mocked(fs.unlink).mockClear();
|
|
await mediaEngine.deleteMedia('delete-media-id');
|
|
|
|
expect(fs.unlink).toHaveBeenCalledWith(filePath);
|
|
});
|
|
|
|
it('should delete sidecar file', async () => {
|
|
const fs = await import('fs/promises');
|
|
const filePath = '/mock/media/delete-me.jpg';
|
|
const sidecarPath = `${filePath}.meta`;
|
|
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
get: vi.fn().mockResolvedValue({
|
|
id: 'delete-media-id',
|
|
filePath,
|
|
sidecarPath,
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
vi.mocked(fs.unlink).mockClear();
|
|
await mediaEngine.deleteMedia('delete-media-id');
|
|
|
|
expect(fs.unlink).toHaveBeenCalledWith(sidecarPath);
|
|
});
|
|
|
|
it('should delete from database', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
get: vi.fn().mockResolvedValue({
|
|
id: 'db-delete-id',
|
|
filePath: '/mock/media/test.jpg',
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
await mediaEngine.deleteMedia('db-delete-id');
|
|
|
|
expect(mockLocalDb.delete).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should emit mediaDeleted event', async () => {
|
|
const handler = vi.fn();
|
|
mediaEngine.on('mediaDeleted', handler);
|
|
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
get: vi.fn().mockResolvedValue({
|
|
id: 'event-delete-id',
|
|
filePath: '/mock/media/test.jpg',
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
await mediaEngine.deleteMedia('event-delete-id');
|
|
|
|
expect(handler).toHaveBeenCalledWith('event-delete-id');
|
|
});
|
|
|
|
it('should delete from FTS index', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
get: vi.fn().mockResolvedValue({
|
|
id: 'fts-delete-id',
|
|
filePath: '/mock/media/test.jpg',
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
ftsExecuteCalls = [];
|
|
await mediaEngine.deleteMedia('fts-delete-id');
|
|
|
|
const ftsDelete = ftsExecuteCalls.find(c => c.sql.includes('DELETE FROM media_fts'));
|
|
expect(ftsDelete).toBeDefined();
|
|
});
|
|
|
|
it('should handle file deletion error gracefully', async () => {
|
|
const fs = await import('fs/promises');
|
|
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
get: vi.fn().mockResolvedValue({
|
|
id: 'error-delete-id',
|
|
filePath: '/mock/media/missing-file.jpg',
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
vi.mocked(fs.unlink).mockRejectedValueOnce(new Error('ENOENT'));
|
|
|
|
// Should not throw
|
|
const result = await mediaEngine.deleteMedia('error-delete-id');
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should return true on successful deletion', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
get: vi.fn().mockResolvedValue({
|
|
id: 'success-delete-id',
|
|
filePath: '/mock/media/test.jpg',
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
const result = await mediaEngine.deleteMedia('success-delete-id');
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('getAllMedia', () => {
|
|
it('should return empty array when no media exists', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
orderBy: vi.fn().mockReturnThis(),
|
|
all: vi.fn().mockResolvedValue([]),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
const result = await mediaEngine.getAllMedia();
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should return all media for current project', async () => {
|
|
mediaEngine.setProjectContext('media-project');
|
|
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
orderBy: vi.fn().mockReturnThis(),
|
|
all: vi.fn().mockResolvedValue([
|
|
{ id: 'm1', originalName: 'image1.jpg', projectId: 'media-project' },
|
|
{ id: 'm2', originalName: 'image2.png', projectId: 'media-project' },
|
|
]),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
const result = await mediaEngine.getAllMedia();
|
|
expect(result).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('getMediaFiltered', () => {
|
|
it('should filter by MIME type', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.from = vi.fn().mockReturnValue({
|
|
...chain,
|
|
where: vi.fn().mockReturnValue({
|
|
...chain,
|
|
orderBy: vi.fn().mockReturnValue({
|
|
...chain,
|
|
all: vi.fn().mockResolvedValue([
|
|
{ id: 'm1', mimeType: 'image/jpeg', tags: '[]' },
|
|
]),
|
|
}),
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
const result = await mediaEngine.getMediaFiltered({ mimeType: 'image/jpeg' });
|
|
expect(result).toHaveLength(1);
|
|
});
|
|
|
|
it('should filter by date range', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.from = vi.fn().mockReturnValue({
|
|
...chain,
|
|
where: vi.fn().mockReturnValue({
|
|
...chain,
|
|
orderBy: vi.fn().mockReturnValue({
|
|
...chain,
|
|
all: vi.fn().mockResolvedValue([
|
|
{ id: 'm1', mimeType: 'image/jpeg', createdAt: new Date('2024-01-15'), tags: '[]' },
|
|
]),
|
|
}),
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
const result = await mediaEngine.getMediaFiltered({
|
|
startDate: new Date('2024-01-01'),
|
|
endDate: new Date('2024-01-31')
|
|
});
|
|
expect(result).toHaveLength(1);
|
|
});
|
|
|
|
it('should filter by year and month', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.from = vi.fn().mockReturnValue({
|
|
...chain,
|
|
where: vi.fn().mockReturnValue({
|
|
...chain,
|
|
orderBy: vi.fn().mockReturnValue({
|
|
...chain,
|
|
all: vi.fn().mockResolvedValue([
|
|
{ id: 'm1', mimeType: 'image/jpeg', createdAt: new Date('2024-06-15'), tags: '[]' },
|
|
]),
|
|
}),
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
const result = await mediaEngine.getMediaFiltered({ year: 2024, month: 5 }); // June (0-indexed)
|
|
expect(result).toHaveLength(1);
|
|
});
|
|
|
|
it('should filter by tags on client side', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.from = vi.fn().mockReturnValue({
|
|
...chain,
|
|
where: vi.fn().mockReturnValue({
|
|
...chain,
|
|
orderBy: vi.fn().mockReturnValue({
|
|
...chain,
|
|
all: vi.fn().mockResolvedValue([
|
|
{ id: 'm1', mimeType: 'image/jpeg', tags: '["nature", "landscape"]' },
|
|
{ id: 'm2', mimeType: 'image/jpeg', tags: '["portrait"]' },
|
|
]),
|
|
}),
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
const result = await mediaEngine.getMediaFiltered({ tags: ['nature'] });
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].id).toBe('m1');
|
|
});
|
|
});
|
|
|
|
describe('getMediaByYearMonth', () => {
|
|
it('should return empty array when no media exists', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.from = vi.fn().mockReturnValue({
|
|
...chain,
|
|
where: vi.fn().mockReturnValue({
|
|
...chain,
|
|
orderBy: vi.fn().mockReturnValue({
|
|
...chain,
|
|
all: vi.fn().mockResolvedValue([]),
|
|
}),
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
const result = await mediaEngine.getMediaByYearMonth();
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should group media by year and month', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.from = vi.fn().mockReturnValue({
|
|
...chain,
|
|
where: vi.fn().mockReturnValue({
|
|
...chain,
|
|
orderBy: vi.fn().mockReturnValue({
|
|
...chain,
|
|
all: vi.fn().mockResolvedValue([
|
|
{ createdAt: new Date('2024-01-15'), tags: '[]' },
|
|
{ createdAt: new Date('2024-01-20'), tags: '[]' },
|
|
{ createdAt: new Date('2024-02-10'), tags: '[]' },
|
|
]),
|
|
}),
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
const result = await mediaEngine.getMediaByYearMonth();
|
|
|
|
// Note: month is 0-indexed from Date.getMonth()
|
|
expect(result).toContainEqual({ year: 2024, month: 0, count: 2 }); // January
|
|
expect(result).toContainEqual({ year: 2024, month: 1, count: 1 }); // February
|
|
});
|
|
|
|
it('should sort by year and month descending', async () => {
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.from = vi.fn().mockReturnValue({
|
|
...chain,
|
|
where: vi.fn().mockReturnValue({
|
|
...chain,
|
|
orderBy: vi.fn().mockReturnValue({
|
|
...chain,
|
|
all: vi.fn().mockResolvedValue([
|
|
{ createdAt: new Date('2023-06-01'), tags: '[]' },
|
|
{ createdAt: new Date('2024-03-01'), tags: '[]' },
|
|
]),
|
|
}),
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
const result = await mediaEngine.getMediaByYearMonth();
|
|
|
|
expect(result[0].year).toBe(2024);
|
|
expect(result[0].month).toBe(2); // March is month 2 (0-indexed)
|
|
});
|
|
});
|
|
|
|
describe('generateThumbnails', () => {
|
|
it('should skip non-image media', async () => {
|
|
const fs = await import('fs/promises');
|
|
|
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
|
const chain = createSelectChain();
|
|
chain.where = vi.fn().mockReturnValue({
|
|
...chain,
|
|
get: vi.fn().mockResolvedValue({
|
|
id: 'pdf-id',
|
|
mimeType: 'application/pdf',
|
|
filePath: '/mock/media/document.pdf',
|
|
}),
|
|
});
|
|
return chain;
|
|
});
|
|
|
|
vi.mocked(fs.writeFile).mockClear();
|
|
await mediaEngine.generateThumbnails('pdf-id');
|
|
|
|
// Should not write any thumbnail files for non-images
|
|
const thumbnailWrites = vi.mocked(fs.writeFile).mock.calls.filter(
|
|
call => String(call[0]).includes('thumbnail')
|
|
);
|
|
expect(thumbnailWrites).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('getThumbnailPaths', () => {
|
|
it('should return object with all size keys', async () => {
|
|
// getThumbnailPaths checks fs.access for each thumbnail
|
|
// By default our mock throws ENOENT, so all paths will be null
|
|
const paths = await mediaEngine.getThumbnailPaths('test-media-id');
|
|
|
|
expect(paths).toHaveProperty('small');
|
|
expect(paths).toHaveProperty('medium');
|
|
expect(paths).toHaveProperty('large');
|
|
});
|
|
|
|
it('should return null for non-existent thumbnails', async () => {
|
|
const paths = await mediaEngine.getThumbnailPaths('non-existent-media-id');
|
|
|
|
expect(paths.small).toBeNull();
|
|
expect(paths.medium).toBeNull();
|
|
expect(paths.large).toBeNull();
|
|
});
|
|
|
|
it('should return paths when thumbnails exist', async () => {
|
|
const fs = await import('fs/promises');
|
|
const mediaId = 'existing-media-id';
|
|
|
|
// Mock fs.access to succeed for small thumbnail
|
|
vi.mocked(fs.access).mockImplementation(async (path: any) => {
|
|
if (path.includes(`${mediaId}-small.webp`)) {
|
|
return undefined; // File exists
|
|
}
|
|
throw new Error('ENOENT');
|
|
});
|
|
|
|
const paths = await mediaEngine.getThumbnailPaths(mediaId);
|
|
|
|
expect(paths.small).not.toBeNull();
|
|
expect(paths.small).toContain(`${mediaId}-small.webp`);
|
|
expect(paths.medium).toBeNull();
|
|
expect(paths.large).toBeNull();
|
|
});
|
|
});
|
|
});
|