import { describe, it, expect, vi, beforeEach } from 'vitest'; import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; import type { OidbBase } from '@snowluma/proto-defs/oidb'; import type { CustomFaceModifyResp, CustomFaceMoveBody, FaceroamOpResp, GroupAvatarExtra, Oidb0x7edResp, Oidb0xe17Resp, SetStatusReq, SetStatusResp, } from '@snowluma/proto-defs/oidb-actions/base'; vi.mock('@snowluma/protocol/bridge-oidb', async () => { const actual = await vi.importActual( '@snowluma/protocol/bridge-oidb', ); return { ...actual, makeOidbEnvelope: vi.fn((_oidbCmd, _subCmd, body) => ({ body })), }; }); vi.mock('@snowluma/protocol/highway', () => ({ fetchHighwaySession: vi.fn(async () => ({})), uploadHighwayHttp: vi.fn(async () => undefined), BufferChunkSource: class BufferChunkSource { constructor(readonly bytes: Uint8Array) {} }, })); vi.mock('@snowluma/protocol/highway/utils', () => ({ loadBinarySource: vi.fn(async () => ({ bytes: new Uint8Array([1, 2, 3]), fileName: 'avatar.bin', })), computeHashes: vi.fn(() => ({ md5: new Uint8Array(16), sha1: new Uint8Array(20) })), computeMd5: vi.fn(() => new Uint8Array(16)), })); import * as oidb from '@snowluma/protocol/bridge-oidb'; import * as highwayClient from '@snowluma/protocol/highway'; import { ProfileApi } from '../../src/bridge/apis/profile'; import { mockBridge } from './_helpers'; describe('apis/profile', () => { beforeEach(() => { vi.mocked(oidb.makeOidbEnvelope).mockClear(); vi.mocked(highwayClient.fetchHighwaySession).mockClear(); vi.mocked(highwayClient.uploadHighwayHttp).mockClear(); }); it('setOnlineStatus sends to status_svc.SetStatus and accepts an empty response', async () => { const bridge = mockBridge(); await new ProfileApi(bridge as any).setOnlineStatus(11, 0, 100); const [serviceCmd] = bridge.sendRawPacket.mock.calls[0]!; expect(serviceCmd).toBe('trpc.qq_new_tech.status_svc.StatusService.SetStatus'); }); it('setOnlineStatus does NOT include the customExt field 4 (varint 0x22 absent in body)', async () => { const bridge = mockBridge(); await new ProfileApi(bridge as any).setOnlineStatus(11, 0, 100); const [, body] = bridge.sendRawPacket.mock.calls[0]!; // proto field 4 length-delimited tag = (4 << 3) | 2 = 0x22. The // body should not contain that byte; if encoder ever leaks an // empty customExt it'd appear here. expect(Buffer.from(body).includes(0x22)).toBe(false); }); it('setDiyOnlineStatus hardcodes status=10 / extStatus=2000 and packs faceId+wording+faceType into customExt', async () => { const bridge = mockBridge(); await new ProfileApi(bridge as any).setDiyOnlineStatus(1234, '摸鱼中', 2); const [serviceCmd, body] = bridge.sendRawPacket.mock.calls[0]!; expect(serviceCmd).toBe('trpc.qq_new_tech.status_svc.StatusService.SetStatus'); // Decode the wire bytes back through the same schema to assert // every field landed in the right place. const decoded = protobuf_decode(body as Uint8Array); expect(decoded).toMatchObject({ status: 10, extStatus: 2000, customExt: { faceId: 1234, text: '摸鱼中', faceType: 2 }, }); expect(decoded.batteryStatus ?? 0).toBe(0); }); it("setOnlineStatus accepts QQ's success reply even when the reply code is non-zero (#405)", async () => { const bridge = mockBridge(); const respBuf = Buffer.from( protobuf_encode({ errCode: 1, errMsg: 'set status success', }), ); expect(respBuf.length).toBeGreaterThan(0); bridge.sendRawPacket.mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: respBuf, } as any); await expect( new ProfileApi(bridge as any).setOnlineStatus(10, 0, 100), ).resolves.toBeUndefined(); }); it('setOnlineStatus accepts a success-only reply body (#405)', async () => { const bridge = mockBridge(); const respBuf = Buffer.from( protobuf_encode({ errMsg: 'set status success' }), ); bridge.sendRawPacket.mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: respBuf, } as any); await expect(new ProfileApi(bridge as any).setOnlineStatus(10, 0)).resolves.toBeUndefined(); }); it('setDiyOnlineStatus surfaces server errors via the same path as setOnlineStatus', async () => { const bridge = mockBridge(); const respBuf = Buffer.from( protobuf_encode({ errCode: 1, errMsg: 'denied' }), ); bridge.sendRawPacket.mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: respBuf, } as any); await expect(new ProfileApi(bridge as any).setDiyOnlineStatus(1, 't', 1)).rejects.toThrow( /denied/, ); }); it('setDiyOnlineStatus rejects when the transport itself fails', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockResolvedValueOnce({ success: false, gotResponse: false, errorCode: -1, errorMessage: 'pipe closed', responseData: null, } as any); await expect(new ProfileApi(bridge as any).setDiyOnlineStatus(1, 't', 1)).rejects.toThrow( /pipe closed/, ); }); it('setProfile is a no-op when both arguments are undefined', async () => { const bridge = mockBridge(); await new ProfileApi(bridge as any).setProfile(); // Namespace migration: no SSO packet should fire at all. expect(bridge.sendRawPacket).not.toHaveBeenCalled(); }); it('setProfile only sends non-undefined fields', async () => { const bridge = mockBridge(); await new ProfileApi(bridge as any).setProfile('New Nick'); expect(bridge.sendRawPacket).toHaveBeenCalledOnce(); expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x112a_2'); }); it('setSelfLongNick targets 0x112a_2 wire cmd', async () => { const bridge = mockBridge(); await new ProfileApi(bridge as any).setSelfLongNick('hello world'); expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x112a_2'); }); it('setInputStatus resolves UID first and sends 0xcd4_1', async () => { const bridge = mockBridge(); await new ProfileApi(bridge as any).setInputStatus(10001, 1); expect(bridge.resolveUserUid).toHaveBeenCalledWith(10001); expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xcd4_1'); }); it('setAvatar loads bytes and pushes through the highway upload path (cmd 90)', async () => { const bridge = mockBridge(); await new ProfileApi(bridge as any).setAvatar('/some/avatar.png'); expect(highwayClient.fetchHighwaySession).toHaveBeenCalledOnce(); expect(highwayClient.uploadHighwayHttp).toHaveBeenCalledOnce(); const [, , cmdId, , , extend] = vi.mocked(highwayClient.uploadHighwayHttp).mock.calls[0]!; expect(cmdId).toBe(90); expect((extend as Uint8Array).length).toBe(0); // personal avatar has no extra payload }); it('setGroupAvatar uses cmdId 3000 and packs the Lagrange GroupAvatarExtra constants', async () => { const bridge = mockBridge(); await new ProfileApi(bridge as any).setGroupAvatar(12345, '/some/group-avatar.png'); expect(highwayClient.fetchHighwaySession).toHaveBeenCalledOnce(); expect(highwayClient.uploadHighwayHttp).toHaveBeenCalledOnce(); const [, , cmdId, , , extend] = vi.mocked(highwayClient.uploadHighwayHttp).mock.calls[0]!; expect(cmdId).toBe(3000); // Decode the extra blob back through the schema and assert every // protocol-prescribed constant lands where it should. const decoded = protobuf_decode(extend as Uint8Array); expect(decoded).toEqual({ type: 101, groupUin: 12345, field3: { field1: 1 }, field5: 3, field6: 1, }); }); it('setGroupAvatar rejects an empty file before hitting highway', async () => { const bridge = mockBridge(); const { loadBinarySource } = await import('@snowluma/protocol/highway/utils'); vi.mocked(loadBinarySource).mockResolvedValueOnce({ bytes: new Uint8Array(0), fileName: 'empty.png', } as any); await expect(new ProfileApi(bridge as any).setGroupAvatar(1, 'empty.png')).rejects.toThrow( /empty/, ); expect(highwayClient.fetchHighwaySession).not.toHaveBeenCalled(); }); it('getProfileLike (self): resolves self UID, returns formatted favorite + vote info', async () => { const bridge = mockBridge(); bridge.identity.findUinByUid.mockImplementation((uid: string) => uid === 'liker-uid' ? 20002 : 0, ); bridge.sendRawPacket.mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode>({ body: { userLikeInfos: [ { uid: 'u', time: 1700000000, favoriteInfo: { totalCount: 5, lastTime: 1, todayCount: 1 }, voteInfo: { totalCount: 7, newCount: 2, newNearbyCount: 1, lastVisitTime: 2, userInfos: [{ uid: 'liker-uid', nick: '点赞者', count: 3 }], }, }, ], } as any, }), ), }); const out = await new ProfileApi(bridge as any).getLike(); expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x7ed_13'); expect(out.favoriteInfo.total_count).toBe(5); expect(out.voteInfo.total_count).toBe(7); expect(out.voteInfo.userInfos).toEqual([ expect.objectContaining({ uid: 'liker-uid', uin: 20002, nick: '点赞者', count: 3 }), ]); }); it('getProfileLike throws on empty result', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode>({ body: { userLikeInfos: [] } as any }), ), }); await expect(new ProfileApi(bridge as any).getLike()).rejects.toThrow(/empty/); }); it('getProfileLike resolves uncached liker uins through Identity.resolveUin', async () => { const bridge = mockBridge(); bridge.identity.findUinByUid.mockReturnValue(null); bridge.identity.resolveUin.mockImplementation(async (uid: string) => uid === 'stranger-uid' ? 30003 : null, ); const fetchUserProfileByUid = vi.fn(); (bridge.apis.contacts as any).fetchUserProfileByUid = fetchUserProfileByUid; bridge.sendRawPacket.mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode>({ body: { userLikeInfos: [ { uid: 'self-uid', voteInfo: { userInfos: [{ uid: 'stranger-uid', nick: '陌生点赞者' }], }, }, ], }, }), ), }); const out = await new ProfileApi(bridge as any).getLike(); expect(bridge.identity.resolveUin).toHaveBeenCalledOnce(); expect(bridge.identity.resolveUin).toHaveBeenCalledWith('stranger-uid'); expect(fetchUserProfileByUid).not.toHaveBeenCalled(); expect(out.voteInfo.userInfos[0]?.uin).toBe(30003); }); it('getProfileLike keeps uin=0 when Identity.resolveUin misses', async () => { const bridge = mockBridge(); bridge.identity.findUinByUid.mockReturnValue(null); bridge.identity.resolveUin.mockResolvedValue(null); const fetchUserProfileByUid = vi.fn(async () => { throw new Error('profile lookup failed'); }); (bridge.apis.contacts as any).fetchUserProfileByUid = fetchUserProfileByUid; bridge.sendRawPacket.mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode>({ body: { userLikeInfos: [ { uid: 'self-uid', voteInfo: { userInfos: [{ uid: 'stranger-uid' }] }, }, ], }, }), ), }); const out = await new ProfileApi(bridge as any).getLike(); expect(out.voteInfo.userInfos[0]?.uin).toBe(0); expect(fetchUserProfileByUid).not.toHaveBeenCalled(); }); it('getUnidirectionalFriendList parses the embedded JSON body', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ jsonBody: JSON.stringify({ ActionStatus: 'OK', ErrorCode: 0, rpt_block_list: [{ uin: 10001 }, { uin: 10002 }], }), }), ), }); const out = await new ProfileApi(bridge as any).getUnidirectionalFriendList(); expect(out).toEqual([{ uin: 10001 }, { uin: 10002 }]); }); it('fetchCustomFaceIds slices the Faceroam list and skips the packet for count=0', async () => { const bridge = mockBridge(); const emojiA = '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0'; const emojiB = '10001_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0'; bridge.sendRawPacket.mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ retCode: 0, item: { faceIds: [emojiA, emojiB] }, }), ), } as any); const api = new ProfileApi(bridge as any); await expect(api.fetchCustomFaceIds(1)).resolves.toEqual([emojiA]); expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('Faceroam.OpReq'); await expect(api.fetchCustomFaceIds(0)).resolves.toEqual([]); expect(bridge.sendRawPacket).toHaveBeenCalledOnce(); }); it('fetchCustomFace maps ids to image urls', async () => { const bridge = mockBridge(); const emojiA = '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0'; bridge.sendRawPacket.mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ retCode: 0, item: { faceIds: [emojiA] }, }), ), } as any); await expect(new ProfileApi(bridge as any).fetchCustomFace(1)).resolves.toEqual([ `https://p.qpic.cn/qq_expression/10001/${emojiA}/0`, ]); }); it.each(['resource', 'md5'])( 'resolves a saved sticker by %s without fetching every description', async (kind) => { const bridge = mockBridge(); const md5 = 'ABCDEF0123456789ABCDEF0123456789'; const emojiId = `10001_0_0_0_${md5}_0_0`; bridge.sendRawPacket.mockResolvedValue({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ item: { faceIds: [emojiId] } }), ), } as any); const api = new ProfileApi(bridge as any); expect( await api.resolveCustomFace(kind === 'md5' ? md5.toLowerCase() : emojiId), ).toEqual({ emojiId, md5, url: `https://p.qpic.cn/qq_expression/10001/${emojiId}/0`, }); expect(bridge.sendRawPacket).toHaveBeenCalledTimes(1); await expect(api.resolveCustomFace('missing')).rejects.toThrow( /not in the saved sticker list/, ); await expect(api.resolveCustomFace(' ')).rejects.toThrow(/identifier is empty/); expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2); }, ); it('rejects a negative custom-face count before sending', async () => { const bridge = mockBridge(); await expect(new ProfileApi(bridge as any).fetchCustomFaceIds(-1)).rejects.toThrow( /non-negative integer/, ); expect(bridge.sendRawPacket).not.toHaveBeenCalled(); }); it('moveCustomFaceToFront reorders from the id list without parsing urls', async () => { const bridge = mockBridge(); const emojiA = '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0'; const emojiB = '10001_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0'; bridge.sendRawPacket .mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ retCode: 0, item: { faceIds: [emojiA, emojiB] }, }), ), } as any) .mockResolvedValue({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode>({ body: { retCode: 0 }, }), ), } as any); await new ProfileApi(bridge as any).moveCustomFaceToFront(emojiB); expect(bridge.sendRawPacket.mock.calls.map((call) => call[0])).toEqual([ 'Faceroam.OpReq', 'OidbSvcTrpcTcp.0x902f_1', 'OidbSvcTrpcTcp.0x902e_1', ]); const moveBody = protobuf_decode>( bridge.sendRawPacket.mock.calls[2]![1], ); expect(moveBody.body?.emojis?.map((emoji) => emoji.emojiId)).toEqual([emojiB, emojiA]); }); it('fetchCustomFaceDetails supplements the Faceroam ids with server descriptions (#333)', async () => { const bridge = mockBridge(); const emojiA = '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0'; const emojiB = '10001_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0'; bridge.sendRawPacket .mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ retCode: 0, item: { faceIds: [emojiA, emojiB] }, }), ), } as any) .mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode>({ body: { retCode: 0, entries: [{ emoji: { emojiId: emojiB }, desc: '第二个表情' }], }, }), ), } as any); const out = await new ProfileApi(bridge as any).fetchCustomFaceDetails(2); expect(bridge.sendRawPacket.mock.calls.map((call) => call[0])).toEqual([ 'Faceroam.OpReq', 'OidbSvcTrpcTcp.0x902e_1', ]); const detailRequest = protobuf_decode>( bridge.sendRawPacket.mock.calls[1]![1], ); expect(detailRequest.body?.emojis).toEqual([ { emojiId: emojiA, md5: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }, { emojiId: emojiB, md5: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' }, ]); expect(out).toEqual([ { emojiId: emojiA, url: `https://p.qpic.cn/qq_expression/10001/${emojiA}/0`, md5: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', desc: '', }, { emojiId: emojiB, url: `https://p.qpic.cn/qq_expression/10001/${emojiB}/0`, md5: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', desc: '第二个表情', }, ]); }); it('fetchCustomFaceDetails returns immediately for count=0', async () => { const bridge = mockBridge(); await expect(new ProfileApi(bridge as any).fetchCustomFaceDetails(0)).resolves.toEqual([]); expect(bridge.sendRawPacket).not.toHaveBeenCalled(); }); });