diff --git a/packages/core/tests/actions/profile.test.ts b/packages/core/tests/actions/profile.test.ts index ae4b68b9..21789e9d 100644 --- a/packages/core/tests/actions/profile.test.ts +++ b/packages/core/tests/actions/profile.test.ts @@ -352,12 +352,12 @@ describe('apis/profile', () => { errorCode: 0, errorMessage: '', responseData: Buffer.from( - protobuf_encode>({ - body: { - jsonBody: JSON.stringify({ - rpt_block_list: [{ uin: 10001 }, { uin: 10002 }], - }), - } as any, + protobuf_encode({ + jsonBody: JSON.stringify({ + ActionStatus: 'OK', + ErrorCode: 0, + rpt_block_list: [{ uin: 10001 }, { uin: 10002 }], + }), }), ), }); diff --git a/packages/proto-defs/src/oidb-actions/base.ts b/packages/proto-defs/src/oidb-actions/base.ts index 518beb92..cfeb91ba 100644 --- a/packages/proto-defs/src/oidb-actions/base.ts +++ b/packages/proto-defs/src/oidb-actions/base.ts @@ -637,10 +637,13 @@ export interface Oidb0x8a7Resp { canAtAll?: pb<6, bool>; } export interface Oidb0xe17Req { + subCommand?: pb_optional<2, uint_32>; jsonBody?: pb<3, string>; } export interface Oidb0xe17Resp { + errorCode?: pb<3, uint_32>; jsonBody?: pb<4, string>; + errorMessage?: pb<5, string>; } export interface Oidb0x112aProfileInfo { tag?: pb<1, uint_32>; diff --git a/packages/protocol/src/oidb-services/profile/get-unidirectional-friend-list.ts b/packages/protocol/src/oidb-services/profile/get-unidirectional-friend-list.ts index bec6bcd7..9cdee4ca 100644 --- a/packages/protocol/src/oidb-services/profile/get-unidirectional-friend-list.ts +++ b/packages/protocol/src/oidb-services/profile/get-unidirectional-friend-list.ts @@ -1,28 +1,22 @@ -// 0xE17_0 — fetch the unidirectional friend block list. Wire name is -// non-standard (`MQUpdateSvc_com_qq_ti.web.OidbSvc.0xe17_0`) — uses -// the `wireName` override to bypass the default -// `OidbSvcTrpcTcp.0xNNNN_N` scheme. -// -// Request and response carry a JSON-encoded string in a single -// protobuf field, so the namespace's `serialize` / `deserialize` do -// the JSON marshalling. - -import type { OidbBase } from '@snowluma/proto-defs/oidb'; +// This service uses its own JSON transport rather than the standard OIDB envelope. import type { Oidb0xe17Req, Oidb0xe17Resp } from '@snowluma/proto-defs/oidb-actions/base'; import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; import type { BridgeContext } from '../../bridge-context'; -import { invokeOidb, type OidbSender } from '../../oidb-service'; +import { OidbError, type OidbSender } from '../../oidb-service'; +import { requirePacketResponse } from '../../msg-push/packet-response'; export interface UnidirectionalFriendEntry { [key: string]: import('@snowluma/common/json').JsonValue; } +function isObject(value: unknown): value is UnidirectionalFriendEntry { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + export namespace GetUnidirectionalFriendList { export const command = 0xe17; export const subCommand = 0; - /** Wire name override — this cmd lives outside the standard - * `OidbSvcTrpcTcp.0xNNNN_N` namespace. */ export const wireName = (): string => 'MQUpdateSvc_com_qq_ti.web.OidbSvc.0xe17_0'; export interface Params {} @@ -36,26 +30,45 @@ export namespace GetUnidirectionalFriendList { uint32_req_num: 99, bytes_cookies: '', }; - return { jsonBody: JSON.stringify(reqObj) }; + return { subCommand, jsonBody: JSON.stringify(reqObj) }; }; export const deserialize = (_ctx: Deps, body: Oidb0xe17Resp): UnidirectionalFriendEntry[] => { + if (body.errorCode) { + throw new OidbError(body.errorCode, body.errorMessage ?? '', command, subCommand); + } + if (!body || !body.jsonBody) { throw new Error('get unidirectional friend list empty'); } - const parsed = JSON.parse(body.jsonBody) as { - rpt_block_list?: UnidirectionalFriendEntry[]; - }; - return parsed.rpt_block_list || []; + const parsed: unknown = JSON.parse(body.jsonBody); + + if (!isObject(parsed)) { + throw new Error('get unidirectional friend list invalid response'); + } + + if (parsed.ActionStatus !== 'OK' || parsed.ErrorCode !== 0) { + const code = typeof parsed.ErrorCode === 'number' ? parsed.ErrorCode : 'missing'; + const message = typeof parsed.ErrorInfo === 'string' ? parsed.ErrorInfo : ''; + throw new Error(`get unidirectional friend list failed: ${code} ${message}`.trim()); + } + + if (!Array.isArray(parsed.rpt_block_list) || !parsed.rpt_block_list.every(isObject)) { + throw new Error('get unidirectional friend list invalid block list'); + } + + return parsed.rpt_block_list; }; - export const encode = (env: OidbBase): Uint8Array => - protobuf_encode>(env); + export const encode = (request: Oidb0xe17Req): Uint8Array => + protobuf_encode(request); - export const decode = (bytes: Uint8Array): OidbBase => - protobuf_decode>(bytes); + export const decode = (bytes: Uint8Array): Oidb0xe17Resp => + protobuf_decode(bytes); - export const invoke = (deps: Deps): Promise => - invokeOidb(deps, GetUnidirectionalFriendList, {}); + export const invoke = async (deps: Deps): Promise => { + const result = await deps.sendRawPacket(wireName(), encode(serialize(deps, {}))); + return deserialize(deps, decode(requirePacketResponse(result, wireName()))); + }; } diff --git a/packages/protocol/tests/oidb-services/profile/get-unidirectional-friend-list.test.ts b/packages/protocol/tests/oidb-services/profile/get-unidirectional-friend-list.test.ts index ce7c33a0..51c2c1a7 100644 --- a/packages/protocol/tests/oidb-services/profile/get-unidirectional-friend-list.test.ts +++ b/packages/protocol/tests/oidb-services/profile/get-unidirectional-friend-list.test.ts @@ -1,93 +1,101 @@ import { describe, expect, it, vi } from 'vitest'; -import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; -import type { OidbBase } from '@snowluma/proto-defs/oidb'; -import type { Oidb0xe17Req, Oidb0xe17Resp } from '@snowluma/proto-defs/oidb-actions/base'; import type { SendPacketResult } from '@snowluma/common/packet-sender'; - +import type { IdentityService } from '../../../src/identity-service'; import { GetUnidirectionalFriendList } from '../../../src/oidb-services/profile/get-unidirectional-friend-list'; +import { s, v } from '../_pb-oracle'; -function makeDeps(responseJson?: unknown) { - const responseData = - responseJson !== undefined - ? Buffer.from( - protobuf_encode>({ - body: { jsonBody: JSON.stringify(responseJson) }, - }), - ) - : Buffer.alloc(0); - const r: SendPacketResult = { +function makeDeps(json: unknown = { ActionStatus: 'OK', ErrorCode: 0, rpt_block_list: [] }) { + const result: SendPacketResult = { success: true, gotResponse: true, errorCode: 0, errorMessage: '', - responseData, + responseData: Buffer.from([ + ...v(1, 0xe17), + ...v(2, 0), + ...v(3, 0), + ...s(4, JSON.stringify(json)), + ]), }; return { - sendRawPacket: vi.fn(async () => r), - identity: { uin: '10001' } as any, + sendRawPacket: vi.fn(async (_cmd: string, _body: Uint8Array) => result), + identity: { uin: '10001' } as IdentityService, }; } -describe('GetUnidirectionalFriendList namespace', () => { - it('declares 0xE17_0', () => { - expect(GetUnidirectionalFriendList.command).toBe(0xe17); - expect(GetUnidirectionalFriendList.subCommand).toBe(0); - }); - - it('overrides the wire name to MQUpdateSvc.OidbSvc.0xe17_0', async () => { - const deps = makeDeps({ rpt_block_list: [] }); - await GetUnidirectionalFriendList.invoke(deps); - expect(deps.sendRawPacket.mock.calls[0]![0]).toBe( - 'MQUpdateSvc_com_qq_ti.web.OidbSvc.0xe17_0', +describe('GetUnidirectionalFriendList', () => { + it('uses the direct JSON request and response captured for this service', async () => { + const list = [{ uint64_uin: '20002', extra: { keep: true } }]; + const deps = makeDeps({ ActionStatus: 'OK', ErrorCode: 0, rpt_block_list: list }); + await expect(GetUnidirectionalFriendList.invoke(deps)).resolves.toEqual(list); + const [command, bytes] = deps.sendRawPacket.mock.calls[0]!; + expect(command).toBe('MQUpdateSvc_com_qq_ti.web.OidbSvc.0xe17_0'); + expect(Buffer.from(bytes)).toEqual( + Buffer.from([ + ...v(2, 0), + ...s( + 3, + JSON.stringify({ + uint64_uin: '10001', + uint64_top: 0, + uint32_req_num: 99, + bytes_cookies: '', + }), + ), + ]), ); }); - describe('serialize', () => { - it('JSON-encodes the request body with the bot uin and fixed pagination', async () => { - const deps = makeDeps({ rpt_block_list: [] }); - await GetUnidirectionalFriendList.invoke(deps); - const [, bytes] = deps.sendRawPacket.mock.calls[0]!; - const env = protobuf_decode>(bytes); - const parsed = JSON.parse(env.body?.jsonBody ?? '{}'); - expect(parsed).toEqual({ - uint64_uin: '10001', - uint64_top: 0, - uint32_req_num: 99, - bytes_cookies: '', - }); - }); + it('returns a verified empty list', async () => { + await expect(GetUnidirectionalFriendList.invoke(makeDeps())).resolves.toEqual([]); }); - describe('deserialize', () => { - it('parses the embedded JSON body and returns rpt_block_list', () => { - const out = GetUnidirectionalFriendList.deserialize({} as any, { - jsonBody: JSON.stringify({ rpt_block_list: [{ uin: 10001 }, { uin: 10002 }] }), - }); - expect(out).toEqual([{ uin: 10001 }, { uin: 10002 }]); - }); - - it('returns [] when rpt_block_list is absent', () => { - const out = GetUnidirectionalFriendList.deserialize({} as any, { - jsonBody: JSON.stringify({}), - }); - expect(out).toEqual([]); - }); - - it('throws when jsonBody is empty / missing', () => { - expect(() => GetUnidirectionalFriendList.deserialize({} as any, {})).toThrow( - 'get unidirectional friend list empty', - ); - expect(() => - GetUnidirectionalFriendList.deserialize({} as any, { jsonBody: '' }), - ).toThrow('get unidirectional friend list empty'); + it('surfaces JSON business failures instead of returning an empty list', async () => { + const deps = makeDeps({ + ActionStatus: 'FAIL', + ErrorCode: 302, + ErrorInfo: 'parse json failed', }); + await expect(GetUnidirectionalFriendList.invoke(deps)).rejects.toThrow( + '302 parse json failed', + ); }); - describe('invoke (e2e)', () => { - it('returns the parsed block list', async () => { - const deps = makeDeps({ rpt_block_list: [{ uin: 999 }] }); - const out = await GetUnidirectionalFriendList.invoke(deps); - expect(out).toEqual([{ uin: 999 }]); + it.each([ + null, + {}, + { ActionStatus: 'OK', ErrorCode: 0 }, + { ActionStatus: 'OK', ErrorCode: 0, rpt_block_list: [null] }, + { ActionStatus: 'OK', ErrorCode: 0, rpt_block_list: {} }, + ])('rejects malformed response data (%j)', async (json) => { + await expect(GetUnidirectionalFriendList.invoke(makeDeps(json))).rejects.toThrow(); + }); + + it('surfaces outer errors, invalid JSON, empty replies and transport failures', async () => { + const deps = makeDeps(); + + for (const [responseData, message] of [ + [Buffer.from([...v(3, 5), ...s(5, 'denied')]), 'denied'], + [Buffer.from(s(4, '{')), 'JSON'], + [Buffer.from(v(1, 0xe17)), 'empty'], + [Buffer.alloc(0), 'empty'], + ] as const) { + deps.sendRawPacket.mockResolvedValue({ + success: true, + gotResponse: true, + errorCode: 0, + errorMessage: '', + responseData, + }); + await expect(GetUnidirectionalFriendList.invoke(deps)).rejects.toThrow(message); + } + + deps.sendRawPacket.mockResolvedValue({ + success: false, + gotResponse: false, + errorCode: 5, + errorMessage: 'disconnected', }); + await expect(GetUnidirectionalFriendList.invoke(deps)).rejects.toThrow('disconnected'); }); });