Files
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

513 lines
17 KiB
TypeScript

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> = {}): MessageElement {
return {
type: 'image',
fileId: 'abc.png',
fileSize: 4242,
imageUrl: 'https://example.com/abc.png',
...overrides,
};
}
function recordElement(overrides: Partial<MessageElement> = {}): 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);
});
});