import { beforeEach, describe, expect, it, vi } from 'vitest'; import sharp from 'sharp'; const { mkdirMock, rmMock, writeFileMock } = vi.hoisted(() => ({ mkdirMock: vi.fn(), rmMock: vi.fn(), writeFileMock: vi.fn(), })); vi.mock('node:fs/promises', async (importOriginal) => ({ ...(await importOriginal()), mkdir: mkdirMock, rm: rmMock, writeFile: writeFileMock, })); import { GAME_ICON_TYPES } from './game-data-icons'; import { GAME_DATA_RENDER_TYPES, ManagedImageDeletionError, applyGameDataRender, deleteManagedImage, processManagedImage, } from './managed-images'; type Row = Record; class FakeDatabase { images = new Map(); icons = new Map(); executed: string[] = []; prepare(sql: string) { return Promise.resolve({ get: async (...args: unknown[]) => { if ( sql.startsWith('SELECT * FROM managed_images') && sql.includes('source_entity_type') ) { return [...this.images.values()].find( (row) => row.source_entity_type === args[0] && row.source_entity_id === args[1], ); } if (sql.startsWith('SELECT * FROM managed_images')) return this.images.get(String(args[0])); if (sql.startsWith('SELECT * FROM game_data_icons')) { return this.icons.get(`${String(args[0])}:${String(args[1])}`); } throw new Error(`Unexpected get: ${sql}`); }, run: async (...args: unknown[]) => { if (sql.startsWith('DELETE FROM game_data_icons')) { const key = `${String(args[0])}:${String(args[1])}`; const row = this.icons.get(key); if (row?.storage_key === args[2]) this.icons.delete(key); return; } if (sql.startsWith('DELETE FROM managed_images')) { const row = this.images.get(String(args[0])); if (!sql.includes('storage_key') || row?.storage_key === args[1]) this.images.delete(String(args[0])); return; } if (sql.startsWith('UPDATE managed_images SET')) { const row = this.images.get(String(args[7])); if (!row) throw new Error('Missing managed image for update'); this.images.set(String(args[7]), { ...row, category: args[0], tags_json: args[1], is_icon: 0, force_white: 0, original_filename: args[2], byte_size: args[3], width: args[4], height: args[5], updated_at: args[6], }); return; } if (sql.startsWith('INSERT INTO managed_images')) { const [ id, name, category, tagsJson, isIcon, forceWhite, storageKey, originalFilename, byteSize, width, height, sourceType, sourceEntityId, createdBy, createdAt, updatedAt, ] = args; this.images.set(String(id), { id, name, category, tags_json: tagsJson, is_icon: isIcon, force_white: forceWhite, storage_key: storageKey, original_filename: originalFilename, byte_size: byteSize, width, height, source_entity_type: sourceType, source_entity_id: sourceEntityId, created_by: createdBy, created_at: createdAt, updated_at: updatedAt, }); return; } throw new Error(`Unexpected run: ${sql}`); }, }); } async exec(sql: string) { this.executed.push(sql); } } const imageId = '11111111-1111-4111-8111-111111111111'; const sourceId = '22222222-2222-4222-8222-222222222222'; describe('deleteManagedImage', () => { beforeEach(() => rmMock.mockReset().mockResolvedValue(undefined)); it.each(GAME_ICON_TYPES)( 'detaches and deletes an assigned %s image', async (sourceType) => { const database = new FakeDatabase(); const storageKey = `${sourceType}-${sourceId}.webp`; database.images.set(imageId, { id: imageId, storage_key: storageKey, source_entity_type: sourceType, source_entity_id: sourceId, }); database.icons.set(`${sourceType}:${sourceId}`, { entity_type: sourceType, entity_id: sourceId, storage_key: storageKey, }); const deleted = await deleteManagedImage(database as never, imageId); expect(deleted?.sourceIcon).toMatchObject({ entity_type: sourceType, entity_id: sourceId, }); expect(database.images.has(imageId)).toBe(false); expect(database.icons.has(`${sourceType}:${sourceId}`)).toBe(false); expect(database.executed).toEqual(['BEGIN IMMEDIATE', 'COMMIT']); expect(rmMock).toHaveBeenCalledOnce(); }, ); it('deletes a standalone library image', async () => { const database = new FakeDatabase(); database.images.set(imageId, { id: imageId, storage_key: `library-${imageId}.webp`, source_entity_type: null, source_entity_id: null, }); const deleted = await deleteManagedImage(database as never, imageId); expect(deleted?.sourceIcon).toBeNull(); expect(database.images.has(imageId)).toBe(false); expect(database.icons.size).toBe(0); expect(rmMock).toHaveBeenCalledOnce(); }); it.each(GAME_DATA_RENDER_TYPES)( 'deletes an assigned %s render image', async (type) => { const database = new FakeDatabase(); database.images.set(imageId, { id: imageId, storage_key: `library-${imageId}.webp`, source_entity_type: `${type}_render`, source_entity_id: sourceId, }); const deleted = await deleteManagedImage(database as never, imageId); expect(deleted?.sourceIcon).toBeNull(); expect(database.images.has(imageId)).toBe(false); expect(rmMock).toHaveBeenCalledOnce(); }, ); it('deletes an orphaned linked image without touching another assignment', async () => { const database = new FakeDatabase(); database.images.set(imageId, { id: imageId, storage_key: `talent-${sourceId}.webp`, source_entity_type: 'talent', source_entity_id: sourceId, }); const deleted = await deleteManagedImage(database as never, imageId); expect(deleted?.sourceIcon).toBeNull(); expect(database.images.has(imageId)).toBe(false); expect(database.executed).toEqual(['BEGIN IMMEDIATE', 'COMMIT']); }); it('fails closed when the library and source rows reference different files', async () => { const database = new FakeDatabase(); database.images.set(imageId, { id: imageId, storage_key: `talent-${sourceId}.webp`, source_entity_type: 'talent', source_entity_id: sourceId, }); database.icons.set(`talent:${sourceId}`, { entity_type: 'talent', entity_id: sourceId, storage_key: 'talent-33333333-3333-4333-8333-333333333333.webp', }); await expect( deleteManagedImage(database as never, imageId), ).rejects.toThrow(ManagedImageDeletionError); expect(database.images.has(imageId)).toBe(true); expect(database.icons.has(`talent:${sourceId}`)).toBe(true); expect(database.executed).toEqual(['BEGIN IMMEDIATE', 'ROLLBACK']); expect(rmMock).not.toHaveBeenCalled(); }); it.each([ { source_entity_type: 'talent', source_entity_id: null }, { source_entity_type: null, source_entity_id: sourceId }, { source_entity_type: 'unknown', source_entity_id: sourceId }, ])( 'rejects an invalid source relationship: $source_entity_type / $source_entity_id', async (source) => { const database = new FakeDatabase(); database.images.set(imageId, { id: imageId, storage_key: `library-${imageId}.webp`, ...source, }); await expect( deleteManagedImage(database as never, imageId), ).rejects.toThrow(ManagedImageDeletionError); expect(database.images.has(imageId)).toBe(true); expect(database.executed).toEqual(['BEGIN IMMEDIATE', 'ROLLBACK']); expect(rmMock).not.toHaveBeenCalled(); }, ); }); describe('processManagedImage', () => { it('center-crops game-data renders to 1000 pixel WebP squares without removing the background', async () => { const input = await sharp({ create: { width: 1296, height: 1000, channels: 3, background: '#263238' }, }) .png() .toBuffer(); const result = await processManagedImage( new File([input], 'mask.png', { type: 'image/png' }), false, false, true, ); const metadata = await sharp(result.output).metadata(); expect(metadata.format).toBe('webp'); expect(metadata.width).toBe(1000); expect(metadata.height).toBe(1000); }); }); describe('applyGameDataRender', () => { beforeEach(() => { mkdirMock.mockReset().mockResolvedValue(undefined); rmMock.mockReset().mockResolvedValue(undefined); writeFileMock.mockReset().mockResolvedValue(undefined); }); it.each(GAME_DATA_RENDER_TYPES)( 'persists and returns authoritative %s render metadata', async (type) => { const database = new FakeDatabase(); const result = await applyGameDataRender( database as never, type, sourceId, 'Test Entity', { file: new File(['source'], 'source.png', { type: 'image/png' }), remove: false, processed: { output: Buffer.from('processed-render'), width: 1000, height: 1000, }, }, 'operator-1', ); expect(result).toMatchObject({ original_filename: 'source.png', byte_size: Buffer.byteLength('processed-render'), width: 1000, height: 1000, source_entity_type: `${type}_render`, source_entity_id: sourceId, created_by: 'operator-1', created_at: expect.any(String), updated_at: expect.any(String), }); expect(writeFileMock).toHaveBeenCalledOnce(); }, ); it('returns refreshed replacement metadata without rewriting creator provenance', async () => { const database = new FakeDatabase(); database.images.set(imageId, { id: imageId, name: 'Ability render', category: 'Specializations', tags_json: '[]', is_icon: 0, force_white: 0, storage_key: `library-${imageId}.webp`, original_filename: 'old.png', byte_size: 3, width: 100, height: 100, source_entity_type: 'specialization_ability_render', source_entity_id: sourceId, created_by: 'original-operator', created_at: '2026-01-01T00:00:00.000Z', updated_at: '2026-01-01T00:00:00.000Z', }); const result = await applyGameDataRender( database as never, 'specialization_ability', sourceId, 'Ability', { file: new File(['new'], 'new.png', { type: 'image/png' }), remove: false, processed: { output: Buffer.from('replacement'), width: 1000, height: 1000, }, }, 'replacement-operator', ); expect(result).toMatchObject({ id: imageId, original_filename: 'new.png', byte_size: Buffer.byteLength('replacement'), created_by: 'original-operator', created_at: '2026-01-01T00:00:00.000Z', }); }); });