import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DatabaseSync } from 'node:sqlite'; import fs from 'fs'; import path from 'path'; import { MEDIA_DATA_MAX_CHARS, MEDIA_KEY_MAX_CHARS, MediaStore } from '../src/media-store'; import { convertEvent, type ConverterContext } from '../src/event-converter'; import type { GroupMessage, FriendMessage, MessageElement } from '@snowluma/protocol/events'; const SELF_ID = 10001; const PEER_UIN = 22222; const GROUP_ID = 99999; function imageElement(overrides: Partial = {}): MessageElement { return { type: 'image', fileId: 'abc.png', fileSize: 4242, imageUrl: 'https://example.com/abc.png', ...overrides, }; } function recordElement(overrides: Partial = {}): MessageElement { return { type: 'record', fileId: 'uuid-base64-fake', fileName: 'silk_test.amr', fileSize: 1024, duration: 3, fileHash: 'deadbeef', mediaNode: { fileUuid: 'uuid-base64-fake', storeId: 1 }, ...overrides, }; } function makeGroupMessage(elements: MessageElement[]): GroupMessage { return { kind: 'group_message', groupName: '', time: 1700000000, selfUin: SELF_ID, groupId: GROUP_ID, senderUin: PEER_UIN, senderNick: 'peer', senderCard: '', senderRole: 'member', msgSeq: 1, msgId: 1, elements, }; } function makeFriendMessage(elements: MessageElement[]): FriendMessage { return { kind: 'friend_message', time: 1700000000, selfUin: SELF_ID, senderUin: PEER_UIN, senderNick: 'peer', msgSeq: 1, msgId: 1, elements, }; } function tempDbPath(label: string): string { return path.join( 'data', 'test', `media-${label}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`, ); } describe('MediaStore basic semantics', () => { const dbs: string[] = []; beforeEach(() => { dbs.length = 0; }); afterEach(() => { for (const dbPath of dbs) { for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + ext); } catch { /* ignore */ } } } }); function open(label: string, max?: number): MediaStore { const dbPath = tempDbPath(label); dbs.push(dbPath); return max !== undefined ? new MediaStore(dbPath, max) : new MediaStore(dbPath); } it('indexes images by file, fileName, and url', async () => { const store = open('img-keys'); await store.rememberImage({ file: 'abc.png', url: 'https://example.com/abc.png', fileSize: 4242, fileName: 'abc.png', subType: 0, summary: '', imageUrl: 'https://example.com/abc.png', isGroup: true, sessionId: GROUP_ID, }); expect((await store.findImage('abc.png'))?.fileSize).toBe(4242); expect((await store.findImage('https://example.com/abc.png'))?.url).toBe( 'https://example.com/abc.png', ); expect(await store.findImage('missing.png')).toBeNull(); expect(await store.findImage('')).toBeNull(); await store.close(); }); it('indexes records by file, fileName, fileId, and url', async () => { const store = open('rec-keys'); await store.rememberRecord({ file: 'silk_test.amr', fileId: 'uuid-base64-fake', url: 'https://example.com/voice.amr', fileSize: 1024, fileName: 'silk_test.amr', duration: 3, fileHash: 'deadbeef', mediaNode: { fileUuid: 'uuid-base64-fake' }, isGroup: false, sessionId: PEER_UIN, }); expect((await store.findRecord('silk_test.amr'))?.duration).toBe(3); expect((await store.findRecord('uuid-base64-fake'))?.fileId).toBe('uuid-base64-fake'); expect((await store.findRecord('https://example.com/voice.amr'))?.url).toBe( 'https://example.com/voice.amr', ); expect(await store.findRecord('nope')).toBeNull(); await store.close(); }); it('updates URLs in place across all alias keys', async () => { const store = open('url-update'); await store.rememberRecord({ file: 'silk_test.amr', fileId: 'uuid-base64-fake', url: '', fileSize: 1024, fileName: 'silk_test.amr', duration: 3, fileHash: '', mediaNode: { fileUuid: 'uuid-base64-fake' }, isGroup: false, sessionId: PEER_UIN, }); await store.updateRecordUrl('silk_test.amr', 'https://refreshed.example.com/voice.amr'); expect((await store.findRecord('silk_test.amr'))?.url).toBe( 'https://refreshed.example.com/voice.amr', ); expect((await store.findRecord('uuid-base64-fake'))?.url).toBe( 'https://refreshed.example.com/voice.amr', ); await store.close(); }); it('survives close+reopen against the same db path', async () => { const dbPath = tempDbPath('persist'); dbs.push(dbPath); { const store = new MediaStore(dbPath); await store.initialization; await store.rememberImage({ file: 'abc.png', url: 'https://example.com/abc.png', fileSize: 99, fileName: 'abc.png', subType: 0, summary: '', imageUrl: 'https://example.com/abc.png', isGroup: true, sessionId: GROUP_ID, }); await store.close(); } { const store = new MediaStore(dbPath); await store.initialization; const found = await store.findImage('abc.png'); expect(found).not.toBeNull(); expect(found!.fileSize).toBe(99); // Alias index must also persist. expect((await store.findImage('https://example.com/abc.png'))?.fileSize).toBe(99); await store.close(); } }); it('retains durable entries beyond the memory cache cap', async () => { // Lookups can be evicted from memory, but historical metadata survives. const cap = 80; const store = open('evict', cap); for (let i = 0; i < 200; i++) { await store.rememberImage({ file: `file-${i}.png`, url: `https://example.com/${i}.png`, fileSize: i, fileName: `file-${i}.png`, subType: 0, summary: '', imageUrl: '', isGroup: true, sessionId: GROUP_ID, }); } const { images } = await store.size(); expect(images).toBe(200); for (let i = 0; i < 200; i++) { expect((await store.findImage(`file-${i}.png`))?.fileSize).toBe(i); } expect((await store.findImage('file-0.png'))?.fileSize).toBe(0); // Most recent entries should still be present. expect((await store.findImage('file-199.png'))?.fileSize).toBe(199); await store.close(); }); it('does not persist inline base64 as a key or JSON payload (#463)', async () => { const store = open('inline-b64'); const payload = `base64://${'A'.repeat(8192)}`; await store.rememberImage({ file: payload, url: payload, fileSize: 99, fileName: '', subType: 0, summary: '', imageUrl: payload, isGroup: true, sessionId: GROUP_ID, md5Hex: 'aa'.repeat(16), width: 800, height: 600, }); const found = await store.findImage(payload); expect(found).not.toBeNull(); expect(found!.fileSize).toBe(99); expect(found!.md5Hex).toBe('aa'.repeat(16)); expect(found!.url).toBe(''); expect(found!.imageUrl).toBe(''); expect(found!.file.startsWith('inline:')).toBe(true); expect(found!.file.includes('base64://')).toBe(false); await store.close(); const rows = inspectMediaDb(dbs[dbs.length - 1]!); expect(rows.longestKey).toBeLessThanOrEqual(MEDIA_KEY_MAX_CHARS); expect(rows.longestData).toBeLessThanOrEqual(MEDIA_DATA_MAX_CHARS); expect(rows.longestKey).toBeLessThan(100); expect(rows.concatenated).not.toContain('base64://'); expect(rows.concatenated).not.toContain('A'.repeat(64)); }); it('folds oversized non-inline keys the same way', async () => { const store = open('long-url'); const url = `https://cdn.example/${'a'.repeat(3000)}.png`; await store.rememberImage({ file: 'short.png', url, fileSize: 12, fileName: 'short.png', subType: 0, summary: '', imageUrl: url, isGroup: true, sessionId: GROUP_ID, }); expect((await store.findImage('short.png'))?.fileSize).toBe(12); expect((await store.findImage(url))?.fileSize).toBe(12); expect((await store.findImage('short.png'))?.url).toBe(''); await store.close(); const rows = inspectMediaDb(dbs[dbs.length - 1]!); expect(rows.longestKey).toBeLessThanOrEqual(MEDIA_KEY_MAX_CHARS); expect(rows.concatenated).not.toContain('a'.repeat(64)); }); it('ignores an inline URL refresh so eviction cannot be re-poisoned', async () => { const store = open('no-inline-refresh'); await store.rememberImage({ file: 'abc.png', url: 'https://example.com/abc.png', fileSize: 7, fileName: 'abc.png', subType: 0, summary: '', imageUrl: '', isGroup: true, sessionId: GROUP_ID, }); await store.updateImageUrl('abc.png', `base64://${'B'.repeat(100)}`); expect((await store.findImage('abc.png'))?.url).toBe('https://example.com/abc.png'); await store.close(); }); it('does not destructively purge existing metadata on open', async () => { const dbPath = tempDbPath('legacy-overflow'); dbs.push(dbPath); fs.mkdirSync(path.dirname(dbPath), { recursive: true }); const raw = new DatabaseSync(dbPath); raw.exec(` CREATE TABLE media_entries ( type TEXT NOT NULL, primary_key TEXT NOT NULL, data TEXT NOT NULL, last_seen INTEGER NOT NULL, PRIMARY KEY (type, primary_key) ); CREATE TABLE media_keys ( type TEXT NOT NULL, key TEXT NOT NULL, primary_key TEXT NOT NULL, PRIMARY KEY (type, key) ); `); const poisonKey = 'k'.repeat(MEDIA_KEY_MAX_CHARS + 8); raw.prepare( `INSERT INTO media_entries (type, primary_key, data, last_seen) VALUES ('image', ?, ?, 1)`, ).run(poisonKey, '{"file":"poison"}'); raw.prepare(`INSERT INTO media_keys (type, key, primary_key) VALUES ('image', ?, ?)`).run( poisonKey, poisonKey, ); raw.close(); const store = new MediaStore(dbPath); await store.initialization; expect(await store.findImage(poisonKey)).toBeNull(); expect((await store.size()).images).toBe(1); await store.close(); const rows = inspectMediaDb(dbPath); expect(rows.entryCount).toBe(1); expect(rows.keyCount).toBe(1); }); }); function inspectMediaDb(dbPath: string): { longestKey: number; longestData: number; concatenated: string; entryCount: number; keyCount: number; } { const db = new DatabaseSync(dbPath); const entries = db.prepare(`SELECT primary_key, data FROM media_entries`).all() as Array<{ primary_key: string; data: string; }>; const keys = db.prepare(`SELECT key, primary_key FROM media_keys`).all() as Array<{ key: string; primary_key: string; }>; db.close(); const keyLens = [ ...entries.map((row) => row.primary_key.length), ...keys.map((row) => row.key.length), ...keys.map((row) => row.primary_key.length), 0, ]; return { longestKey: Math.max(...keyLens), longestData: Math.max(0, ...entries.map((row) => row.data.length)), concatenated: [ ...entries.map((row) => row.primary_key + row.data), ...keys.map((row) => row.key + row.primary_key), ].join('\n'), entryCount: entries.length, keyCount: keys.length, }; } describe('convertEvent media segment sink → MediaStore wiring', () => { const dbs: string[] = []; afterEach(() => { for (const dbPath of dbs) { for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + ext); } catch { /* ignore */ } } } dbs.length = 0; }); it('emits sink invocations for image segments with the right context', async () => { const sinkCalls: Array<{ type: string; isGroup: boolean; sessionId: number; file: string; }> = []; const ctx: ConverterContext = { selfId: SELF_ID, imageUrlResolver: null, mediaUrlResolver: null, messageIdResolver: null, mediaSegmentSink: (type, _element, data, isGroup, sessionId) => { sinkCalls.push({ type, isGroup, sessionId, file: String(data.file ?? '') }); }, }; await convertEvent(ctx, makeGroupMessage([imageElement()])); await convertEvent(ctx, makeFriendMessage([recordElement()])); expect(sinkCalls).toEqual([ { type: 'image', isGroup: true, sessionId: GROUP_ID, file: 'abc.png' }, { type: 'record', isGroup: false, sessionId: PEER_UIN, file: 'silk_test.amr' }, ]); }); it('lets the sink populate a MediaStore that get_image-style lookups can use', async () => { const dbPath = tempDbPath('sink'); dbs.push(dbPath); const store = new MediaStore(dbPath); await store.initialization; const ctx: ConverterContext = { selfId: SELF_ID, imageUrlResolver: (element) => element.imageUrl ?? '', mediaUrlResolver: async (element) => element.url ?? '', messageIdResolver: null, mediaSegmentSink: async (type, element, data, isGroup, sessionId) => { const url = typeof data.url === 'string' ? data.url : ''; const file = typeof data.file === 'string' ? data.file : ''; if (type === 'image') { await store.rememberImage({ file: file || element.fileId || '', url, fileSize: element.fileSize ?? 0, fileName: element.fileId ?? '', subType: element.subType ?? 0, summary: element.summary ?? '', imageUrl: element.imageUrl ?? '', isGroup, sessionId, }); } else { await store.rememberRecord({ file: file || element.fileName || element.fileId || '', fileId: element.fileId ?? '', url, fileSize: element.fileSize ?? 0, fileName: element.fileName ?? '', duration: element.duration ?? 0, fileHash: element.fileHash ?? '', mediaNode: element.mediaNode, isGroup, sessionId, }); } }, }; await convertEvent(ctx, makeGroupMessage([imageElement()])); await convertEvent(ctx, makeFriendMessage([recordElement()])); const img = await store.findImage('abc.png'); expect(img).not.toBeNull(); expect(img!.url).toBe('https://example.com/abc.png'); expect(img!.fileSize).toBe(4242); expect(img!.isGroup).toBe(true); expect(img!.sessionId).toBe(GROUP_ID); const rec = await store.findRecord('silk_test.amr'); expect(rec).not.toBeNull(); expect(rec!.fileId).toBe('uuid-base64-fake'); expect(rec!.duration).toBe(3); expect(rec!.isGroup).toBe(false); expect(rec!.sessionId).toBe(PEER_UIN); // Alias lookup by fileUuid still works. expect((await store.findRecord('uuid-base64-fake'))?.fileName).toBe('silk_test.amr'); await store.close(); }); it('does not invoke the sink when no media segments are present', async () => { let calls = 0; const ctx: ConverterContext = { selfId: SELF_ID, imageUrlResolver: null, mediaUrlResolver: null, messageIdResolver: null, mediaSegmentSink: () => { calls++; }, }; await convertEvent(ctx, makeGroupMessage([{ type: 'text', text: 'hello' }])); expect(calls).toBe(0); }); });