Files
SnowLuma/packages/onebot/tests/contact-actions.test.ts
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

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',
);
});
});