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
1377 lines
49 KiB
TypeScript
1377 lines
49 KiB
TypeScript
// First real OneBot unit test that exercises a module purely through
|
|
// BridgeInterface — no concrete Bridge, no SQLite, no native packet
|
|
// sender, no BridgeManager. This is the testability win that PR4 + PR5
|
|
// were supposed to unlock; the file exists to prove it.
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import type { BridgeInterface } from '../../src/bridge/bridge-interface';
|
|
import {
|
|
datalineFriendListEntries,
|
|
DATALINE_UIN_PAD,
|
|
} from '@snowluma/protocol/dataline/device-contacts';
|
|
import type {
|
|
FriendInfo,
|
|
GroupMemberInfo,
|
|
GroupRequestInfo,
|
|
QQGroupInfo,
|
|
UserProfileInfo,
|
|
} from '@snowluma/protocol/qq-info';
|
|
import {
|
|
getFriendList,
|
|
getGroupInfo,
|
|
getGroupFiles,
|
|
getGroupList,
|
|
getGroupMemberInfo,
|
|
getGroupMemberList,
|
|
getGroupSystemMessages,
|
|
getLoginInfo,
|
|
getStrangerInfo,
|
|
} from '../src/modules/contact-actions';
|
|
import type { OneBotInstanceContext } from '../src/instance-context';
|
|
|
|
/**
|
|
* A typed BridgeInterface where every property is undefined by default;
|
|
* accessing an un-stubbed property throws with a clear message so tests
|
|
* fail loudly if they exercise a code path the fake hasn't been told
|
|
* about. Tests just spread the methods they care about.
|
|
*/
|
|
// `apisAutoPromote` lets tests written before #6 (which stubbed flat
|
|
// methods like `fetchFriendList: vi.fn()` on bridge directly) keep
|
|
// working without restructuring — the helper rewrites flat stubs into
|
|
// the new `apis.<area>.method` shape automatically. New code can also
|
|
// pass `apis: { contacts: { … } }` explicitly; the two merge.
|
|
const APIS_ROUTING: Record<string, string> = {
|
|
fetchFriendList: 'contacts',
|
|
fetchGroupList: 'contacts',
|
|
fetchGroupMemberList: 'contacts',
|
|
fetchUserProfile: 'contacts',
|
|
fetchUserProfileByUid: 'contacts',
|
|
fetchGroupRequests: 'contacts',
|
|
fetchDownloadRKeys: 'contacts',
|
|
fetchGroupDetail: 'contacts',
|
|
};
|
|
|
|
function fakeBridge(overrides: Record<string, any> = {}): BridgeInterface {
|
|
const apisSynth: Record<string, Record<string, any>> = {};
|
|
|
|
for (const [k, v] of Object.entries(overrides)) {
|
|
const area = APIS_ROUTING[k];
|
|
|
|
if (area) {
|
|
if (!apisSynth[area]) {
|
|
apisSynth[area] = {};
|
|
}
|
|
|
|
apisSynth[area][k] = v;
|
|
}
|
|
}
|
|
|
|
const merged = { ...overrides, apis: { ...apisSynth, ...(overrides.apis ?? {}) } };
|
|
return new Proxy(merged as BridgeInterface, {
|
|
get(target, prop) {
|
|
if (prop in target) {
|
|
return (target as any)[prop];
|
|
}
|
|
|
|
throw new Error(`fakeBridge: '${String(prop)}' was not stubbed for this test`);
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Same Proxy trick for the IdentityService surface that contact-actions touches. */
|
|
function fakeIdentity(overrides: Record<string, unknown> = {}): BridgeInterface['identity'] {
|
|
const target = Object.create(Object.getPrototypeOf(overrides), {
|
|
uin: { value: '10001', enumerable: true },
|
|
...Object.getOwnPropertyDescriptors(overrides),
|
|
});
|
|
return new Proxy(target as any, {
|
|
get(target, prop) {
|
|
if (prop in target) {
|
|
return target[prop];
|
|
}
|
|
|
|
throw new Error(`fakeIdentity: '${String(prop)}' was not stubbed for this test`);
|
|
},
|
|
});
|
|
}
|
|
|
|
// ─── Fixture builders ───
|
|
|
|
function makeFriend(uin: number, nickname: string, remark = ''): FriendInfo {
|
|
return { uin, uid: `u_${uin}`, nickname, remark };
|
|
}
|
|
|
|
function makeGroup(
|
|
groupId: number,
|
|
groupName: string,
|
|
members: GroupMemberInfo[] = [],
|
|
): QQGroupInfo {
|
|
return {
|
|
groupId,
|
|
groupName,
|
|
remark: '',
|
|
memberCount: members.length,
|
|
memberMax: 500,
|
|
members: new Map(members.map((m) => [m.uin, m])),
|
|
};
|
|
}
|
|
|
|
function makeMember(uin: number, nickname: string, card = '', isRobot = false): GroupMemberInfo {
|
|
return {
|
|
uin,
|
|
uid: `u_${uin}`,
|
|
nickname,
|
|
card,
|
|
isRobot,
|
|
role: 'member',
|
|
level: 1,
|
|
title: '',
|
|
joinTime: 0,
|
|
lastSentTime: 0,
|
|
shutUpTime: 0,
|
|
};
|
|
}
|
|
|
|
function makeProfile(
|
|
uin: number,
|
|
nickname: string,
|
|
sex: 'male' | 'female' | 'unknown' = 'unknown',
|
|
age = 0,
|
|
sign = '',
|
|
remark = '',
|
|
): UserProfileInfo {
|
|
return {
|
|
uin,
|
|
uid: `u_${uin}`,
|
|
nickname,
|
|
remark,
|
|
qid: '',
|
|
sex,
|
|
age,
|
|
sign,
|
|
avatar: '',
|
|
level: 0,
|
|
qidianMasterFlag: 0,
|
|
qidianCrewFlag: 0,
|
|
qidianCrewFlag2: 0,
|
|
};
|
|
}
|
|
|
|
function makeGroupRequest(overrides: Partial<GroupRequestInfo> = {}): GroupRequestInfo {
|
|
return {
|
|
groupId: 999,
|
|
groupName: 'group',
|
|
targetUid: 'u_target',
|
|
targetUin: 123,
|
|
targetName: 'target',
|
|
invitorUid: 'u_inviter',
|
|
invitorUin: 456,
|
|
invitorName: 'inviter',
|
|
operatorUid: 'u_operator',
|
|
operatorUin: 789,
|
|
operatorName: 'operator',
|
|
sequence: 123456,
|
|
state: 1,
|
|
notifyType: 7,
|
|
eventType: 22,
|
|
comment: 'please',
|
|
filtered: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('public contact profile output', () => {
|
|
const profile = {
|
|
sign: '',
|
|
age: 0,
|
|
level: 0,
|
|
sex: 'unknown' as const,
|
|
vipFlag: false,
|
|
svipFlag: false,
|
|
country: 'CN',
|
|
city: 'City',
|
|
email: '',
|
|
birthdayMonth: 2,
|
|
birthdayDay: 29,
|
|
privilegeIcons: {
|
|
open: [{ type: 1, iconUrl: 'normal', deluxeIconUrl: 'deluxe' }],
|
|
closed: [],
|
|
},
|
|
};
|
|
const expected = {
|
|
long_nick: '',
|
|
age: 0,
|
|
level: 0,
|
|
sex: 'unknown',
|
|
is_qq_vip: false,
|
|
is_vip: false,
|
|
country: 'CN',
|
|
city: 'City',
|
|
email: '',
|
|
birthday_month: 2,
|
|
birthday_day: 29,
|
|
privilege_icons: {
|
|
open: [{ type: 1, icon_url: 'normal', deluxe_icon_url: 'deluxe' }],
|
|
closed: [],
|
|
},
|
|
};
|
|
|
|
it('returns the same public fields for friends and stranger profiles without invented values', async () => {
|
|
const fetchFriendList = vi.fn(async () => [
|
|
{ ...makeFriend(123, 'Name'), categoryId: 0, profile },
|
|
]);
|
|
const bridge = fakeBridge({
|
|
fetchFriendList,
|
|
fetchUserProfile: vi.fn(async () => ({ ...makeProfile(123, 'Name'), profile })),
|
|
});
|
|
const friends = await getFriendList(bridge, true);
|
|
expect(friends[0]).toMatchObject({ user_id: 123, category_id: 0, ...expected });
|
|
expect(friends[0]).not.toHaveProperty('phone_num');
|
|
expect(friends[0]).not.toHaveProperty('birthday_year');
|
|
expect(fetchFriendList).toHaveBeenCalledWith({ withProfile: true });
|
|
expect(await getStrangerInfo(bridge, 123)).toMatchObject(expected);
|
|
});
|
|
|
|
it('does not mask explicit enrichment failures with cached friends', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchFriendList: vi.fn().mockRejectedValue(new Error('profile failed')),
|
|
identity: fakeIdentity({ friends: [makeFriend(123, 'Cached')] }),
|
|
});
|
|
await expect(getFriendList(bridge, true)).rejects.toThrow('profile failed');
|
|
// The legacy ordinary-list cache behavior remains available.
|
|
expect((await getFriendList(bridge))[0]?.nickname).toBe('Cached');
|
|
});
|
|
});
|
|
|
|
// ─── Tests ───
|
|
|
|
describe('onebot/contact-actions / getLoginInfo', () => {
|
|
it('returns user_id parsed from uin and nickname from identity', () => {
|
|
const ref = {
|
|
uin: '10001',
|
|
bridge: fakeBridge({ identity: fakeIdentity({ nickname: 'self-nick' }) }),
|
|
} as unknown as OneBotInstanceContext;
|
|
expect(getLoginInfo(ref)).toEqual({ userId: 10001, nickname: 'self-nick' });
|
|
});
|
|
|
|
it('falls back to the uin string when identity nickname is empty', () => {
|
|
const ref = {
|
|
uin: '10001',
|
|
bridge: fakeBridge({ identity: fakeIdentity({ nickname: '' }) }),
|
|
} as unknown as OneBotInstanceContext;
|
|
expect(getLoginInfo(ref)).toEqual({ userId: 10001, nickname: '10001' });
|
|
});
|
|
});
|
|
|
|
describe('onebot/contact-actions / getFriendList', () => {
|
|
it('returns the fetched list mapped to OneBot shape', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchFriendList: vi.fn(async () => [makeFriend(22222, 'alice', 'best-friend')]),
|
|
});
|
|
const out = await getFriendList(bridge);
|
|
expect(out).toEqual([
|
|
{ user_id: 22222, nickname: 'alice', remark: 'best-friend' },
|
|
...datalineFriendListEntries(),
|
|
]);
|
|
expect(bridge.apis.contacts.fetchFriendList).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('falls back to identity.friends on fetch failure', async () => {
|
|
const cached = [makeFriend(33333, 'bob')];
|
|
const bridge = fakeBridge({
|
|
fetchFriendList: vi.fn(async () => {
|
|
throw new Error('network down');
|
|
}),
|
|
identity: fakeIdentity({ friends: cached }),
|
|
});
|
|
const out = await getFriendList(bridge);
|
|
expect(out).toEqual([
|
|
{ user_id: 33333, nickname: 'bob', remark: '' },
|
|
...datalineFriendListEntries(),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('onebot/contact-actions / getGroupFiles', () => {
|
|
it('returns folder last-upload metadata in OneBot field names', async () => {
|
|
const list = vi.fn(async () => ({
|
|
files: [],
|
|
folders: [
|
|
{
|
|
folderId: 'd1',
|
|
folderName: 'dir',
|
|
createTime: 100,
|
|
creator: 123,
|
|
creatorName: 'creator',
|
|
totalFileCount: 2,
|
|
lastUploadTime: 200,
|
|
lastUploader: 5_000_000_001,
|
|
lastUploaderName: 'uploader',
|
|
},
|
|
],
|
|
}));
|
|
const bridge = fakeBridge({ apis: { groupFile: { list } } });
|
|
|
|
const out = await getGroupFiles(bridge, 12345, '/');
|
|
|
|
expect(out.folders).toEqual([
|
|
expect.objectContaining({
|
|
last_upload_time: 200,
|
|
last_uploader: 5_000_000_001,
|
|
last_uploader_name: 'uploader',
|
|
}),
|
|
]);
|
|
expect(list).toHaveBeenCalledWith(12345, '/');
|
|
});
|
|
});
|
|
|
|
describe('onebot/contact-actions / getGroupList', () => {
|
|
it('triggers fetch when the in-memory roster is empty', async () => {
|
|
const fetched = [{ ...makeGroup(100, 'Group A'), remark: '工作群', allMuted: true }];
|
|
// `groups` starts empty; the fetch callback flips it to mimic
|
|
// bridge.apis.contacts.fetchGroupList writing back through identity.rememberGroups.
|
|
let groups: QQGroupInfo[] = [];
|
|
const bridge = fakeBridge({
|
|
fetchGroupList: vi.fn(async () => {
|
|
groups = fetched;
|
|
return fetched;
|
|
}),
|
|
identity: fakeIdentity({
|
|
get groups() {
|
|
return groups;
|
|
},
|
|
}),
|
|
});
|
|
const out = await getGroupList(bridge);
|
|
expect(bridge.apis.contacts.fetchGroupList).toHaveBeenCalledOnce();
|
|
expect(out).toEqual([
|
|
{
|
|
group_id: 100,
|
|
group_name: 'Group A',
|
|
group_remark: '工作群',
|
|
member_count: 0,
|
|
max_member_count: 500,
|
|
group_create_time: 0,
|
|
group_level: 0,
|
|
group_memo: '',
|
|
group_description: '',
|
|
group_all_shut: -1,
|
|
},
|
|
]);
|
|
});
|
|
|
|
// #490: group_description must carry the description itself, not a copy of
|
|
// group_memo (which prefers the announcement).
|
|
it('exposes the group description separately from the memo (#490)', async () => {
|
|
const fetched = [
|
|
{
|
|
...makeGroup(150, 'Both Set'),
|
|
memo: '公告正文',
|
|
description: '简介正文',
|
|
},
|
|
];
|
|
const bridge = fakeBridge({
|
|
fetchGroupList: vi.fn(async () => fetched),
|
|
identity: fakeIdentity({ groups: fetched }),
|
|
});
|
|
|
|
const out = await getGroupList(bridge);
|
|
|
|
expect(out[0]).toMatchObject({
|
|
group_memo: '公告正文',
|
|
group_description: '简介正文',
|
|
});
|
|
});
|
|
|
|
it('reports an empty group_description when QQ returns no description (#490)', async () => {
|
|
const fetched = [makeGroup(160, 'No Description')];
|
|
const bridge = fakeBridge({
|
|
fetchGroupList: vi.fn(async () => fetched),
|
|
identity: fakeIdentity({ groups: fetched }),
|
|
});
|
|
|
|
const out = await getGroupList(bridge);
|
|
|
|
expect(out[0]).toMatchObject({ group_memo: '', group_description: '' });
|
|
});
|
|
|
|
it('skips fetch when cache is populated and noCache is omitted', async () => {
|
|
const cached = [makeGroup(200, 'Group B')];
|
|
const bridge = fakeBridge({
|
|
fetchGroupList: vi.fn(async () => []),
|
|
identity: fakeIdentity({ groups: cached }),
|
|
});
|
|
await getGroupList(bridge);
|
|
expect(bridge.apis.contacts.fetchGroupList).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('forces fetch when noCache=true even with a populated cache', async () => {
|
|
const cached = [makeGroup(300, 'Group C')];
|
|
const bridge = fakeBridge({
|
|
fetchGroupList: vi.fn(async () => cached),
|
|
identity: fakeIdentity({ groups: cached }),
|
|
});
|
|
await getGroupList(bridge, true);
|
|
expect(bridge.apis.contacts.fetchGroupList).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('propagates fetch failures when noCache=true', async () => {
|
|
const cached = [makeGroup(400, 'Group D')];
|
|
const bridge = fakeBridge({
|
|
fetchGroupList: vi.fn(async () => {
|
|
throw new Error('boom');
|
|
}),
|
|
identity: fakeIdentity({ groups: cached }),
|
|
});
|
|
await expect(getGroupList(bridge, true)).rejects.toThrow('boom');
|
|
});
|
|
});
|
|
|
|
describe('onebot/contact-actions / getGroupInfo', () => {
|
|
it('returns the cached group without refreshing when group is known and noCache is false', async () => {
|
|
const cached = { ...makeGroup(500, 'Group E'), remark: '常用群' };
|
|
const findGroup = vi.fn((groupId: number) => (groupId === 500 ? cached : null));
|
|
const bridge = fakeBridge({
|
|
fetchGroupList: vi.fn(async () => []),
|
|
fetchGroupDetail: vi.fn(async () => cached),
|
|
identity: fakeIdentity({ findGroup }),
|
|
});
|
|
const out = await getGroupInfo(bridge, 500);
|
|
expect(out).toMatchObject({
|
|
group_id: 500,
|
|
group_name: 'Group E',
|
|
group_remark: '常用群',
|
|
});
|
|
expect(bridge.apis.contacts.fetchGroupList).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps joined-group mute state on the roster source across level-cache hits', async () => {
|
|
const cached = { ...makeGroup(510, 'Muted Group'), allMuted: true };
|
|
const fetchGroupDetail = vi.fn(async () => ({
|
|
...cached,
|
|
level: 6,
|
|
allMuted: false,
|
|
}));
|
|
const bridge = fakeBridge({
|
|
fetchGroupList: vi.fn(async () => []),
|
|
fetchGroupDetail,
|
|
identity: fakeIdentity({
|
|
findGroup: (groupId: number) => (groupId === 510 ? cached : null),
|
|
}),
|
|
});
|
|
|
|
const first = await getGroupInfo(bridge, 510);
|
|
const second = await getGroupInfo(bridge, 510);
|
|
|
|
expect(first).toMatchObject({ group_level: 6, group_all_shut: -1 });
|
|
expect(second).toMatchObject({ group_level: 6, group_all_shut: -1 });
|
|
expect(fetchGroupDetail).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('propagates detail refresh failures when noCache=true', async () => {
|
|
const cached = makeGroup(520, 'Refresh Group');
|
|
const bridge = fakeBridge({
|
|
fetchGroupList: vi.fn(async () => [cached]),
|
|
fetchGroupDetail: vi.fn(async () => {
|
|
throw new Error('detail unavailable');
|
|
}),
|
|
identity: fakeIdentity({
|
|
findGroup: (groupId: number) => (groupId === 520 ? cached : null),
|
|
}),
|
|
});
|
|
|
|
await expect(getGroupInfo(bridge, 520, true)).rejects.toThrow('detail unavailable');
|
|
});
|
|
|
|
it('triggers fetch when the group is unknown to the cache', async () => {
|
|
const cached = makeGroup(600, 'Group F');
|
|
let known = false;
|
|
const findGroup = vi.fn((groupId: number) => (known && groupId === 600 ? cached : null));
|
|
const fetchGroupList = vi.fn(async () => {
|
|
known = true;
|
|
return [cached];
|
|
});
|
|
const bridge = fakeBridge({
|
|
fetchGroupList,
|
|
fetchGroupDetail: vi.fn(async () => cached),
|
|
identity: fakeIdentity({ findGroup }),
|
|
});
|
|
const out = await getGroupInfo(bridge, 600);
|
|
expect(fetchGroupList).toHaveBeenCalledOnce();
|
|
expect(out).toMatchObject({ group_id: 600, group_name: 'Group F' });
|
|
});
|
|
|
|
it('returns null when the group remains unknown after fetch and the server has no such group', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchGroupList: vi.fn(async () => []),
|
|
fetchGroupDetail: vi.fn(async () => null),
|
|
identity: fakeIdentity({ findGroup: () => null }),
|
|
});
|
|
expect(await getGroupInfo(bridge, 700)).toBeNull();
|
|
});
|
|
|
|
it('resolves a non-member group via the by-id server lookup (e.g. a group invite name)', async () => {
|
|
const fetchGroupDetail = vi.fn(async () => ({
|
|
...makeGroup(7100, '邀请来的群'),
|
|
allMuted: true,
|
|
}));
|
|
const bridge = fakeBridge({
|
|
fetchGroupList: vi.fn(async () => []),
|
|
fetchGroupDetail,
|
|
identity: fakeIdentity({ findGroup: () => null }),
|
|
});
|
|
const out = await getGroupInfo(bridge, 7100);
|
|
expect(fetchGroupDetail).toHaveBeenCalledWith(7100);
|
|
expect(out).toMatchObject({
|
|
group_id: 7100,
|
|
group_name: '邀请来的群',
|
|
group_remark: '',
|
|
group_all_shut: -1,
|
|
});
|
|
});
|
|
|
|
it('caches the non-member lookup — a second call within TTL does not re-fetch', async () => {
|
|
const fetchGroupDetail = vi.fn(async () => makeGroup(7200, 'Cached Invite Group'));
|
|
const bridge = fakeBridge({
|
|
fetchGroupList: vi.fn(async () => []),
|
|
fetchGroupDetail,
|
|
identity: fakeIdentity({ findGroup: () => null }),
|
|
});
|
|
const a = await getGroupInfo(bridge, 7200);
|
|
const b = await getGroupInfo(bridge, 7200);
|
|
expect(a).toMatchObject({ group_name: 'Cached Invite Group' });
|
|
expect(b).toMatchObject({ group_name: 'Cached Invite Group' });
|
|
expect(fetchGroupDetail).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('noCache bypasses the non-member cache', async () => {
|
|
const fetchGroupDetail = vi.fn(async () => makeGroup(7300, 'NoCache Group'));
|
|
const bridge = fakeBridge({
|
|
fetchGroupList: vi.fn(async () => []),
|
|
fetchGroupDetail,
|
|
identity: fakeIdentity({ findGroup: () => null }),
|
|
});
|
|
await getGroupInfo(bridge, 7300);
|
|
await getGroupInfo(bridge, 7300, true);
|
|
expect(fetchGroupDetail).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('isolates non-member group details between accounts', async () => {
|
|
const groupId = 7400;
|
|
const firstFetch = vi.fn(async () => ({
|
|
...makeGroup(groupId, 'Account A View'),
|
|
allMuted: true,
|
|
}));
|
|
const secondFetch = vi.fn(async () => ({
|
|
...makeGroup(groupId, 'Account B View'),
|
|
allMuted: false,
|
|
}));
|
|
const firstBridge = fakeBridge({
|
|
fetchGroupList: vi.fn(async () => []),
|
|
fetchGroupDetail: firstFetch,
|
|
identity: fakeIdentity({ uin: '10001', findGroup: () => null }),
|
|
});
|
|
const secondBridge = fakeBridge({
|
|
fetchGroupList: vi.fn(async () => []),
|
|
fetchGroupDetail: secondFetch,
|
|
identity: fakeIdentity({ uin: '10002', findGroup: () => null }),
|
|
});
|
|
|
|
const first = await getGroupInfo(firstBridge, groupId);
|
|
const second = await getGroupInfo(secondBridge, groupId);
|
|
|
|
expect(first).toMatchObject({ group_name: 'Account A View', group_all_shut: -1 });
|
|
expect(second).toMatchObject({ group_name: 'Account B View', group_all_shut: 0 });
|
|
expect(firstFetch).toHaveBeenCalledOnce();
|
|
expect(secondFetch).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe('onebot/contact-actions / getGroupMemberList', () => {
|
|
it('returns the fetched roster mapped to OneBot shape', async () => {
|
|
const members = [makeMember(11, 'alice', 'A', true), makeMember(22, 'bob', 'B')];
|
|
const bridge = fakeBridge({
|
|
fetchGroupMemberList: vi.fn(async () => members),
|
|
});
|
|
const out = await getGroupMemberList(bridge, 800);
|
|
expect(out).toHaveLength(2);
|
|
expect(out[0]).toMatchObject({
|
|
group_id: 800,
|
|
user_id: 11,
|
|
nickname: 'alice',
|
|
card: 'A',
|
|
is_robot: true,
|
|
});
|
|
expect(out[1]).toMatchObject({ user_id: 22, is_robot: false });
|
|
});
|
|
|
|
it('falls back to the cached roster when fetch fails', async () => {
|
|
const cached = makeGroup(900, '', [makeMember(33, 'cached')]);
|
|
const bridge = fakeBridge({
|
|
fetchGroupMemberList: vi.fn(async () => {
|
|
throw new Error('net');
|
|
}),
|
|
identity: fakeIdentity({
|
|
findGroup: (gid: number) => (gid === 900 ? cached : null),
|
|
}),
|
|
});
|
|
const out = await getGroupMemberList(bridge, 900);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]).toMatchObject({ user_id: 33, nickname: 'cached' });
|
|
});
|
|
|
|
it('propagates fetch failure when no classified roster is known', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchGroupMemberList: vi.fn(async () => {
|
|
throw new Error('net');
|
|
}),
|
|
identity: fakeIdentity({ findGroup: () => null }),
|
|
});
|
|
await expect(getGroupMemberList(bridge, 999)).rejects.toThrow('net');
|
|
});
|
|
|
|
it('rejects an unclassified cached roster instead of reporting false', async () => {
|
|
const member = { ...makeMember(34, 'unknown'), isRobot: undefined };
|
|
const bridge = fakeBridge({
|
|
fetchGroupMemberList: vi.fn(async () => {
|
|
throw new Error('net');
|
|
}),
|
|
identity: fakeIdentity({
|
|
findGroup: (gid: number) => (gid === 901 ? makeGroup(901, '', [member]) : null),
|
|
}),
|
|
});
|
|
|
|
await expect(getGroupMemberList(bridge, 901)).rejects.toThrow(
|
|
'group member robot classification unavailable',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('onebot/contact-actions / getGroupMemberInfo', () => {
|
|
it('includes account level and completed account years without changing group level', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-10-04T00:00:00Z'));
|
|
|
|
try {
|
|
const profile = {
|
|
...makeProfile(88, 'member'),
|
|
level: 64,
|
|
regTime: Date.parse('2016-10-05T00:00:00Z') / 1000,
|
|
};
|
|
const fetchUserProfile = vi.fn(async () => profile);
|
|
const bridge = fakeBridge({
|
|
fetchUserProfile,
|
|
identity: fakeIdentity({ findGroupMember: () => makeMember(88, 'member') }),
|
|
});
|
|
expect(await getGroupMemberInfo(bridge, 1400, 88)).toMatchObject({
|
|
level: '1',
|
|
qq_level: 64,
|
|
qage: 9,
|
|
});
|
|
vi.setSystemTime(new Date('2026-10-05T00:00:00Z'));
|
|
expect(await getGroupMemberInfo(bridge, 1400, 88)).toMatchObject({ qage: 10 });
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('returns the cached member when present and noCache is false', async () => {
|
|
const member = makeMember(44, 'dave', 'D');
|
|
const bridge = fakeBridge({
|
|
fetchGroupMemberList: vi.fn(async () => []),
|
|
identity: fakeIdentity({
|
|
findGroupMember: (gid: number, uin: number) =>
|
|
gid === 1000 && uin === 44 ? member : null,
|
|
}),
|
|
});
|
|
const out = await getGroupMemberInfo(bridge, 1000, 44);
|
|
expect(out).toMatchObject({ user_id: 44, nickname: 'dave', card: 'D', is_robot: false });
|
|
expect(bridge.apis.contacts.fetchGroupMemberList).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('triggers fetch when member is unknown and re-queries the cache', async () => {
|
|
const member = makeMember(55, 'eve');
|
|
let known = false;
|
|
const findGroupMember = vi.fn((gid: number, uin: number) =>
|
|
known && gid === 1100 && uin === 55 ? member : null,
|
|
);
|
|
const fetchGroupMemberList = vi.fn(async () => {
|
|
known = true;
|
|
return [member];
|
|
});
|
|
const bridge = fakeBridge({
|
|
fetchGroupMemberList,
|
|
identity: fakeIdentity({ findGroupMember }),
|
|
});
|
|
const out = await getGroupMemberInfo(bridge, 1100, 55);
|
|
expect(fetchGroupMemberList).toHaveBeenCalledOnce();
|
|
expect(out).toMatchObject({ user_id: 55, nickname: 'eve' });
|
|
});
|
|
|
|
it('retries a failed legacy card refresh and then reuses the confirmed empty card', async () => {
|
|
const fresh = makeMember(66, 'legacy', '');
|
|
let member: GroupMemberInfo = { ...fresh, card: undefined };
|
|
const fetchGroupMemberList = vi
|
|
.fn(async () => {
|
|
member = fresh;
|
|
return [fresh];
|
|
})
|
|
.mockRejectedValueOnce(new Error('roster offline'));
|
|
const bridge = fakeBridge({
|
|
fetchGroupMemberList,
|
|
identity: fakeIdentity({ findGroupMember: () => member }),
|
|
});
|
|
await expect(getGroupMemberInfo(bridge, 1200, 66)).rejects.toThrow('roster offline');
|
|
expect(await getGroupMemberInfo(bridge, 1200, 66)).toMatchObject({
|
|
nickname: 'legacy',
|
|
card: '',
|
|
});
|
|
expect(await getGroupMemberInfo(bridge, 1200, 66)).toMatchObject({ card: '' });
|
|
expect(fetchGroupMemberList).toHaveBeenCalledTimes(2);
|
|
expect(fetchGroupMemberList).toHaveBeenCalledWith(1200, { force: false });
|
|
});
|
|
|
|
it('refreshes a persisted member whose robot classification is unknown', async () => {
|
|
let member: GroupMemberInfo = { ...makeMember(66, 'legacy'), isRobot: undefined };
|
|
const fetchGroupMemberList = vi.fn(async () => {
|
|
member = makeMember(66, 'legacy', '', true);
|
|
return [member];
|
|
});
|
|
const bridge = fakeBridge({
|
|
fetchGroupMemberList,
|
|
identity: fakeIdentity({
|
|
findGroupMember: (gid: number, uin: number) =>
|
|
gid === 1200 && uin === 66 ? member : null,
|
|
}),
|
|
});
|
|
|
|
const out = await getGroupMemberInfo(bridge, 1200, 66);
|
|
|
|
expect(fetchGroupMemberList).toHaveBeenCalledWith(1200, { force: false });
|
|
expect(out).toMatchObject({ user_id: 66, is_robot: true });
|
|
});
|
|
|
|
it('passes through the qidian (企点) flags from the member profile', async () => {
|
|
const member = makeMember(88, 'QidianMember', 'Q');
|
|
const bridge = fakeBridge({
|
|
fetchGroupMemberList: vi.fn(async () => []),
|
|
identity: fakeIdentity({
|
|
findGroupMember: (gid: number, uin: number) =>
|
|
gid === 1400 && uin === 88 ? member : null,
|
|
}),
|
|
fetchUserProfile: vi.fn(async () => ({
|
|
...makeProfile(88, 'QidianMember', 'male'),
|
|
qidianMasterFlag: 0,
|
|
qidianCrewFlag: 1,
|
|
qidianCrewFlag2: 0,
|
|
})),
|
|
});
|
|
|
|
const out = await getGroupMemberInfo(bridge, 1400, 88);
|
|
|
|
expect(out).toMatchObject({
|
|
user_id: 88,
|
|
qidian_master_flag: 0,
|
|
qidian_crew_flag: 1,
|
|
qidian_crew_flag_2: 0,
|
|
});
|
|
expect(bridge.apis.contacts.fetchUserProfile).toHaveBeenCalledWith(88);
|
|
});
|
|
|
|
it('falls back to zero qidian flags when the profile fetch fails', async () => {
|
|
const member = makeMember(89, 'NoProfileMember', 'N');
|
|
const bridge = fakeBridge({
|
|
fetchGroupMemberList: vi.fn(async () => []),
|
|
identity: fakeIdentity({
|
|
findGroupMember: (gid: number, uin: number) =>
|
|
gid === 1500 && uin === 89 ? member : null,
|
|
}),
|
|
fetchUserProfile: vi.fn(async () => {
|
|
throw new Error('net down');
|
|
}),
|
|
});
|
|
|
|
const out = await getGroupMemberInfo(bridge, 1500, 89);
|
|
|
|
expect(out).toMatchObject({
|
|
user_id: 89,
|
|
qidian_master_flag: 0,
|
|
qidian_crew_flag: 0,
|
|
qidian_crew_flag_2: 0,
|
|
});
|
|
});
|
|
|
|
it('rejects when an unknown cached classification cannot be refreshed', async () => {
|
|
const member: GroupMemberInfo = { ...makeMember(77, 'unknown'), isRobot: undefined };
|
|
const bridge = fakeBridge({
|
|
fetchGroupMemberList: vi.fn(async () => {
|
|
throw new Error('range unavailable');
|
|
}),
|
|
identity: fakeIdentity({
|
|
findGroupMember: (gid: number, uin: number) =>
|
|
gid === 1300 && uin === 77 ? member : null,
|
|
}),
|
|
});
|
|
|
|
await expect(getGroupMemberInfo(bridge, 1300, 77)).rejects.toThrow('range unavailable');
|
|
});
|
|
});
|
|
|
|
describe('onebot/contact-actions / getStrangerInfo', () => {
|
|
it('exposes available compatibility fields without fabricating login days', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchUserProfile: vi.fn(async () => ({
|
|
...makeProfile(55555, 'member'),
|
|
qid: 'my_qid',
|
|
level: 64,
|
|
regTime: 1_200_000_000,
|
|
birthdayYear: 2000,
|
|
birthdayMonth: 3,
|
|
birthdayDay: 15,
|
|
svipFlag: true,
|
|
yearVipFlag: false,
|
|
vipLevel: 7,
|
|
})),
|
|
});
|
|
const out = await getStrangerInfo(bridge, 55555);
|
|
expect(out).toMatchObject({
|
|
uid: 'u_55555',
|
|
qid: 'my_qid',
|
|
qq_level: 64,
|
|
qqLevel: 64,
|
|
level: 64,
|
|
reg_time: 1_200_000_000,
|
|
birthday_year: 2000,
|
|
birthday_month: 3,
|
|
birthday_day: 15,
|
|
is_vip: true,
|
|
is_years_vip: false,
|
|
vip_level: 7,
|
|
});
|
|
expect(out).not.toHaveProperty('login_days');
|
|
});
|
|
|
|
it('omits unavailable registration and membership values', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchUserProfile: vi.fn(async () => makeProfile(55555, 'member')),
|
|
});
|
|
const out = await getStrangerInfo(bridge, 55555);
|
|
|
|
for (const field of [
|
|
'reg_time',
|
|
'birthday_year',
|
|
'birthday_month',
|
|
'birthday_day',
|
|
'is_vip',
|
|
'is_years_vip',
|
|
'vip_level',
|
|
]) {
|
|
expect(out).not.toHaveProperty(field);
|
|
}
|
|
});
|
|
|
|
it('omits only birthday_year when the birth year is hidden', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchUserProfile: vi.fn(async () => ({
|
|
...makeProfile(55555, 'member'),
|
|
birthdayMonth: 12,
|
|
birthdayDay: 31,
|
|
})),
|
|
});
|
|
const out = await getStrangerInfo(bridge, 55555);
|
|
expect(out).toMatchObject({ birthday_month: 12, birthday_day: 31 });
|
|
expect(out).not.toHaveProperty('birthday_year');
|
|
});
|
|
|
|
it('returns a fetched profile', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchUserProfile: vi.fn(async () =>
|
|
makeProfile(55555, 'Eve', 'female', 25, 'Stay curious', 'Teammate'),
|
|
),
|
|
});
|
|
const out = await getStrangerInfo(bridge, 55555);
|
|
expect(out).toMatchObject({
|
|
user_id: 55555,
|
|
nickname: 'Eve',
|
|
sex: 'female',
|
|
age: 25,
|
|
remark: 'Teammate',
|
|
long_nick: 'Stay curious',
|
|
});
|
|
});
|
|
|
|
it('passes through the qidian (企点) flags from the profile', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchUserProfile: vi.fn(async () => ({
|
|
...makeProfile(55555, 'QidianWorker', 'male'),
|
|
qidianMasterFlag: 0,
|
|
qidianCrewFlag: 1,
|
|
qidianCrewFlag2: 0,
|
|
})),
|
|
});
|
|
const out = await getStrangerInfo(bridge, 55555);
|
|
expect(out).toMatchObject({
|
|
user_id: 55555,
|
|
qidian_master_flag: 0,
|
|
qidian_crew_flag: 1,
|
|
qidian_crew_flag_2: 0,
|
|
});
|
|
});
|
|
|
|
it('passes through the qidian enterprise (企业) data-card when available', async () => {
|
|
const fetchQidianCorpInfo = vi.fn(async () => ({
|
|
name: '测试企业',
|
|
intro: '测试简介',
|
|
website: 'https://example.com',
|
|
slogan: '测试签名',
|
|
address: '测试地址',
|
|
phone: '400-0000-0000',
|
|
email: 'contact@example.com',
|
|
}));
|
|
const bridge = fakeBridge({
|
|
apis: {
|
|
contacts: {
|
|
fetchUserProfile: vi.fn(async () => ({
|
|
...makeProfile(55555, 'QidianWorker', 'male'),
|
|
qidianMasterFlag: 0,
|
|
qidianCrewFlag: 1,
|
|
qidianCrewFlag2: 0,
|
|
})),
|
|
fetchQidianCorpInfo,
|
|
},
|
|
},
|
|
});
|
|
const out = await getStrangerInfo(bridge, 55555);
|
|
expect(out).toMatchObject({
|
|
user_id: 55555,
|
|
qidian_master_flag: 0,
|
|
qidian_crew_flag: 1,
|
|
qidian_crew_flag_2: 0,
|
|
qidian_enterprise_name: '测试企业',
|
|
});
|
|
expect(fetchQidianCorpInfo).toHaveBeenCalledWith(55555);
|
|
});
|
|
|
|
it('leaves enterprise fields empty for non-qidian accounts and never calls the corp lookup', async () => {
|
|
const fetchQidianCorpInfo = vi.fn(async () => ({
|
|
name: 'x',
|
|
intro: '',
|
|
website: '',
|
|
slogan: '',
|
|
address: '',
|
|
phone: '',
|
|
email: '',
|
|
}));
|
|
const bridge = fakeBridge({
|
|
apis: {
|
|
contacts: {
|
|
fetchUserProfile: vi.fn(async () => makeProfile(88888, 'Normal', 'male')),
|
|
fetchQidianCorpInfo,
|
|
},
|
|
},
|
|
});
|
|
const out = await getStrangerInfo(bridge, 88888);
|
|
expect(out).toMatchObject({
|
|
user_id: 88888,
|
|
qidian_enterprise_name: '',
|
|
});
|
|
expect(fetchQidianCorpInfo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back to identity.findUserProfile when fetch fails but the profile is cached', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchUserProfile: vi.fn(async () => {
|
|
throw new Error('net');
|
|
}),
|
|
identity: fakeIdentity({
|
|
findUserProfile: (uin: number) =>
|
|
uin === 66666
|
|
? makeProfile(
|
|
66666,
|
|
'Frank',
|
|
'male',
|
|
30,
|
|
'Cached signature',
|
|
'Cached remark',
|
|
)
|
|
: null,
|
|
}),
|
|
});
|
|
const out = await getStrangerInfo(bridge, 66666);
|
|
expect(out).toMatchObject({
|
|
user_id: 66666,
|
|
nickname: 'Frank',
|
|
remark: 'Cached remark',
|
|
long_nick: 'Cached signature',
|
|
});
|
|
});
|
|
|
|
it('falls back to the friend roster when only a cached friend remark is available', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchUserProfile: vi.fn(async () => {
|
|
throw new Error('net');
|
|
}),
|
|
identity: fakeIdentity({
|
|
findUserProfile: () => null,
|
|
findFriend: (uin: number) =>
|
|
uin === 77777 ? makeFriend(77777, 'Grace', 'Project lead') : null,
|
|
}),
|
|
});
|
|
|
|
expect(await getStrangerInfo(bridge, 77777)).toEqual({
|
|
user_id: 77777,
|
|
nickname: 'Grace',
|
|
remark: 'Project lead',
|
|
sex: 'unknown',
|
|
age: 0,
|
|
long_nick: '',
|
|
});
|
|
});
|
|
|
|
it('returns null when neither fetch nor cache produces a profile', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchUserProfile: vi.fn(async () => {
|
|
throw new Error('net');
|
|
}),
|
|
identity: fakeIdentity({ findUserProfile: () => null, findFriend: () => null }),
|
|
});
|
|
expect(await getStrangerInfo(bridge, 99999)).toBeNull();
|
|
});
|
|
|
|
it('returns the official my-device contact without a profile fetch', async () => {
|
|
const fetchUserProfile = vi.fn(async () => {
|
|
throw new Error('should not fetch');
|
|
});
|
|
const bridge = fakeBridge({ fetchUserProfile });
|
|
expect(await getStrangerInfo(bridge, DATALINE_UIN_PAD)).toEqual({
|
|
user_id: DATALINE_UIN_PAD,
|
|
nickname: '我的Pad',
|
|
remark: '我的Pad',
|
|
sex: 'unknown',
|
|
age: 0,
|
|
long_nick: '',
|
|
});
|
|
expect(fetchUserProfile).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('onebot/contact-actions / getGroupSystemMessages', () => {
|
|
it('reports the invitee, inviter and reviewing operator independently (#643)', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchGroupRequests: vi.fn(async (filtered: boolean) =>
|
|
filtered ? [] : [makeGroupRequest({ state: 2 })],
|
|
),
|
|
});
|
|
expect(await getGroupSystemMessages(bridge)).toEqual([
|
|
expect.objectContaining({
|
|
requester_uin: 123,
|
|
target_uin: 123,
|
|
target_nick: 'target',
|
|
target_status: 'known',
|
|
invitor_uin: 456,
|
|
invitor_nick: 'inviter',
|
|
invitor_status: 'known',
|
|
operator_uin: 789,
|
|
operator_nick: 'operator',
|
|
operator_status: 'known',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('marks omitted actors unknown rather than claiming there was no inviter', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchGroupRequests: vi.fn(async (filtered: boolean) =>
|
|
filtered
|
|
? []
|
|
: [
|
|
makeGroupRequest({
|
|
invitorUid: '',
|
|
invitorUin: 0,
|
|
invitorName: '',
|
|
operatorUid: '',
|
|
operatorUin: 0,
|
|
operatorName: '',
|
|
}),
|
|
],
|
|
),
|
|
});
|
|
expect(await getGroupSystemMessages(bridge)).toEqual([
|
|
expect.objectContaining({
|
|
invitor_uin: 0,
|
|
invitor_status: 'unknown',
|
|
operator_uin: 0,
|
|
operator_status: 'unknown',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('resolves UID-only inviters and operators as well as the requester', async () => {
|
|
const fetchUserProfileByUid = vi.fn(async (uid: string) =>
|
|
makeProfile(uid === 'u_inviter' ? 456 : 789, uid),
|
|
);
|
|
const bridge = fakeBridge({
|
|
fetchGroupRequests: vi.fn(async (filtered: boolean) =>
|
|
filtered ? [] : [makeGroupRequest({ invitorUin: 0, operatorUin: 0 })],
|
|
),
|
|
fetchUserProfileByUid,
|
|
identity: fakeIdentity({ findUinByUid: () => null }),
|
|
});
|
|
expect(await getGroupSystemMessages(bridge)).toEqual([
|
|
expect.objectContaining({
|
|
invitor_uin: 456,
|
|
invitor_status: 'known',
|
|
operator_uin: 789,
|
|
operator_status: 'known',
|
|
}),
|
|
]);
|
|
expect(fetchUserProfileByUid.mock.calls.map(([uid]) => uid).sort()).toEqual([
|
|
'u_inviter',
|
|
'u_operator',
|
|
]);
|
|
});
|
|
|
|
it.each(['invitor', 'operator'] as const)(
|
|
'propagates %s lookup failures instead of reporting absence',
|
|
async (role) => {
|
|
const bridge = fakeBridge({
|
|
fetchGroupRequests: vi.fn(async (filtered: boolean) =>
|
|
filtered ? [] : [makeGroupRequest({ [`${role}Uin`]: 0 })],
|
|
),
|
|
fetchUserProfileByUid: vi.fn(async () => {
|
|
throw new Error('profile offline');
|
|
}),
|
|
identity: fakeIdentity({ findUinByUid: () => null }),
|
|
});
|
|
await expect(getGroupSystemMessages(bridge)).rejects.toThrow(
|
|
`failed to resolve ${role} UIN`,
|
|
);
|
|
},
|
|
);
|
|
|
|
it('reads both request inboxes with the requested per-inbox count', async () => {
|
|
const fetchGroupRequests = vi.fn(async (filtered: boolean) =>
|
|
filtered
|
|
? [makeGroupRequest({ sequence: 2, filtered: true, targetUin: 202, targetUid: '' })]
|
|
: [
|
|
makeGroupRequest({
|
|
sequence: 1,
|
|
filtered: false,
|
|
targetUin: 101,
|
|
targetUid: '',
|
|
}),
|
|
],
|
|
);
|
|
const bridge = fakeBridge({ fetchGroupRequests });
|
|
|
|
const result = await getGroupSystemMessages(bridge, { count: 80 });
|
|
|
|
expect(fetchGroupRequests).toHaveBeenNthCalledWith(1, false, 80);
|
|
expect(fetchGroupRequests).toHaveBeenNthCalledWith(2, true, 80);
|
|
expect(result.map((item) => item.request_id)).toEqual([1, 2]);
|
|
});
|
|
|
|
it('returns the same canonical flag accepted by set_group_add_request', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchGroupRequests: vi.fn(async () => [
|
|
makeGroupRequest({ sequence: 123456, groupId: 999, eventType: 22, filtered: true }),
|
|
]),
|
|
});
|
|
|
|
const result = await getGroupSystemMessages(bridge);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toMatchObject({
|
|
request_id: 123456,
|
|
group_id: 999,
|
|
checked: false,
|
|
flag: 'slreq:1:123456:999:22:1',
|
|
});
|
|
});
|
|
|
|
it('includes admin-approval invitations with the invited account and inviter', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchGroupRequests: vi.fn(async (filtered: boolean) =>
|
|
filtered
|
|
? []
|
|
: [
|
|
makeGroupRequest({
|
|
notifyType: 5,
|
|
eventType: 22,
|
|
targetUin: 10_001,
|
|
targetName: 'invitee',
|
|
invitorUin: 45_678,
|
|
invitorName: 'inviter',
|
|
}),
|
|
],
|
|
),
|
|
});
|
|
|
|
const result = await getGroupSystemMessages(bridge);
|
|
|
|
expect(result).toEqual([
|
|
expect.objectContaining({
|
|
requester_uin: 10_001,
|
|
requester_nick: 'invitee',
|
|
invitor_uin: 45_678,
|
|
invitor_nick: 'inviter',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('reports the inviter as requester for group invitations', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchGroupRequests: vi.fn(async (filtered: boolean) =>
|
|
filtered
|
|
? []
|
|
: [
|
|
makeGroupRequest({
|
|
notifyType: 1,
|
|
eventType: 2,
|
|
targetUin: 10_001,
|
|
targetName: 'bot',
|
|
invitorUin: 45_678,
|
|
invitorName: 'inviter',
|
|
}),
|
|
],
|
|
),
|
|
});
|
|
|
|
const result = await getGroupSystemMessages(bridge);
|
|
|
|
expect(result).toEqual([
|
|
expect.objectContaining({
|
|
requester_uin: 45_678,
|
|
requester_nick: 'inviter',
|
|
target_uin: 10_001,
|
|
target_nick: 'bot',
|
|
invitor_uin: 45_678,
|
|
invitor_nick: 'inviter',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('filters by group and pending state before resolving requester identities', async () => {
|
|
const fetchUserProfileByUid = vi.fn(async (uid: string) =>
|
|
makeProfile(uid === 'u_pending' ? 101 : 202, uid),
|
|
);
|
|
const bridge = fakeBridge({
|
|
fetchGroupRequests: vi.fn(async () => [
|
|
makeGroupRequest({
|
|
groupId: 100,
|
|
sequence: 1,
|
|
state: 1,
|
|
targetUid: 'u_pending',
|
|
targetUin: 0,
|
|
}),
|
|
makeGroupRequest({
|
|
groupId: 100,
|
|
sequence: 2,
|
|
state: 2,
|
|
targetUid: 'u_checked',
|
|
targetUin: 0,
|
|
}),
|
|
makeGroupRequest({
|
|
groupId: 200,
|
|
sequence: 3,
|
|
state: 1,
|
|
targetUid: 'u_other_group',
|
|
targetUin: 0,
|
|
}),
|
|
]),
|
|
fetchUserProfileByUid,
|
|
identity: fakeIdentity({ findUinByUid: () => null }),
|
|
});
|
|
|
|
const result = await getGroupSystemMessages(bridge, { groupId: 100, onlyPending: true });
|
|
|
|
expect(result).toEqual([
|
|
expect.objectContaining({
|
|
group_id: 100,
|
|
request_id: 1,
|
|
requester_uin: 101,
|
|
checked: false,
|
|
}),
|
|
]);
|
|
expect(fetchUserProfileByUid).toHaveBeenCalledOnce();
|
|
expect(fetchUserProfileByUid).toHaveBeenCalledWith('u_pending');
|
|
});
|
|
|
|
it('deduplicates requester profile lookups and limits QQ query concurrency', async () => {
|
|
let inFlight = 0;
|
|
let peak = 0;
|
|
const fetchUserProfileByUid = vi.fn(async (uid: string) => {
|
|
inFlight++;
|
|
peak = Math.max(peak, inFlight);
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
inFlight--;
|
|
return makeProfile(1_000 + Number(uid.slice(2)), uid);
|
|
});
|
|
const requests = Array.from({ length: 6 }, (_, index) =>
|
|
makeGroupRequest({
|
|
sequence: index + 1,
|
|
targetUid: `u_${index + 1}`,
|
|
targetUin: 0,
|
|
}),
|
|
);
|
|
requests.push(makeGroupRequest({ sequence: 7, targetUid: 'u_1', targetUin: 0 }));
|
|
const bridge = fakeBridge({
|
|
fetchGroupRequests: vi.fn(async () => requests),
|
|
fetchUserProfileByUid,
|
|
identity: fakeIdentity({ findUinByUid: () => null }),
|
|
});
|
|
|
|
const result = await getGroupSystemMessages(bridge);
|
|
|
|
expect(fetchUserProfileByUid).toHaveBeenCalledTimes(6);
|
|
expect(peak).toBeLessThanOrEqual(4);
|
|
expect(result.map((item) => item.requester_uin)).toEqual([
|
|
1001, 1002, 1003, 1004, 1005, 1006, 1001,
|
|
]);
|
|
});
|
|
|
|
it('uses a known UIN from a duplicate request without querying QQ', async () => {
|
|
const fetchUserProfileByUid = vi.fn(async () => {
|
|
throw new Error('profile lookup must not run');
|
|
});
|
|
const bridge = fakeBridge({
|
|
fetchGroupRequests: vi.fn(async () => [
|
|
makeGroupRequest({ sequence: 1, targetUid: 'u_known', targetUin: 0 }),
|
|
makeGroupRequest({ sequence: 2, targetUid: 'u_known', targetUin: 4242 }),
|
|
]),
|
|
fetchUserProfileByUid,
|
|
identity: fakeIdentity({ findUinByUid: () => null }),
|
|
});
|
|
|
|
const result = await getGroupSystemMessages(bridge);
|
|
|
|
expect(fetchUserProfileByUid).not.toHaveBeenCalled();
|
|
expect(result.map((item) => item.requester_uin)).toEqual([4242, 4242]);
|
|
});
|
|
|
|
it('propagates inbox failures instead of disguising them as an empty list', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchGroupRequests: vi.fn(async () => {
|
|
throw new Error('OIDB unavailable');
|
|
}),
|
|
});
|
|
|
|
await expect(getGroupSystemMessages(bridge)).rejects.toThrow('OIDB unavailable');
|
|
});
|
|
|
|
it('identifies the requester UID when profile resolution fails', async () => {
|
|
const bridge = fakeBridge({
|
|
fetchGroupRequests: vi.fn(async () => [
|
|
makeGroupRequest({ targetUid: 'u_missing', targetUin: 0 }),
|
|
]),
|
|
fetchUserProfileByUid: vi.fn(async () => {
|
|
throw new Error('profile OIDB unavailable');
|
|
}),
|
|
identity: fakeIdentity({ findUinByUid: () => null }),
|
|
});
|
|
|
|
await expect(getGroupSystemMessages(bridge)).rejects.toThrow(
|
|
'failed to resolve requester UIN for UID u_missing: profile OIDB unavailable',
|
|
);
|
|
});
|
|
});
|