// 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..method` shape automatically. New code can also // pass `apis: { contacts: { … } }` explicitly; the two merge. const APIS_ROUTING: Record = { fetchFriendList: 'contacts', fetchGroupList: 'contacts', fetchGroupMemberList: 'contacts', fetchUserProfile: 'contacts', fetchUserProfileByUid: 'contacts', fetchGroupRequests: 'contacts', fetchDownloadRKeys: 'contacts', fetchGroupDetail: 'contacts', }; function fakeBridge(overrides: Record = {}): BridgeInterface { const apisSynth: Record> = {}; 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 = {}): 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 { 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((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', ); }); });