feat: add community publishing and news syndication
This commit is contained in:
@@ -4,151 +4,209 @@ import sharp from 'sharp';
|
||||
const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() }));
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('node:fs/promises')>(),
|
||||
rm: rmMock
|
||||
...(await importOriginal<typeof import('node:fs/promises')>()),
|
||||
rm: rmMock,
|
||||
}));
|
||||
|
||||
import { GAME_ICON_TYPES } from './game-data-icons';
|
||||
import { ManagedImageDeletionError, deleteManagedImage, processManagedImage } from './managed-images';
|
||||
import {
|
||||
GAME_DATA_RENDER_TYPES,
|
||||
ManagedImageDeletionError,
|
||||
deleteManagedImage,
|
||||
processManagedImage,
|
||||
} from './managed-images';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
class FakeDatabase {
|
||||
images = new Map<string, Row>();
|
||||
icons = new Map<string, Row>();
|
||||
executed: string[] = [];
|
||||
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')) 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 (row?.storage_key === args[1]) this.images.delete(String(args[0]));
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unexpected run: ${sql}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
prepare(sql: string) {
|
||||
return Promise.resolve({
|
||||
get: async (...args: unknown[]) => {
|
||||
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 (row?.storage_key === args[1]) this.images.delete(String(args[0]));
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unexpected run: ${sql}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async exec(sql: string) {
|
||||
this.executed.push(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));
|
||||
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
|
||||
});
|
||||
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);
|
||||
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', 'COMMIT']);
|
||||
expect(rmMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
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', '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
|
||||
});
|
||||
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);
|
||||
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();
|
||||
});
|
||||
expect(deleted?.sourceIcon).toBeNull();
|
||||
expect(database.images.has(imageId)).toBe(false);
|
||||
expect(database.icons.size).toBe(0);
|
||||
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
|
||||
});
|
||||
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);
|
||||
const deleted = await deleteManagedImage(database as never, imageId);
|
||||
|
||||
expect(deleted?.sourceIcon).toBeNull();
|
||||
expect(database.images.has(imageId)).toBe(false);
|
||||
expect(database.executed).toEqual(['BEGIN', 'COMMIT']);
|
||||
});
|
||||
expect(deleted?.sourceIcon).toBeNull();
|
||||
expect(database.images.has(imageId)).toBe(false);
|
||||
expect(rmMock).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
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'
|
||||
});
|
||||
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,
|
||||
});
|
||||
|
||||
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([]);
|
||||
expect(rmMock).not.toHaveBeenCalled();
|
||||
});
|
||||
const deleted = await deleteManagedImage(database as never, imageId);
|
||||
|
||||
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
|
||||
});
|
||||
expect(deleted?.sourceIcon).toBeNull();
|
||||
expect(database.images.has(imageId)).toBe(false);
|
||||
expect(database.executed).toEqual(['BEGIN', 'COMMIT']);
|
||||
});
|
||||
|
||||
await expect(deleteManagedImage(database as never, imageId)).rejects.toThrow(ManagedImageDeletionError);
|
||||
expect(database.images.has(imageId)).toBe(true);
|
||||
expect(database.executed).toEqual([]);
|
||||
expect(rmMock).not.toHaveBeenCalled();
|
||||
});
|
||||
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([]);
|
||||
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([]);
|
||||
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();
|
||||
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);
|
||||
});
|
||||
expect(metadata.format).toBe('webp');
|
||||
expect(metadata.width).toBe(1000);
|
||||
expect(metadata.height).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user