fix: thumbnail generation on image change
This commit is contained in:
@@ -611,6 +611,78 @@ export class MediaEngine extends EventEmitter {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the actual file content for an existing media item.
|
||||||
|
* This will:
|
||||||
|
* - Check if the new file has a different checksum
|
||||||
|
* - Replace the file if checksum differs
|
||||||
|
* - Update size, dimensions (for images), and checksum in database
|
||||||
|
* - Regenerate thumbnails for images
|
||||||
|
*
|
||||||
|
* @returns The updated MediaData if file was replaced, null if media not found or checksum unchanged
|
||||||
|
*/
|
||||||
|
async replaceMediaFile(id: string, newSourcePath: string): Promise<MediaData | null> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
const dbMedia = await db.select().from(media).where(eq(media.id, id)).get();
|
||||||
|
|
||||||
|
if (!dbMedia) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the new source file
|
||||||
|
const newBuffer = await fs.readFile(newSourcePath);
|
||||||
|
const newChecksum = this.calculateChecksum(newBuffer);
|
||||||
|
|
||||||
|
// If checksum is the same, no need to replace
|
||||||
|
if (dbMedia.checksum === newChecksum) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy new file to existing location
|
||||||
|
await fs.copyFile(newSourcePath, dbMedia.filePath);
|
||||||
|
|
||||||
|
// Get new dimensions for images
|
||||||
|
let width = dbMedia.width;
|
||||||
|
let height = dbMedia.height;
|
||||||
|
if (dbMedia.mimeType.startsWith('image/') && !dbMedia.mimeType.includes('svg')) {
|
||||||
|
try {
|
||||||
|
const sharp = (await import('sharp')).default;
|
||||||
|
const imageMetadata = await sharp(dbMedia.filePath).metadata();
|
||||||
|
width = imageMetadata.width ?? width;
|
||||||
|
height = imageMetadata.height ?? height;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get image dimensions:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
await db.update(media)
|
||||||
|
.set({
|
||||||
|
size: newBuffer.length,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
checksum: newChecksum,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(media.id, id));
|
||||||
|
|
||||||
|
// Regenerate thumbnails for images
|
||||||
|
if (dbMedia.mimeType.startsWith('image/') && !dbMedia.mimeType.includes('svg')) {
|
||||||
|
// Await thumbnail generation to ensure it completes before returning
|
||||||
|
await this.generateThumbnails(id, dbMedia.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the updated media data
|
||||||
|
const updated = await this.getMedia(id);
|
||||||
|
if (updated) {
|
||||||
|
this.emit('mediaFileReplaced', updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async deleteMedia(id: string): Promise<boolean> {
|
async deleteMedia(id: string): Promise<boolean> {
|
||||||
const db = getDatabase().getLocal();
|
const db = getDatabase().getLocal();
|
||||||
const existing = await db.select().from(media).where(eq(media.id, id)).get();
|
const existing = await db.select().from(media).where(eq(media.id, id)).get();
|
||||||
|
|||||||
@@ -326,6 +326,43 @@ export function registerIpcHandlers(): void {
|
|||||||
return engine.updateMedia(id, data);
|
return engine.updateMedia(id, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
safeHandle('media:replaceFile', async (_, id: string, newSourcePath: string) => {
|
||||||
|
const engine = getMediaEngine();
|
||||||
|
return engine.replaceMediaFile(id, newSourcePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('media:replaceFileDialog', async (_, id: string) => {
|
||||||
|
// Get the current media to determine file type filter
|
||||||
|
const engine = getMediaEngine();
|
||||||
|
const currentMedia = await engine.getMedia(id);
|
||||||
|
if (!currentMedia) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build filter based on current media type
|
||||||
|
let filters: { name: string; extensions: string[] }[] = [];
|
||||||
|
if (currentMedia.mimeType.startsWith('image/')) {
|
||||||
|
filters = [
|
||||||
|
{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'] },
|
||||||
|
{ name: 'All Files', extensions: ['*'] },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
filters = [{ name: 'All Files', extensions: ['*'] }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await dialog.showOpenDialog({
|
||||||
|
title: 'Replace Media File',
|
||||||
|
filters,
|
||||||
|
properties: ['openFile'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || result.filePaths.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return engine.replaceMediaFile(id, result.filePaths[0]);
|
||||||
|
});
|
||||||
|
|
||||||
safeHandle('media:delete', async (_, id: string) => {
|
safeHandle('media:delete', async (_, id: string) => {
|
||||||
const engine = getMediaEngine();
|
const engine = getMediaEngine();
|
||||||
return engine.deleteMedia(id);
|
return engine.deleteMedia(id);
|
||||||
@@ -979,6 +1016,7 @@ export function registerIpcHandlers(): void {
|
|||||||
mediaEngine.on('mediaImported', forwardEvent('media:imported'));
|
mediaEngine.on('mediaImported', forwardEvent('media:imported'));
|
||||||
mediaEngine.on('mediaUpdated', forwardEvent('media:updated'));
|
mediaEngine.on('mediaUpdated', forwardEvent('media:updated'));
|
||||||
mediaEngine.on('mediaDeleted', forwardEvent('media:deleted'));
|
mediaEngine.on('mediaDeleted', forwardEvent('media:deleted'));
|
||||||
|
mediaEngine.on('mediaFileReplaced', forwardEvent('media:fileReplaced'));
|
||||||
mediaEngine.on('rebuildStarted', forwardEvent('media:rebuildStarted'));
|
mediaEngine.on('rebuildStarted', forwardEvent('media:rebuildStarted'));
|
||||||
mediaEngine.on('databaseRebuilt', forwardEvent('media:databaseRebuilt'));
|
mediaEngine.on('databaseRebuilt', forwardEvent('media:databaseRebuilt'));
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
import: (sourcePath: string, metadata?: unknown) => ipcRenderer.invoke('media:import', sourcePath, metadata),
|
import: (sourcePath: string, metadata?: unknown) => ipcRenderer.invoke('media:import', sourcePath, metadata),
|
||||||
importDialog: () => ipcRenderer.invoke('media:importDialog'),
|
importDialog: () => ipcRenderer.invoke('media:importDialog'),
|
||||||
update: (id: string, data: unknown) => ipcRenderer.invoke('media:update', id, data),
|
update: (id: string, data: unknown) => ipcRenderer.invoke('media:update', id, data),
|
||||||
|
replaceFile: (id: string, newSourcePath: string) => ipcRenderer.invoke('media:replaceFile', id, newSourcePath),
|
||||||
|
replaceFileDialog: (id: string) => ipcRenderer.invoke('media:replaceFileDialog', id),
|
||||||
delete: (id: string) => ipcRenderer.invoke('media:delete', id),
|
delete: (id: string) => ipcRenderer.invoke('media:delete', id),
|
||||||
get: (id: string) => ipcRenderer.invoke('media:get', id),
|
get: (id: string) => ipcRenderer.invoke('media:get', id),
|
||||||
getUrl: (id: string) => ipcRenderer.invoke('media:getUrl', id),
|
getUrl: (id: string) => ipcRenderer.invoke('media:getUrl', id),
|
||||||
|
|||||||
@@ -1644,6 +1644,25 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReplaceFile = async () => {
|
||||||
|
try {
|
||||||
|
const updated = await window.electronAPI?.media.replaceFileDialog(item.id);
|
||||||
|
if (updated) {
|
||||||
|
updateMedia(item.id, updated as Partial<typeof item>);
|
||||||
|
showToast.success('File replaced (thumbnails regenerated)');
|
||||||
|
}
|
||||||
|
// null means user cancelled or file unchanged - no action needed
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to replace media file:', error);
|
||||||
|
const err = error as Error;
|
||||||
|
showErrorModal({
|
||||||
|
title: 'Replace Failed',
|
||||||
|
message: err.message || 'Failed to replace media file',
|
||||||
|
stack: err.stack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
// Fetch posts that link to this media
|
// Fetch posts that link to this media
|
||||||
@@ -1735,6 +1754,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<button onClick={handleReplaceFile} className="secondary">Replace File</button>
|
||||||
<button onClick={handleSave}>Save</button>
|
<button onClick={handleSave}>Save</button>
|
||||||
<button onClick={handleDelete} className="secondary danger">Delete</button>
|
<button onClick={handleDelete} className="secondary danger">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1745,7 +1765,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
|||||||
{item.mimeType.startsWith('image/') ? (
|
{item.mimeType.startsWith('image/') ? (
|
||||||
<div className="media-preview-image">
|
<div className="media-preview-image">
|
||||||
<img
|
<img
|
||||||
src={`bds-media://${item.id}`}
|
src={`bds-media://${item.id}?t=${item.updatedAt instanceof Date ? item.updatedAt.getTime() : item.updatedAt}`}
|
||||||
alt={item.alt || item.originalName}
|
alt={item.alt || item.originalName}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// Fallback to placeholder if image fails to load
|
// Fallback to placeholder if image fails to load
|
||||||
|
|||||||
2
src/renderer/types/electron.d.ts
vendored
2
src/renderer/types/electron.d.ts
vendored
@@ -302,6 +302,8 @@ export interface ElectronAPI {
|
|||||||
import: (sourcePath: string, metadata?: Partial<MediaData>) => Promise<MediaData>;
|
import: (sourcePath: string, metadata?: Partial<MediaData>) => Promise<MediaData>;
|
||||||
importDialog: () => Promise<MediaData[]>;
|
importDialog: () => Promise<MediaData[]>;
|
||||||
update: (id: string, data: Partial<MediaData>) => Promise<MediaData | null>;
|
update: (id: string, data: Partial<MediaData>) => Promise<MediaData | null>;
|
||||||
|
replaceFile: (id: string, newSourcePath: string) => Promise<MediaData | null>;
|
||||||
|
replaceFileDialog: (id: string) => Promise<MediaData | null>;
|
||||||
delete: (id: string) => Promise<boolean>;
|
delete: (id: string) => Promise<boolean>;
|
||||||
get: (id: string) => Promise<MediaData | null>;
|
get: (id: string) => Promise<MediaData | null>;
|
||||||
getUrl: (id: string) => Promise<string | null>;
|
getUrl: (id: string) => Promise<string | null>;
|
||||||
|
|||||||
@@ -237,12 +237,21 @@ describe('MediaEngine', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Media Import', () => {
|
describe('Media Import', () => {
|
||||||
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
// Spy on console.error to suppress expected error output (sharp can't read mock files)
|
||||||
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
// Setup a source file for import
|
// Setup a source file for import
|
||||||
const imageBuffer = Buffer.from('fake-image-data');
|
const imageBuffer = Buffer.from('fake-image-data');
|
||||||
mockFiles.set('/source/image.jpg', imageBuffer);
|
mockFiles.set('/source/image.jpg', imageBuffer);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it('should import media from source path', async () => {
|
it('should import media from source path', async () => {
|
||||||
const media = await mediaEngine.importMedia('/source/image.jpg');
|
const media = await mediaEngine.importMedia('/source/image.jpg');
|
||||||
|
|
||||||
@@ -1434,6 +1443,9 @@ tags: ["nature", "sunset"]`;
|
|||||||
it('should skip non-image media', async () => {
|
it('should skip non-image media', async () => {
|
||||||
const fs = await import('fs/promises');
|
const fs = await import('fs/promises');
|
||||||
|
|
||||||
|
// Spy on console.error to suppress expected error output
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||||
const chain = createSelectChain();
|
const chain = createSelectChain();
|
||||||
chain.where = vi.fn().mockReturnValue({
|
chain.where = vi.fn().mockReturnValue({
|
||||||
@@ -1455,6 +1467,14 @@ tags: ["nature", "sunset"]`;
|
|||||||
call => String(call[0]).includes('thumbnail')
|
call => String(call[0]).includes('thumbnail')
|
||||||
);
|
);
|
||||||
expect(thumbnailWrites).toHaveLength(0);
|
expect(thumbnailWrites).toHaveLength(0);
|
||||||
|
|
||||||
|
// Verify error was logged (graceful degradation behavior)
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Failed to generate thumbnails:',
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1497,4 +1517,257 @@ tags: ["nature", "sunset"]`;
|
|||||||
expect(paths.large).toBeNull();
|
expect(paths.large).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('replaceMediaFile', () => {
|
||||||
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Spy on console.error to suppress expected error output (sharp can't read mock files)
|
||||||
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for non-existent media', async () => {
|
||||||
|
// Mock database to return nothing
|
||||||
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||||
|
const chain = createSelectChain();
|
||||||
|
chain.where = vi.fn().mockReturnValue({
|
||||||
|
...chain,
|
||||||
|
get: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await mediaEngine.replaceMediaFile('non-existent-id', '/source/new-image.jpg');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace file and update database when checksum differs', async () => {
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
|
||||||
|
// Spy on console.error to suppress expected error output (sharp can't read mock file)
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Setup new source file with different content
|
||||||
|
const newImageBuffer = Buffer.from('new-image-data-different');
|
||||||
|
mockFiles.set('/source/new-image.jpg', newImageBuffer);
|
||||||
|
|
||||||
|
// Setup existing media in database with different checksum
|
||||||
|
const existingMedia = {
|
||||||
|
id: 'media-id-123',
|
||||||
|
projectId: 'default',
|
||||||
|
filename: 'media-id-123.jpg',
|
||||||
|
originalName: 'original.jpg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
size: 100,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
filePath: '/mock/media/2025/01/media-id-123.jpg',
|
||||||
|
sidecarPath: '/mock/media/2025/01/media-id-123.jpg.meta',
|
||||||
|
createdAt: new Date('2025-01-15'),
|
||||||
|
updatedAt: new Date('2025-01-15'),
|
||||||
|
checksum: 'old-checksum',
|
||||||
|
tags: '[]',
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||||
|
const chain = createSelectChain();
|
||||||
|
chain.where = vi.fn().mockReturnValue({
|
||||||
|
...chain,
|
||||||
|
get: vi.fn().mockResolvedValue(existingMedia),
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear any previous mock calls
|
||||||
|
vi.mocked(fs.copyFile).mockClear();
|
||||||
|
|
||||||
|
const result = await mediaEngine.replaceMediaFile('media-id-123', '/source/new-image.jpg');
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.id).toBe('media-id-123');
|
||||||
|
// File should be copied to the existing location
|
||||||
|
expect(fs.copyFile).toHaveBeenCalledWith('/source/new-image.jpg', existingMedia.filePath);
|
||||||
|
|
||||||
|
// Verify error was logged (graceful degradation for image dimensions)
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Failed to get image dimensions:',
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not replace file when checksum is the same', async () => {
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
const crypto = await import('crypto');
|
||||||
|
|
||||||
|
// Create content that we know the checksum of
|
||||||
|
const imageBuffer = Buffer.from('same-content');
|
||||||
|
const checksum = crypto.createHash('md5').update(imageBuffer).digest('hex');
|
||||||
|
mockFiles.set('/source/same-image.jpg', imageBuffer);
|
||||||
|
|
||||||
|
// Setup existing media with same checksum
|
||||||
|
const existingMedia = {
|
||||||
|
id: 'media-id-456',
|
||||||
|
projectId: 'default',
|
||||||
|
filename: 'media-id-456.jpg',
|
||||||
|
originalName: 'original.jpg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
size: imageBuffer.length,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
filePath: '/mock/media/2025/01/media-id-456.jpg',
|
||||||
|
sidecarPath: '/mock/media/2025/01/media-id-456.jpg.meta',
|
||||||
|
createdAt: new Date('2025-01-15'),
|
||||||
|
updatedAt: new Date('2025-01-15'),
|
||||||
|
checksum: checksum, // Same checksum as the source file
|
||||||
|
tags: '[]',
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||||
|
const chain = createSelectChain();
|
||||||
|
chain.where = vi.fn().mockReturnValue({
|
||||||
|
...chain,
|
||||||
|
get: vi.fn().mockResolvedValue(existingMedia),
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(fs.copyFile).mockClear();
|
||||||
|
|
||||||
|
const result = await mediaEngine.replaceMediaFile('media-id-456', '/source/same-image.jpg');
|
||||||
|
|
||||||
|
// Should return null because file hasn't changed
|
||||||
|
expect(result).toBeNull();
|
||||||
|
// File should NOT be copied
|
||||||
|
expect(fs.copyFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit mediaFileReplaced event when file is replaced', async () => {
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
|
||||||
|
const newImageBuffer = Buffer.from('event-test-data');
|
||||||
|
mockFiles.set('/source/event-test.jpg', newImageBuffer);
|
||||||
|
|
||||||
|
const existingMedia = {
|
||||||
|
id: 'media-event-id',
|
||||||
|
projectId: 'default',
|
||||||
|
filename: 'media-event-id.jpg',
|
||||||
|
originalName: 'original.jpg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
size: 100,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
filePath: '/mock/media/2025/01/media-event-id.jpg',
|
||||||
|
sidecarPath: '/mock/media/2025/01/media-event-id.jpg.meta',
|
||||||
|
createdAt: new Date('2025-01-15'),
|
||||||
|
updatedAt: new Date('2025-01-15'),
|
||||||
|
checksum: 'different-checksum',
|
||||||
|
tags: '[]',
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||||
|
const chain = createSelectChain();
|
||||||
|
chain.where = vi.fn().mockReturnValue({
|
||||||
|
...chain,
|
||||||
|
get: vi.fn().mockResolvedValue(existingMedia),
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventHandler = vi.fn();
|
||||||
|
mediaEngine.on('mediaFileReplaced', eventHandler);
|
||||||
|
|
||||||
|
await mediaEngine.replaceMediaFile('media-event-id', '/source/event-test.jpg');
|
||||||
|
|
||||||
|
expect(eventHandler).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: 'media-event-id' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call generateThumbnails for image files when checksum differs', async () => {
|
||||||
|
const newImageBuffer = Buffer.from('thumbnail-test-data');
|
||||||
|
mockFiles.set('/source/thumb-test.jpg', newImageBuffer);
|
||||||
|
|
||||||
|
const existingMedia = {
|
||||||
|
id: 'media-thumb-id',
|
||||||
|
projectId: 'default',
|
||||||
|
filename: 'media-thumb-id.jpg',
|
||||||
|
originalName: 'original.jpg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
size: 100,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
filePath: '/mock/media/2025/01/media-thumb-id.jpg',
|
||||||
|
sidecarPath: '/mock/media/2025/01/media-thumb-id.jpg.meta',
|
||||||
|
createdAt: new Date('2025-01-15'),
|
||||||
|
updatedAt: new Date('2025-01-15'),
|
||||||
|
checksum: 'old-checksum-different',
|
||||||
|
tags: '[]',
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||||
|
const chain = createSelectChain();
|
||||||
|
chain.where = vi.fn().mockReturnValue({
|
||||||
|
...chain,
|
||||||
|
get: vi.fn().mockResolvedValue(existingMedia),
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spy on generateThumbnails
|
||||||
|
const generateThumbnailsSpy = vi.spyOn(mediaEngine, 'generateThumbnails').mockResolvedValue({
|
||||||
|
small: '/mock/thumbnails/media-thumb-id-small.webp',
|
||||||
|
medium: '/mock/thumbnails/media-thumb-id-medium.webp',
|
||||||
|
large: '/mock/thumbnails/media-thumb-id-large.webp',
|
||||||
|
});
|
||||||
|
|
||||||
|
await mediaEngine.replaceMediaFile('media-thumb-id', '/source/thumb-test.jpg');
|
||||||
|
|
||||||
|
expect(generateThumbnailsSpy).toHaveBeenCalledWith('media-thumb-id', existingMedia.filePath);
|
||||||
|
|
||||||
|
generateThumbnailsSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not generate thumbnails for non-image files', async () => {
|
||||||
|
const pdfBuffer = Buffer.from('pdf-content');
|
||||||
|
mockFiles.set('/source/document.pdf', pdfBuffer);
|
||||||
|
|
||||||
|
const existingMedia = {
|
||||||
|
id: 'media-pdf-id',
|
||||||
|
projectId: 'default',
|
||||||
|
filename: 'media-pdf-id.pdf',
|
||||||
|
originalName: 'document.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
size: 100,
|
||||||
|
filePath: '/mock/media/2025/01/media-pdf-id.pdf',
|
||||||
|
sidecarPath: '/mock/media/2025/01/media-pdf-id.pdf.meta',
|
||||||
|
createdAt: new Date('2025-01-15'),
|
||||||
|
updatedAt: new Date('2025-01-15'),
|
||||||
|
checksum: 'old-pdf-checksum',
|
||||||
|
tags: '[]',
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||||
|
const chain = createSelectChain();
|
||||||
|
chain.where = vi.fn().mockReturnValue({
|
||||||
|
...chain,
|
||||||
|
get: vi.fn().mockResolvedValue(existingMedia),
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
|
||||||
|
const generateThumbnailsSpy = vi.spyOn(mediaEngine, 'generateThumbnails');
|
||||||
|
|
||||||
|
await mediaEngine.replaceMediaFile('media-pdf-id', '/source/document.pdf');
|
||||||
|
|
||||||
|
expect(generateThumbnailsSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
generateThumbnailsSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -480,11 +480,22 @@ describe('MetaEngine', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw non-ENOENT errors when loading project metadata', async () => {
|
it('should throw non-ENOENT errors when loading project metadata', async () => {
|
||||||
|
// Spy on console.error to suppress expected error output
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
// Mock readFile to throw a non-ENOENT error
|
// Mock readFile to throw a non-ENOENT error
|
||||||
const originalReadFile = vi.mocked(fs.readFile);
|
const originalReadFile = vi.mocked(fs.readFile);
|
||||||
originalReadFile.mockRejectedValueOnce(Object.assign(new Error('Permission denied'), { code: 'EACCES' }));
|
originalReadFile.mockRejectedValueOnce(Object.assign(new Error('Permission denied'), { code: 'EACCES' }));
|
||||||
|
|
||||||
await expect(metaEngine.loadProjectMetadata()).rejects.toThrow('Permission denied');
|
await expect(metaEngine.loadProjectMetadata()).rejects.toThrow('Permission denied');
|
||||||
|
|
||||||
|
// Verify error was logged before rethrowing
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'[MetaEngine] Failed to load project metadata:',
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle ENOENT error when loading categories (no file)', async () => {
|
it('should handle ENOENT error when loading categories (no file)', async () => {
|
||||||
@@ -496,11 +507,22 @@ describe('MetaEngine', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw non-ENOENT errors when loading categories', async () => {
|
it('should throw non-ENOENT errors when loading categories', async () => {
|
||||||
|
// Spy on console.error to suppress expected error output
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
// Mock readFile to throw a non-ENOENT error
|
// Mock readFile to throw a non-ENOENT error
|
||||||
const originalReadFile = vi.mocked(fs.readFile);
|
const originalReadFile = vi.mocked(fs.readFile);
|
||||||
originalReadFile.mockRejectedValueOnce(Object.assign(new Error('Disk full'), { code: 'ENOSPC' }));
|
originalReadFile.mockRejectedValueOnce(Object.assign(new Error('Disk full'), { code: 'ENOSPC' }));
|
||||||
|
|
||||||
await expect(metaEngine.loadCategories()).rejects.toThrow('Disk full');
|
await expect(metaEngine.loadCategories()).rejects.toThrow('Disk full');
|
||||||
|
|
||||||
|
// Verify error was logged before rethrowing
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'[MetaEngine] Failed to load categories:',
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit projectMetadataChanged event when metadata is modified', async () => {
|
it('should emit projectMetadataChanged event when metadata is modified', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user