378 lines
11 KiB
TypeScript
378 lines
11 KiB
TypeScript
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<typeof import('node:fs/promises')>()),
|
|
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<string, unknown>;
|
|
|
|
class FakeDatabase {
|
|
images = new Map<string, Row>();
|
|
icons = new Map<string, Row>();
|
|
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',
|
|
});
|
|
});
|
|
});
|