Move database work off the main thread on Node.js 24 and improve message handling and runtime diagnostics. Preserve migration, restart, and concurrency coverage with the async SQLite driver.
342 lines
13 KiB
TypeScript
342 lines
13 KiB
TypeScript
// Tests for utils/event-format.ts.
|
|
//
|
|
// Each formatter is defensive about cache misses / malformed input —
|
|
// these tests cover both the happy paths (resolves names from
|
|
// Identity / MessageStore) and the degraded paths (falls back to the
|
|
// raw uin / groupId without crashing).
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
formatEvent,
|
|
formatGroup,
|
|
formatMessageSegments,
|
|
formatReply,
|
|
formatUser,
|
|
} from '@snowluma/protocol/format';
|
|
import type { QQEventVariant } from '@snowluma/protocol/events';
|
|
|
|
// ─── Test fakes (just the find* surface event-format uses) ───
|
|
|
|
function fakeIdentity(
|
|
opts: {
|
|
groups?: Record<number, { groupName: string }>;
|
|
members?: Record<string, { uin: number; card?: string; nickname?: string }>;
|
|
friends?: Record<number, { uin: number; nickname?: string; remark?: string }>;
|
|
} = {},
|
|
): any {
|
|
return {
|
|
findGroup: (gid: number) => opts.groups?.[gid] ?? null,
|
|
findGroupMember: (gid: number, uin: number) => opts.members?.[`${gid}/${uin}`] ?? null,
|
|
findFriend: (uin: number) => opts.friends?.[uin] ?? null,
|
|
};
|
|
}
|
|
|
|
function fakeMessageStore(events: Record<number, Record<string, unknown>>): any {
|
|
return {
|
|
findEvent: (id: number) => events[id] ?? null,
|
|
};
|
|
}
|
|
|
|
describe('formatGroup', () => {
|
|
it('renders [groupName(id)] when the group is in cache', () => {
|
|
const id = fakeIdentity({ groups: { 123: { groupName: 'TestGroup' } } });
|
|
expect(formatGroup(id, 123)).toBe('[TestGroup(123)]');
|
|
});
|
|
|
|
it('falls back to bare id on cache miss', () => {
|
|
const id = fakeIdentity();
|
|
expect(formatGroup(id, 123)).toBe('123');
|
|
});
|
|
|
|
it('handles 0 / missing group id without crashing', () => {
|
|
const id = fakeIdentity();
|
|
expect(formatGroup(id, 0)).toBe('0');
|
|
});
|
|
|
|
it('falls back to bare id if findGroup throws', () => {
|
|
const id = {
|
|
findGroup: () => {
|
|
throw new Error('db dead');
|
|
},
|
|
} as any;
|
|
expect(formatGroup(id, 123)).toBe('123');
|
|
});
|
|
});
|
|
|
|
describe('formatUser', () => {
|
|
it('prefers group-member card over nickname', () => {
|
|
const id = fakeIdentity({
|
|
members: { '1/100': { uin: 100, card: 'Card', nickname: 'Nick' } },
|
|
});
|
|
expect(formatUser(id, 1, 100)).toBe('[Card(100)]');
|
|
});
|
|
|
|
it('falls back to nickname when card is empty', () => {
|
|
const id = fakeIdentity({ members: { '1/100': { uin: 100, card: '', nickname: 'Nick' } } });
|
|
expect(formatUser(id, 1, 100)).toBe('[Nick(100)]');
|
|
});
|
|
|
|
it('falls back to friend list when not a group member', () => {
|
|
const id = fakeIdentity({ friends: { 100: { uin: 100, nickname: 'FriendNick' } } });
|
|
expect(formatUser(id, undefined, 100)).toBe('[FriendNick(100)]');
|
|
});
|
|
|
|
it('prefers friend remark over nickname', () => {
|
|
const id = fakeIdentity({ friends: { 100: { uin: 100, nickname: 'A', remark: 'B' } } });
|
|
expect(formatUser(id, undefined, 100)).toBe('[B(100)]');
|
|
});
|
|
|
|
it('falls back to uin on full cache miss', () => {
|
|
const id = fakeIdentity();
|
|
expect(formatUser(id, 1, 100)).toBe('100');
|
|
});
|
|
|
|
it('falls back to uid when only uid is available', () => {
|
|
const id = fakeIdentity();
|
|
expect(formatUser(id, undefined, 0, 'u_abc')).toBe('u_abc');
|
|
});
|
|
});
|
|
|
|
describe('formatMessageSegments', () => {
|
|
it('renders text segments and truncates beyond 50 chars', () => {
|
|
const long = 'a'.repeat(80);
|
|
expect(formatMessageSegments([{ type: 'text', data: { text: long } }])).toBe(
|
|
`${'a'.repeat(50)}...`,
|
|
);
|
|
});
|
|
|
|
it('handles known media types and at/reply', () => {
|
|
const out = formatMessageSegments([
|
|
{ type: 'text', data: { text: 'hi' } },
|
|
{ type: 'image', data: {} },
|
|
{ type: 'at', data: { qq: 12345 } },
|
|
{ type: 'at', data: { qq: 'all' } },
|
|
{ type: 'reply', data: { id: 999 } },
|
|
{ type: 'face', data: {} },
|
|
{ type: 'video', data: {} },
|
|
{ type: 'record', data: {} },
|
|
]);
|
|
expect(out).toBe('hi [图片] @12345 @全体成员 [回复:999] [表情] [视频] [语音]');
|
|
});
|
|
|
|
it('renders file segments using data.name (the OneBot field), with data.file fallback', () => {
|
|
expect(
|
|
formatMessageSegments([{ type: 'file', data: { name: '20260606_151113.wav' } }]),
|
|
).toBe('[文件:20260606_151113.wav]');
|
|
expect(formatMessageSegments([{ type: 'file', data: { file: 'legacy.bin' } }])).toBe(
|
|
'[文件:legacy.bin]',
|
|
);
|
|
expect(formatMessageSegments([{ type: 'file', data: {} }])).toBe('[文件]');
|
|
});
|
|
|
|
it('renders json card segments by extracting the card title from common ARK fields', () => {
|
|
// mini-program share: meta.detail_1.title
|
|
const miniApp = JSON.stringify({
|
|
app: 'com.tencent.miniapp_01',
|
|
meta: { detail_1: { title: '哔哩哔哩', desc: '某个视频标题' } },
|
|
});
|
|
expect(formatMessageSegments([{ type: 'json', data: { data: miniApp } }])).toBe(
|
|
'[卡片:哔哩哔哩]',
|
|
);
|
|
|
|
// news share: meta.news.title
|
|
const news = JSON.stringify({ meta: { news: { title: '新闻标题' } } });
|
|
expect(formatMessageSegments([{ type: 'json', data: { data: news } }])).toBe(
|
|
'[卡片:新闻标题]',
|
|
);
|
|
|
|
// QQ server-side fallback (cardId -896954183): only `prompt` is present
|
|
const fallback = JSON.stringify({
|
|
app: 'com.tencent.structmsg',
|
|
prompt: '当前QQ版本不支持此应用,请升级',
|
|
});
|
|
expect(formatMessageSegments([{ type: 'json', data: { data: fallback } }])).toBe(
|
|
'[卡片:当前QQ版本不支持此应用,请升级]',
|
|
);
|
|
|
|
// generic single-section meta (no recognised key, but only one section)
|
|
const generic = JSON.stringify({ meta: { custom_section: { title: '通用卡片' } } });
|
|
expect(formatMessageSegments([{ type: 'json', data: { data: generic } }])).toBe(
|
|
'[卡片:通用卡片]',
|
|
);
|
|
|
|
// long titles are truncated
|
|
const long = JSON.stringify({ prompt: 'a'.repeat(100) });
|
|
expect(formatMessageSegments([{ type: 'json', data: { data: long } }])).toBe(
|
|
`[卡片:${'a'.repeat(30)}...]`,
|
|
);
|
|
});
|
|
|
|
it('falls back to [JSON] when the card payload is unparseable or empty', () => {
|
|
// non-string data.data
|
|
expect(formatMessageSegments([{ type: 'json', data: {} }])).toBe('[JSON]');
|
|
// invalid JSON
|
|
expect(formatMessageSegments([{ type: 'json', data: { data: 'not-json{' } }])).toBe(
|
|
'[JSON]',
|
|
);
|
|
// JSON without any recognised title field
|
|
expect(formatMessageSegments([{ type: 'json', data: { data: '{"foo":1}' } }])).toBe(
|
|
'[JSON]',
|
|
);
|
|
// empty string
|
|
expect(formatMessageSegments([{ type: 'json', data: { data: '' } }])).toBe('[JSON]');
|
|
// JSON array (not an object)
|
|
expect(formatMessageSegments([{ type: 'json', data: { data: '[1,2,3]' } }])).toBe('[JSON]');
|
|
});
|
|
|
|
it('renders unknown segment types as [type]', () => {
|
|
expect(formatMessageSegments([{ type: 'something_new', data: {} }])).toBe(
|
|
'[something_new]',
|
|
);
|
|
});
|
|
|
|
it('renders a string message as text (truncated)', () => {
|
|
expect(formatMessageSegments('hello')).toBe('hello');
|
|
expect(formatMessageSegments('a'.repeat(60))).toBe(`${'a'.repeat(50)}...`);
|
|
});
|
|
|
|
it('returns [空消息] for empty arrays / non-array / null', () => {
|
|
expect(formatMessageSegments([])).toBe('[空消息]');
|
|
expect(formatMessageSegments(null as any)).toBe('[空消息]');
|
|
expect(formatMessageSegments({} as any)).toBe('[空消息]');
|
|
});
|
|
});
|
|
|
|
describe('formatReply', () => {
|
|
it('renders the resolved sender + body preview when the original is in the store', async () => {
|
|
const store = fakeMessageStore({
|
|
42: {
|
|
message_type: 'group',
|
|
group_id: 1,
|
|
user_id: 100,
|
|
sender: { card: 'OrigSender' },
|
|
message: [{ type: 'text', data: { text: '早安' } }],
|
|
},
|
|
});
|
|
const id = fakeIdentity({ members: { '1/100': { uin: 100, card: 'OrigSender' } } });
|
|
expect(await formatReply(store, id, 42)).toBe('[回复 [OrigSender(100)]: 早安]');
|
|
});
|
|
|
|
it('falls back to [回复:<id>] when the original is missing', async () => {
|
|
const store = fakeMessageStore({});
|
|
const id = fakeIdentity();
|
|
expect(await formatReply(store, id, 9999)).toBe('[回复:9999]');
|
|
});
|
|
|
|
it('uses the sender baked into the stored event when cache misses', async () => {
|
|
const store = fakeMessageStore({
|
|
42: {
|
|
message_type: 'private',
|
|
user_id: 100,
|
|
sender: { nickname: 'StoredNick' },
|
|
message: [{ type: 'text', data: { text: 'hi' } }],
|
|
},
|
|
});
|
|
const id = fakeIdentity(); // no cache
|
|
expect(await formatReply(store, id, 42)).toBe('[回复 [StoredNick(100)]: hi]');
|
|
});
|
|
|
|
it('truncates the body preview to 30 chars', async () => {
|
|
const store = fakeMessageStore({
|
|
42: {
|
|
message_type: 'private',
|
|
user_id: 100,
|
|
sender: { nickname: 'X' },
|
|
message: [{ type: 'text', data: { text: 'a'.repeat(80) } }],
|
|
},
|
|
});
|
|
const id = fakeIdentity();
|
|
const out = await formatReply(store, id, 42);
|
|
// Inner text gets truncated to 50 by formatMessageSegments, then to 30 here
|
|
expect(out).toContain('...');
|
|
expect(out.length).toBeLessThan(80);
|
|
});
|
|
|
|
it('returns the fallback when findEvent throws', async () => {
|
|
const store = {
|
|
findEvent: () => {
|
|
throw new Error('store dead');
|
|
},
|
|
} as any;
|
|
const id = fakeIdentity();
|
|
expect(await formatReply(store, id, 42)).toBe('[回复:42]');
|
|
});
|
|
});
|
|
|
|
describe('formatEvent', () => {
|
|
const id = fakeIdentity({
|
|
groups: { 1: { groupName: 'Grp' } },
|
|
members: {
|
|
'1/100': { uin: 100, card: 'Alice' },
|
|
'1/200': { uin: 200, card: 'Bob' },
|
|
'1/300': { uin: 300, nickname: 'Carol' },
|
|
},
|
|
friends: {
|
|
500: { uin: 500, nickname: 'FriendA' },
|
|
600: { uin: 600, nickname: 'FriendB' },
|
|
},
|
|
});
|
|
|
|
it('returns null for kinds rendered elsewhere', () => {
|
|
expect(formatEvent(id, { kind: 'group_message' } as QQEventVariant)).toBeNull();
|
|
expect(formatEvent(id, { kind: 'friend_message' } as QQEventVariant)).toBeNull();
|
|
expect(formatEvent(id, { kind: 'temp_message' } as QQEventVariant)).toBeNull();
|
|
});
|
|
|
|
it('renders group_recall with all three name slots', () => {
|
|
expect(
|
|
formatEvent(id, {
|
|
kind: 'group_recall',
|
|
groupId: 1,
|
|
authorUin: 100,
|
|
operatorUin: 200,
|
|
} as QQEventVariant),
|
|
).toBe('群撤回 [Grp(1)] | [Alice(100)] 被 [Bob(200)] 撤回');
|
|
});
|
|
|
|
it('renders group_member_join with group name and joiner', () => {
|
|
expect(
|
|
formatEvent(id, {
|
|
kind: 'group_member_join',
|
|
groupId: 1,
|
|
userUin: 300,
|
|
userUid: '',
|
|
} as QQEventVariant),
|
|
).toBe('入群 [Carol(300)] 加入 [Grp(1)]');
|
|
});
|
|
|
|
it('renders group_poke with both endpoints', () => {
|
|
expect(
|
|
formatEvent(id, {
|
|
kind: 'group_poke',
|
|
groupId: 1,
|
|
userUin: 100,
|
|
targetUin: 200,
|
|
} as QQEventVariant),
|
|
).toBe('群戳 [Grp(1)] | [Alice(100)] -> [Bob(200)]');
|
|
});
|
|
|
|
it('renders friend_poke with its private peer and both endpoints', () => {
|
|
expect(
|
|
formatEvent(id, {
|
|
kind: 'friend_poke',
|
|
peerUin: 500,
|
|
senderUin: 600,
|
|
targetUin: 500,
|
|
} as QQEventVariant),
|
|
).toBe('私聊戳 [FriendA(500)] | [FriendB(600)] -> [FriendA(500)]');
|
|
});
|
|
|
|
it('degrades to numeric IDs when nothing is cached', () => {
|
|
const empty = fakeIdentity();
|
|
expect(
|
|
formatEvent(empty, {
|
|
kind: 'group_recall',
|
|
groupId: 1,
|
|
authorUin: 100,
|
|
operatorUin: 200,
|
|
} as QQEventVariant),
|
|
).toBe('群撤回 1 | 100 被 200 撤回');
|
|
});
|
|
});
|