SnowLuma v2.4.2 source snapshot
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
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
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,89 @@
|
||||
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 { NTV2RichMediaReq, NTV2RichMediaResp } from '@snowluma/proto-defs/oidb-actions/media';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchDownloadRkeys } from '../../../src/oidb-services/contacts/fetch-download-rkeys';
|
||||
|
||||
function makeSender(body?: NTV2RichMediaResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<NTV2RichMediaResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchDownloadRkeys namespace', () => {
|
||||
it('declares 0x9067_202 with uinForm=true', () => {
|
||||
expect(FetchDownloadRkeys.command).toBe(0x9067);
|
||||
expect(FetchDownloadRkeys.subCommand).toBe(202);
|
||||
expect(FetchDownloadRkeys.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('requests the (private=10, group=20, fallback=2) image scopes', () => {
|
||||
const out = FetchDownloadRkeys.serialize({} as any, {});
|
||||
expect(out.downloadRkey?.types).toEqual([10, 20, 2]);
|
||||
});
|
||||
|
||||
it('packages the fixed reqHead { common, scene, client }', () => {
|
||||
const out = FetchDownloadRkeys.serialize({} as any, {});
|
||||
expect(out.reqHead).toEqual({
|
||||
common: { requestId: 1, command: 202 },
|
||||
scene: { requestType: 2, businessType: 1, sceneType: 0 },
|
||||
client: { agentType: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('throws when respHead.retCode is non-zero', () => {
|
||||
expect(() =>
|
||||
FetchDownloadRkeys.deserialize(
|
||||
{} as any,
|
||||
{
|
||||
respHead: { retCode: 42, message: 'bad' },
|
||||
} as any,
|
||||
),
|
||||
).toThrow('bad');
|
||||
});
|
||||
|
||||
it('passes through on retCode = 0', () => {
|
||||
const body = { respHead: { retCode: 0 }, downloadRkey: { rkeys: [] } };
|
||||
expect(FetchDownloadRkeys.deserialize({} as any, body as any)).toBe(body);
|
||||
});
|
||||
|
||||
it('falls back to a generic message when none is provided', () => {
|
||||
expect(() =>
|
||||
FetchDownloadRkeys.deserialize(
|
||||
{} as any,
|
||||
{
|
||||
respHead: { retCode: 1 },
|
||||
} as any,
|
||||
),
|
||||
).toThrow('fetch download rkey failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0x9067_202 with reserved=1', async () => {
|
||||
const sender = makeSender({
|
||||
respHead: { retCode: 0 },
|
||||
downloadRkey: { rkeys: [] },
|
||||
} as any);
|
||||
await FetchDownloadRkeys.invoke(sender);
|
||||
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0x9067_202');
|
||||
const env = protobuf_decode<OidbBase<NTV2RichMediaReq>>(bytes);
|
||||
expect(env.reserved).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
protobuf_decode,
|
||||
protobuf_encode,
|
||||
type bool,
|
||||
type bytes,
|
||||
type pb,
|
||||
type pb_repeated,
|
||||
type uint_32,
|
||||
} from '@snowluma/proton';
|
||||
import type { OidbBase, OidbSvcTrpcTcp0xFD4_1Response } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbFriendListRequest } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchFriendListPage } from '../../../src/oidb-services/contacts/fetch-friend-list-page';
|
||||
|
||||
interface TestFriendCategory {
|
||||
categoryId?: pb<1, uint_32>;
|
||||
categoryName?: pb<2, string>;
|
||||
memberCount?: pb<3, uint_32>;
|
||||
sortId?: pb<4, uint_32>;
|
||||
}
|
||||
|
||||
interface TestFriendListResponse {
|
||||
cookie?: pb<2, bytes>;
|
||||
isEnd?: pb<3, bool>;
|
||||
categories?: pb_repeated<102, TestFriendCategory>;
|
||||
}
|
||||
|
||||
function makeSender(body?: OidbSvcTrpcTcp0xFD4_1Response) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFD4_1Response>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchFriendListPage namespace', () => {
|
||||
it('declares 0xFD4_1', () => {
|
||||
expect(FetchFriendListPage.command).toBe(0xfd4);
|
||||
expect(FetchFriendListPage.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('omits the cookie on the first page', () => {
|
||||
const out = FetchFriendListPage.serialize({} as any, {} as any) as any;
|
||||
expect(out.cookie).toBeUndefined();
|
||||
expect(out.friendCount).toBe(300);
|
||||
expect(out.field4).toBe(0);
|
||||
});
|
||||
|
||||
it('replays the opaque response cookie in request tag 5', () => {
|
||||
const cookie = Uint8Array.from([0x08, 0x96, 0x01, 0xff]);
|
||||
const out = FetchFriendListPage.serialize({} as any, { cookie } as any) as any;
|
||||
expect(out.cookie).toEqual(cookie);
|
||||
expect(out.nextUin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requests basic identity and public profiles in the same roster page', () => {
|
||||
const out = FetchFriendListPage.serialize({} as any, {}) as any;
|
||||
expect(out.body).toEqual([
|
||||
{
|
||||
type: 1,
|
||||
number: {
|
||||
numbers: [
|
||||
103, 102, 20002, 27394, 105, 107, 20003, 20004, 20020, 20009, 20011,
|
||||
20016, 20021, 20031, 20037, 41756, 41757, 42241,
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: 4, number: { numbers: [100, 101, 102] } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0xfd4_1', async () => {
|
||||
const sender = makeSender({ friends: [] });
|
||||
await FetchFriendListPage.invoke(sender, {});
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xfd4_1');
|
||||
});
|
||||
|
||||
it('maps a page of friends and categories into domain types', async () => {
|
||||
const body = {
|
||||
cookie: Uint8Array.from([0xaa]),
|
||||
friends: [
|
||||
{
|
||||
uin: 10001,
|
||||
uid: 'u1',
|
||||
customGroup: 7,
|
||||
additional: [
|
||||
{
|
||||
type: 1,
|
||||
layer1: {
|
||||
properties: [
|
||||
{ code: 20002, value: Buffer.from('Alice') },
|
||||
{ code: 103, value: Buffer.from('A') },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
categories: [
|
||||
{
|
||||
categoryId: 7,
|
||||
categoryName: 'Work',
|
||||
memberCount: 1,
|
||||
sortId: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
const sender = makeSender(body as any);
|
||||
await expect(FetchFriendListPage.invoke(sender, {} as any)).resolves.toEqual({
|
||||
entries: [
|
||||
{
|
||||
categoryId: 7,
|
||||
friend: {
|
||||
uin: 10001,
|
||||
uid: 'u1',
|
||||
nickname: 'Alice',
|
||||
remark: 'A',
|
||||
categoryId: 7,
|
||||
},
|
||||
},
|
||||
],
|
||||
categories: [
|
||||
{
|
||||
categoryId: 7,
|
||||
categoryName: 'Work',
|
||||
memberCount: 1,
|
||||
sortId: 1,
|
||||
},
|
||||
],
|
||||
cookie: Uint8Array.from([0xaa]),
|
||||
});
|
||||
});
|
||||
|
||||
it('fills missing friend fields with 0 / empty string', () => {
|
||||
expect(
|
||||
FetchFriendListPage.deserialize({} as any, {
|
||||
friends: [{ additional: [] }],
|
||||
}),
|
||||
).toEqual({
|
||||
entries: [
|
||||
{
|
||||
categoryId: 0,
|
||||
friend: { uin: 0, uid: '', nickname: '0', remark: '', categoryId: 0 },
|
||||
},
|
||||
],
|
||||
categories: [],
|
||||
cookie: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('encodes a follow-up cookie in the envelope body', async () => {
|
||||
const sender = makeSender({ friends: [] });
|
||||
const cookie = Uint8Array.from([0xaa, 0xbb]);
|
||||
await FetchFriendListPage.invoke(sender, { cookie } as any);
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbFriendListRequest>>(bytes);
|
||||
expect((env.body as any)?.cookie).toEqual(cookie);
|
||||
});
|
||||
|
||||
it('decodes category metadata and the opaque response cookie', () => {
|
||||
const bytes = protobuf_encode<OidbBase<TestFriendListResponse>>({
|
||||
body: {
|
||||
cookie: Uint8Array.from([0xaa, 0xbb]),
|
||||
isEnd: false,
|
||||
categories: [
|
||||
{
|
||||
categoryId: 7,
|
||||
categoryName: 'Work',
|
||||
memberCount: 2,
|
||||
sortId: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const body = FetchFriendListPage.decode(bytes).body as any;
|
||||
expect(body.cookie).toEqual(Uint8Array.from([0xaa, 0xbb]));
|
||||
expect(body.categories).toEqual([
|
||||
{
|
||||
categoryId: 7,
|
||||
categoryName: 'Work',
|
||||
memberCount: 2,
|
||||
sortId: 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
// 0x88D_0 — fetch a single group's detail by uin (resolves non-member groups,
|
||||
// e.g. to name a group invite). Verifies the request mask + envelope (reserved=0,
|
||||
// matching Lagrange's GetGroupInfoService) and the response decode.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
protobuf_decode,
|
||||
protobuf_encode,
|
||||
type pb,
|
||||
type uint_32,
|
||||
type uint_64,
|
||||
} from '@snowluma/proton';
|
||||
import type { OidbBase, OidbSvcTrpcTcp0x88D_0Response } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbGroupDetailRequest } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchGroupDetail } from '../../../src/oidb-services/contacts/fetch-group-detail';
|
||||
|
||||
interface GroupDetailResultsFixture {
|
||||
name?: pb<15, string>;
|
||||
shutUpAllTimestamp?: pb<45, uint_32>;
|
||||
leftover59?: pb<59, uint_32>;
|
||||
privilegeFlag?: pb<56, uint_32>;
|
||||
groupFlagExt4?: pb<101, uint_32>;
|
||||
}
|
||||
|
||||
interface GroupDetailInfoFixture {
|
||||
uin?: pb<1, uint_64>;
|
||||
results?: pb<3, GroupDetailResultsFixture>;
|
||||
}
|
||||
|
||||
interface GroupDetailResponseFixture {
|
||||
groupInfo?: pb<1, GroupDetailInfoFixture>;
|
||||
}
|
||||
|
||||
function makeSender(body?: OidbSvcTrpcTcp0x88D_0Response) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchGroupDetail namespace', () => {
|
||||
it('declares 0x88D_0 with no uinForm (reserved stays 0, per Lagrange)', () => {
|
||||
expect(FetchGroupDetail.command).toBe(0x88d);
|
||||
expect(FetchGroupDetail.subCommand).toBe(0);
|
||||
expect((FetchGroupDetail as { uinForm?: boolean }).uinForm).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('builds the field1 + uin + request-mask flags', () => {
|
||||
const out = FetchGroupDetail.serialize({} as any, { groupUin: 601692726 });
|
||||
expect(out.field1).toBe(537099973);
|
||||
expect(out.config?.uin).toBe(BigInt(601692726));
|
||||
expect(out.config?.flags?.name).toBe(''); // string mask → request the name
|
||||
expect(out.config?.flags?.memberCount).toBe(true);
|
||||
expect(out.config?.flags?.maxMemberCount).toBe(true);
|
||||
expect(
|
||||
(out.config?.flags as { shutUpAllTimestamp?: boolean })?.shutUpAllTimestamp,
|
||||
).toBe(true);
|
||||
expect(out.config?.flags?.privilegeFlag).toBe(true);
|
||||
expect(out.config?.flags?.groupFlagExt4).toBe(true);
|
||||
expect(out.config?.flags?.addType).toBe(true);
|
||||
expect(out.config?.flags?.noFingerOpen).toBe(true);
|
||||
expect(out.config?.flags?.noCodeFingerOpen).toBe(true);
|
||||
expect(out.config?.flags?.question).toBe('');
|
||||
expect(out.config?.flags?.answer).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('passes the response body through', () => {
|
||||
const body = { groupInfo: { uin: 1n, results: { name: 'X' } } } as any;
|
||||
expect(FetchGroupDetail.deserialize({} as any, body)).toBe(body);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0x88d_0 and round-trips the uin in the request', async () => {
|
||||
const sender = makeSender({ groupInfo: { uin: 601692726n, results: { name: 'g' } } });
|
||||
await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x88d_0');
|
||||
const env = protobuf_decode<OidbBase<OidbGroupDetailRequest>>(bytes);
|
||||
expect(env.command).toBe(0x88d);
|
||||
expect(env.subCommand ?? 0).toBe(0); // proto3 omits the 0 default on the wire
|
||||
expect(env.reserved ?? 0).toBe(0); // NOT uin-form
|
||||
expect(env.body?.config?.uin).toBe(BigInt(601692726));
|
||||
expect(env.body?.config?.flags?.question).toBe('');
|
||||
expect(env.body?.config?.flags?.answer).toBe('');
|
||||
});
|
||||
|
||||
it('keeps empty question/answer masks on the wire (#393)', async () => {
|
||||
const sender = makeSender({ groupInfo: { uin: 1n, results: { name: 'g' } } });
|
||||
await FetchGroupDetail.invoke(sender, { groupUin: 1 });
|
||||
|
||||
const bytes = sender.sendRawPacket.mock.calls[0]![1] as Uint8Array;
|
||||
// length-delimited empty string: field 24/25, wire type 2, length 0.
|
||||
const hex = Buffer.from(bytes).toString('hex');
|
||||
expect(hex).toContain('c20100');
|
||||
expect(hex).toContain('ca0100');
|
||||
});
|
||||
|
||||
it('returns the decoded group detail (name + counts)', async () => {
|
||||
const sender = makeSender({
|
||||
groupInfo: {
|
||||
uin: 601692726n,
|
||||
results: { name: '测试群', memberCount: 42n, maxMemberCount: 500n },
|
||||
},
|
||||
});
|
||||
const out = await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
|
||||
expect(out.groupInfo?.results?.name).toBe('测试群');
|
||||
expect(out.groupInfo?.results?.memberCount).toBe(42n);
|
||||
expect(out.groupInfo?.results?.maxMemberCount).toBe(500n);
|
||||
});
|
||||
|
||||
it('decodes the group-wide mute timestamp from a detail response', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<GroupDetailResponseFixture>>({
|
||||
body: {
|
||||
groupInfo: {
|
||||
uin: 601692726n,
|
||||
results: { name: 'Muted Group', shutUpAllTimestamp: 4_294_967_295 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sender = makeSender();
|
||||
sender.sendRawPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
});
|
||||
|
||||
const out = await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
|
||||
|
||||
expect(out.groupInfo?.results?.shutUpAllTimestamp).toBe(4_294_967_295);
|
||||
});
|
||||
|
||||
it('does not treat 0x88D results tag 59 as the mute expire (#356)', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<GroupDetailResponseFixture>>({
|
||||
body: {
|
||||
groupInfo: {
|
||||
uin: 601692726n,
|
||||
results: { name: 'Unmuted Group', leftover59: 4_294_967_295 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sender = makeSender();
|
||||
sender.sendRawPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
});
|
||||
|
||||
const out = await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
|
||||
expect(out.groupInfo?.results?.shutUpAllTimestamp ?? undefined).toBeUndefined();
|
||||
});
|
||||
|
||||
it('decodes the complete privilege flag used by masked group mutations', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<GroupDetailResponseFixture>>({
|
||||
body: {
|
||||
groupInfo: {
|
||||
uin: 601692726n,
|
||||
results: { privilegeFlag: 0x82100001 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sender = makeSender();
|
||||
sender.sendRawPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
});
|
||||
|
||||
const out = await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
|
||||
expect(out.groupInfo?.results?.privilegeFlag).toBe(0x82100001);
|
||||
});
|
||||
|
||||
it('decodes the complete extended group flag used by history visibility', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<GroupDetailResponseFixture>>({
|
||||
body: {
|
||||
groupInfo: {
|
||||
uin: 601692726n,
|
||||
results: { groupFlagExt4: 0x80000005 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sender = makeSender();
|
||||
sender.sendRawPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
});
|
||||
|
||||
const out = await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
|
||||
expect(out.groupInfo?.results?.groupFlagExt4).toBe(0x80000005);
|
||||
});
|
||||
|
||||
it('decodes add-option and search flags from the detail response', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({
|
||||
body: {
|
||||
groupInfo: {
|
||||
uin: 601692726n,
|
||||
results: { addType: 2, noFingerOpen: 1, noCodeFingerOpen: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sender = makeSender();
|
||||
sender.sendRawPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
});
|
||||
|
||||
const out = await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
|
||||
expect(out.groupInfo?.results?.addType).toBe(2);
|
||||
expect(out.groupInfo?.results?.noFingerOpen).toBe(1);
|
||||
expect(out.groupInfo?.results?.noCodeFingerOpen ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
protobuf_decode,
|
||||
protobuf_encode,
|
||||
type pb,
|
||||
type pb_repeated,
|
||||
type uint_32,
|
||||
} from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbGroupListRequest } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchGroupList } from '../../../src/oidb-services/contacts/fetch-group-list';
|
||||
|
||||
interface GroupInfoFixture {
|
||||
groupName?: pb<5, string>;
|
||||
shutUpAllTimestamp?: pb<10, uint_32>;
|
||||
leftover31?: pb<31, uint_32>;
|
||||
}
|
||||
|
||||
interface GroupFixture {
|
||||
groupUin?: pb<3, uint_32>;
|
||||
info?: pb<4, GroupInfoFixture>;
|
||||
}
|
||||
|
||||
interface GroupListFixture {
|
||||
groups?: pb_repeated<2, GroupFixture>;
|
||||
}
|
||||
|
||||
function makeSender() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchGroupList namespace', () => {
|
||||
it('declares 0xFE5_2 with uinForm=true', () => {
|
||||
expect(FetchGroupList.command).toBe(0xfe5);
|
||||
expect(FetchGroupList.subCommand).toBe(2);
|
||||
expect(FetchGroupList.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0xfe5_2 with envelope reserved=1', async () => {
|
||||
const sender = makeSender();
|
||||
await FetchGroupList.invoke(sender);
|
||||
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0xfe5_2');
|
||||
const env = protobuf_decode<OidbBase<OidbGroupListRequest>>(bytes);
|
||||
expect(env.reserved).toBe(1);
|
||||
});
|
||||
|
||||
it('requests group remarks without enabling the other costly optional fields', async () => {
|
||||
const sender = makeSender();
|
||||
await FetchGroupList.invoke(sender);
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupListRequest>>(bytes);
|
||||
expect(env.body?.config?.config1?.groupName).toBe(true);
|
||||
expect(env.body?.config?.config1?.memberCount).toBe(true);
|
||||
expect(env.body?.config?.config1?.field10).toBe(true);
|
||||
expect(env.body?.config?.config2?.remark).toBe(true);
|
||||
|
||||
for (const field of [
|
||||
'field1',
|
||||
'field2',
|
||||
'field4',
|
||||
'field5',
|
||||
'field6',
|
||||
'field7',
|
||||
'field8',
|
||||
] as const) {
|
||||
expect(env.body?.config?.config2?.[field] ?? false, field).toBe(false);
|
||||
}
|
||||
|
||||
// proto3 default false — omitted on wire, decoded as null.
|
||||
expect(env.body?.config?.config1?.field5002 ?? false).toBe(false);
|
||||
expect(env.body?.config?.config1?.field5003 ?? false).toBe(false);
|
||||
});
|
||||
|
||||
it('decodes the group-wide mute timestamp returned with the joined roster', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<GroupListFixture>>({
|
||||
body: {
|
||||
groups: [
|
||||
{
|
||||
groupUin: 123456789,
|
||||
info: {
|
||||
groupName: 'Muted Group',
|
||||
shutUpAllTimestamp: 4_294_967_295,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sender = makeSender();
|
||||
sender.sendRawPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
});
|
||||
|
||||
const out = await FetchGroupList.invoke(sender);
|
||||
|
||||
expect(out.groups?.[0]?.info?.shutUpAllTimestamp).toBe(4_294_967_295);
|
||||
});
|
||||
|
||||
it('does not treat 0xFE5 info tag 31 as the mute expire (#356)', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<GroupListFixture>>({
|
||||
body: {
|
||||
groups: [
|
||||
{
|
||||
groupUin: 123456789,
|
||||
info: { groupName: 'Unmuted Group', leftover31: 4_294_967_295 },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sender = makeSender();
|
||||
sender.sendRawPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
});
|
||||
|
||||
const out = await FetchGroupList.invoke(sender);
|
||||
|
||||
expect(out.groups?.[0]?.info?.shutUpAllTimestamp ?? undefined).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase, OidbSvcTrpcTcp0xFE7_3Response } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbGroupMemberListRequest } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchGroupMemberListPage } from '../../../src/oidb-services/contacts/fetch-group-member-list-page';
|
||||
|
||||
function makeSender(body?: OidbSvcTrpcTcp0xFE7_3Response) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFE7_3Response>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchGroupMemberListPage namespace', () => {
|
||||
it('declares 0xFE7_3', () => {
|
||||
expect(FetchGroupMemberListPage.command).toBe(0xfe7);
|
||||
expect(FetchGroupMemberListPage.subCommand).toBe(3);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('omits the token on the first page', () => {
|
||||
const out = FetchGroupMemberListPage.serialize({} as any, {
|
||||
groupId: 12345,
|
||||
token: '',
|
||||
}) as any;
|
||||
expect(out.token).toBeUndefined();
|
||||
expect(out.groupUin).toBe(12345);
|
||||
});
|
||||
|
||||
it('threads the server-issued token into follow-up pages', () => {
|
||||
const out = FetchGroupMemberListPage.serialize({} as any, {
|
||||
groupId: 12345,
|
||||
token: 'next-cursor',
|
||||
}) as any;
|
||||
expect(out.token).toBe('next-cursor');
|
||||
});
|
||||
|
||||
it('always requests the full member-field projection', () => {
|
||||
const out = FetchGroupMemberListPage.serialize({} as any, {
|
||||
groupId: 1,
|
||||
token: '',
|
||||
}) as any;
|
||||
expect(out.body).toMatchObject({
|
||||
memberName: true,
|
||||
memberCard: true,
|
||||
level: true,
|
||||
joinTimestamp: true,
|
||||
lastMsgTimestamp: true,
|
||||
shutUpTimestamp: true,
|
||||
permission: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0xfe7_3', async () => {
|
||||
const sender = makeSender();
|
||||
await FetchGroupMemberListPage.invoke(sender, { groupId: 1, token: '' });
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xfe7_3');
|
||||
});
|
||||
|
||||
it('encodes envelope body with the groupId', async () => {
|
||||
const sender = makeSender();
|
||||
await FetchGroupMemberListPage.invoke(sender, { groupId: 12345, token: '' });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupMemberListRequest>>(bytes);
|
||||
expect(env.body?.groupUin).toBe(12345);
|
||||
});
|
||||
|
||||
it('maps members into GroupMemberInfo and leaves isRobot unset-false', async () => {
|
||||
const sender = makeSender({
|
||||
token: 'next',
|
||||
members: [
|
||||
{
|
||||
uin: { uid: 'u_admin', uin: 22222 },
|
||||
memberName: 'Ada',
|
||||
specialTitle: 't',
|
||||
memberCard: { memberCard: Buffer.from('card') },
|
||||
level: { level: 12 },
|
||||
joinTimestamp: 1,
|
||||
lastMsgTimestamp: 2,
|
||||
shutUpTimestamp: 3,
|
||||
permission: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
await expect(
|
||||
FetchGroupMemberListPage.invoke(sender, { groupId: 42, token: '' }),
|
||||
).resolves.toEqual({
|
||||
token: 'next',
|
||||
members: [
|
||||
{
|
||||
uin: 22222,
|
||||
uid: 'u_admin',
|
||||
nickname: 'Ada',
|
||||
card: 'card',
|
||||
isRobot: false,
|
||||
role: 'admin',
|
||||
level: 12,
|
||||
title: 't',
|
||||
joinTime: 1,
|
||||
lastSentTime: 2,
|
||||
shutUpTime: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps permission 1 to owner and missing fields to 0 / empty', () => {
|
||||
expect(
|
||||
FetchGroupMemberListPage.deserialize({} as any, {
|
||||
members: [{ permission: 1 }, {}],
|
||||
}),
|
||||
).toEqual({
|
||||
token: '',
|
||||
members: [
|
||||
{
|
||||
uin: 0,
|
||||
uid: '',
|
||||
nickname: '',
|
||||
card: '',
|
||||
isRobot: false,
|
||||
role: 'owner',
|
||||
level: 0,
|
||||
title: '',
|
||||
joinTime: 0,
|
||||
lastSentTime: 0,
|
||||
shutUpTime: 0,
|
||||
},
|
||||
{
|
||||
uin: 0,
|
||||
uid: '',
|
||||
nickname: '',
|
||||
card: '',
|
||||
isRobot: false,
|
||||
role: 'member',
|
||||
level: 0,
|
||||
title: '',
|
||||
joinTime: 0,
|
||||
lastSentTime: 0,
|
||||
shutUpTime: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { pb, pb_repeated, uint_32, uint_64 } from '@snowluma/proton';
|
||||
import type { OidbBase, OidbSvcTrpcTcp0x10C0Response } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbGroupRequestList } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import {
|
||||
FetchGroupRequests,
|
||||
FetchGroupRequestsByUid,
|
||||
groupRequestNotifyType,
|
||||
} from '../../../src/oidb-services/contacts/fetch-group-requests';
|
||||
|
||||
interface ObservedGroupRequestUserByUin {
|
||||
uin?: pb<1, uint_32>;
|
||||
name?: pb<2, string>;
|
||||
}
|
||||
|
||||
interface ObservedGroupRequestGroup {
|
||||
groupUin?: pb<1, uint_32>;
|
||||
groupName?: pb<2, string>;
|
||||
}
|
||||
|
||||
interface ObservedGroupRequestByUin {
|
||||
sequence?: pb<1, uint_64>;
|
||||
eventType?: pb<2, uint_32>;
|
||||
state?: pb<3, uint_32>;
|
||||
group?: pb<4, ObservedGroupRequestGroup>;
|
||||
target?: pb<5, ObservedGroupRequestUserByUin>;
|
||||
invitor?: pb<6, ObservedGroupRequestUserByUin>;
|
||||
operatorUser?: pb<7, ObservedGroupRequestUserByUin>;
|
||||
comment?: pb<10, string>;
|
||||
}
|
||||
|
||||
interface ObservedGroupRequestResponseByUin {
|
||||
requests?: pb_repeated<1, ObservedGroupRequestByUin>;
|
||||
cursor?: pb<2, uint_64>;
|
||||
}
|
||||
|
||||
function makeSender() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchGroupRequests namespace', () => {
|
||||
it('interprets list discriminators in the wire-to-notification direction', () => {
|
||||
expect(groupRequestNotifyType(1)).toBe(7);
|
||||
expect(groupRequestNotifyType(2)).toBe(1);
|
||||
expect(groupRequestNotifyType(7)).toBe(10);
|
||||
expect(groupRequestNotifyType(10)).toBe(2);
|
||||
expect(groupRequestNotifyType(22)).toBe(5);
|
||||
expect(groupRequestNotifyType(100)).toBe(17);
|
||||
expect(groupRequestNotifyType(5)).toBeUndefined();
|
||||
expect(groupRequestNotifyType(99)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('declares 0x10C0', () => {
|
||||
expect(FetchGroupRequests.command).toBe(0x10c0);
|
||||
expect(FetchGroupRequests.uinForm).toBe(true);
|
||||
expect((FetchGroupRequestsByUid as { uinForm?: boolean }).uinForm).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('resolveSubCommand', () => {
|
||||
it('returns 1 when filtered=false (main inbox)', () => {
|
||||
expect(FetchGroupRequests.resolveSubCommand({ filtered: false })).toBe(1);
|
||||
});
|
||||
it('returns 2 when filtered=true (low-priority inbox)', () => {
|
||||
expect(FetchGroupRequests.resolveSubCommand({ filtered: true })).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('defaults to one native 50-item screen at cursor zero', () => {
|
||||
expect(FetchGroupRequests.serialize({} as any, { filtered: false })).toEqual({
|
||||
count: 50,
|
||||
field2: 0n,
|
||||
});
|
||||
expect(FetchGroupRequestsByUid.serialize({} as any, { filtered: true })).toEqual({
|
||||
count: 50,
|
||||
field2: 0n,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a caller-provided count and 64-bit cursor', () => {
|
||||
const cursor = 1_785_406_628_779_279n;
|
||||
expect(
|
||||
FetchGroupRequests.serialize({} as any, {
|
||||
filtered: false,
|
||||
count: 80,
|
||||
cursor,
|
||||
}),
|
||||
).toEqual({ count: 80, field2: cursor });
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to 0x10c0_1 for the main inbox', async () => {
|
||||
const sender = makeSender();
|
||||
await FetchGroupRequests.invoke(sender, { filtered: false });
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x10c0_1');
|
||||
});
|
||||
|
||||
it('routes to 0x10c0_2 for the filtered inbox', async () => {
|
||||
const sender = makeSender();
|
||||
await FetchGroupRequests.invoke(sender, { filtered: true });
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x10c0_2');
|
||||
});
|
||||
|
||||
it('encodes the native envelope and requested screen', async () => {
|
||||
const sender = makeSender();
|
||||
await FetchGroupRequests.invoke(sender, {
|
||||
filtered: false,
|
||||
count: 80,
|
||||
cursor: 1_785_406_628_779_279n,
|
||||
});
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupRequestList>>(bytes);
|
||||
expect(env.reserved).toBe(1);
|
||||
expect(env.body).toEqual({ count: 80, field2: 1_785_406_628_779_279n });
|
||||
});
|
||||
|
||||
it('decodes numeric account identifiers from the native response', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<ObservedGroupRequestResponseByUin>>({
|
||||
body: {
|
||||
requests: [
|
||||
{
|
||||
sequence: 1_785_525_232_784_291n,
|
||||
eventType: 1,
|
||||
state: 1,
|
||||
group: { groupUin: 1_095_186_374, groupName: 'group' },
|
||||
target: { uin: 1_234_567_890, name: 'requester' },
|
||||
invitor: { uin: 2_345_678_901, name: 'inviter' },
|
||||
operatorUser: { uin: 3_456_789_012, name: 'operator' },
|
||||
comment: 'please',
|
||||
},
|
||||
],
|
||||
cursor: 1_785_406_628_779_279n,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
const sender = { sendRawPacket: vi.fn(async () => result) };
|
||||
|
||||
const response = await FetchGroupRequests.invoke(sender, { filtered: false });
|
||||
|
||||
expect(response.requests?.[0]).toMatchObject({
|
||||
eventType: 1,
|
||||
target: { uin: 1_234_567_890, uid: '', name: 'requester' },
|
||||
invitor: { uin: 2_345_678_901, uid: '', name: 'inviter' },
|
||||
operatorUser: { uin: 3_456_789_012, uid: '', name: 'operator' },
|
||||
});
|
||||
expect(response.field2).toBe(1_785_406_628_779_279n);
|
||||
});
|
||||
|
||||
it('keeps string account identifiers from the native reply', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x10C0Response>>({
|
||||
body: {
|
||||
requests: [
|
||||
{
|
||||
sequence: 99n,
|
||||
eventType: 22,
|
||||
state: 1,
|
||||
group: { groupUin: 1001, groupName: 'group' },
|
||||
target: { uid: 'u_target', name: 'invitee' },
|
||||
invitor: { uid: 'u_inviter', name: 'inviter' },
|
||||
comment: 'join us',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sender = {
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
})),
|
||||
};
|
||||
|
||||
const response = await FetchGroupRequests.invoke(sender, { filtered: false });
|
||||
|
||||
expect(response.requests?.[0]).toMatchObject({
|
||||
eventType: 22,
|
||||
target: { uid: 'u_target', uin: 0, name: 'invitee' },
|
||||
invitor: { uid: 'u_inviter', uin: 0, name: 'inviter' },
|
||||
comment: 'join us',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the UID response path on the non-native envelope', async () => {
|
||||
const sender = makeSender();
|
||||
await FetchGroupRequestsByUid.invoke(sender, { filtered: false });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupRequestList>>(bytes);
|
||||
expect(env.reserved ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbRobotUinRangeRequest } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchRobotUinRanges } from '../../../src/oidb-services/contacts/fetch-robot-uin-ranges';
|
||||
|
||||
// Captured from QQ's 0x496_0 response and reduced to the versioned range
|
||||
// config. Keeping this as wire bytes makes the test independent of the
|
||||
// encoder used by the implementation.
|
||||
const RESPONSE_FIXTURE = Buffer.from(
|
||||
'0896091000180022492a4708ce01120c08fee8fdb20c10fee8fdb20c' +
|
||||
'120c08cda8fed00a10cfc5ffd00a120a08c0f8e01f10c0f8e01f12' +
|
||||
'0c08c0dcb5be0e10ffe0f2be0e120c0880fd8ef80e10ffa9f1fc0e',
|
||||
'hex',
|
||||
);
|
||||
|
||||
function makeSender(responseData = RESPONSE_FIXTURE) {
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => result) };
|
||||
}
|
||||
|
||||
describe('FetchRobotUinRanges namespace', () => {
|
||||
it('routes the read-only request to 0x496_0 with QQ-compatible fields', async () => {
|
||||
const sender = makeSender();
|
||||
|
||||
await FetchRobotUinRanges.invoke(sender);
|
||||
|
||||
expect(sender.sendRawPacket).toHaveBeenCalledOnce();
|
||||
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0x496_0');
|
||||
const request = protobuf_decode<OidbBase<OidbRobotUinRangeRequest>>(bytes);
|
||||
expect(request.body).toEqual({
|
||||
justFetchMsgConfig: 1,
|
||||
type: 1,
|
||||
version: 0,
|
||||
aioKeywordVersion: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes the server version and every inclusive UIN range', async () => {
|
||||
const sender = makeSender();
|
||||
|
||||
await expect(FetchRobotUinRanges.invoke(sender)).resolves.toEqual({
|
||||
version: 206,
|
||||
ranges: [
|
||||
{ minUin: 3328144510, maxUin: 3328144510 },
|
||||
{ minUin: 2854196301, maxUin: 2854216399 },
|
||||
{ minUin: 66600000, maxUin: 66600000 },
|
||||
{ minUin: 3889000000, maxUin: 3889999999 },
|
||||
{ minUin: 4010000000, maxUin: 4019999999 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a successful envelope that omits the robot range config', async () => {
|
||||
const sender = makeSender(Buffer.from('089609100018002200', 'hex'));
|
||||
|
||||
await expect(FetchRobotUinRanges.invoke(sender)).rejects.toThrow(
|
||||
'0x496_0 response missing robot range config',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a robot config without its version', () => {
|
||||
expect(() =>
|
||||
FetchRobotUinRanges.deserialize(makeSender(), {
|
||||
robotConfig: {
|
||||
ranges: [{ minUin: 3_889_000_000n, maxUin: 3_889_999_999n }],
|
||||
},
|
||||
}),
|
||||
).toThrow('0x496_0 response missing robot range config version');
|
||||
});
|
||||
|
||||
it('rejects an empty range set instead of classifying everyone as human', () => {
|
||||
expect(() =>
|
||||
FetchRobotUinRanges.deserialize(makeSender(), {
|
||||
robotConfig: { version: 206, ranges: [] },
|
||||
}),
|
||||
).toThrow('0x496_0 response has no robot UIN ranges');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
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 {
|
||||
OidbUserInfoByUidRequest,
|
||||
OidbUserInfoResponse,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchUserProfileByUid } from '../../../src/oidb-services/contacts/fetch-user-profile-by-uid';
|
||||
|
||||
function makeSender(body?: OidbUserInfoResponse) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbUserInfoResponse>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchUserProfileByUid namespace (UID form)', () => {
|
||||
it('declares 0xFE1_2 WITHOUT uinForm (reserved stays 0)', () => {
|
||||
expect(FetchUserProfileByUid.command).toBe(0xfe1);
|
||||
expect(FetchUserProfileByUid.subCommand).toBe(2);
|
||||
// No `uinForm` exposed — matches Lagrange's isUid=false path on
|
||||
// the UID variant. Important because the server distinguishes
|
||||
// UIN vs UID requests via the envelope's `reserved` field.
|
||||
expect((FetchUserProfileByUid as any).uinForm).toBeUndefined();
|
||||
});
|
||||
|
||||
it('packages the uid as field 1 (string), routes to OidbSvcTrpcTcp.0xfe1_2', async () => {
|
||||
const sender = makeSender({ body: { uin: 950929451, uid: 'u_abc' } as any });
|
||||
await FetchUserProfileByUid.invoke(sender, { uid: 'u_abc' });
|
||||
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0xfe1_2');
|
||||
const env = protobuf_decode<OidbBase<OidbUserInfoByUidRequest>>(bytes);
|
||||
expect(env.body?.uid).toBe('u_abc');
|
||||
// reserved=0 (default, omitted on wire) because this is the UID
|
||||
// variant — Lagrange's `OidbSvcTrpcTcp0xFE1_2.cs:9` does the same.
|
||||
expect(env.reserved ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('requests the same property key catalogue as the UIN variant', async () => {
|
||||
const sender = makeSender({ body: { uin: 1, uid: 'u' } as any });
|
||||
await FetchUserProfileByUid.invoke(sender, { uid: 'u' });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbUserInfoByUidRequest>>(bytes);
|
||||
const keys = env.body?.keys?.map((k) => k.key);
|
||||
expect(keys).toContain(20002); // nickname — the whole point
|
||||
expect(keys).toContain(105); // QQ level
|
||||
expect(keys).toContain(40410);
|
||||
expect(keys).toContain(42031);
|
||||
});
|
||||
|
||||
it('decodes qidian flags from number-properties (0 when absent)', async () => {
|
||||
const qidian = makeSender({
|
||||
body: {
|
||||
uin: 10002,
|
||||
uid: 'u',
|
||||
properties: {
|
||||
bytesProperties: [],
|
||||
numberProperties: [
|
||||
{ number1: 40410, number2: 1 },
|
||||
{ number1: 42031, number2: 1 },
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const out = await FetchUserProfileByUid.invoke(qidian, { uid: 'u' });
|
||||
expect(out.qidianMasterFlag).toBe(1);
|
||||
expect(out.qidianCrewFlag).toBe(1);
|
||||
expect(out.qidianCrewFlag2).toBe(0);
|
||||
|
||||
const normal = makeSender({
|
||||
body: {
|
||||
uin: 10001,
|
||||
uid: 'u',
|
||||
properties: {
|
||||
bytesProperties: [],
|
||||
numberProperties: [{ number1: 20009, number2: 1 }],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const out2 = await FetchUserProfileByUid.invoke(normal, { uid: 'u' });
|
||||
expect(out2.qidianMasterFlag).toBe(0);
|
||||
expect(out2.qidianCrewFlag).toBe(0);
|
||||
expect(out2.qidianCrewFlag2).toBe(0);
|
||||
});
|
||||
|
||||
it('decodes nickname + uin from the response so the pipeline can fill them into the event', async () => {
|
||||
// This is the actual point of the namespace — the stranger's
|
||||
// nickname + uin lands here so the pipeline can patch them onto
|
||||
// the in-flight group_invite event.
|
||||
const enc = (s: string) => new TextEncoder().encode(s);
|
||||
const sender = makeSender({
|
||||
body: {
|
||||
uin: 950929451,
|
||||
uid: 'u_abc',
|
||||
properties: {
|
||||
bytesProperties: [{ code: 20002, value: enc('小明') }],
|
||||
numberProperties: [],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const out = await FetchUserProfileByUid.invoke(sender, { uid: 'u_abc' });
|
||||
expect(out.uin).toBe(950929451);
|
||||
expect(out.nickname).toBe('小明');
|
||||
});
|
||||
|
||||
it('throws when the response body is missing entirely', async () => {
|
||||
const sender = makeSender({});
|
||||
await expect(FetchUserProfileByUid.invoke(sender, { uid: 'u_abc' })).rejects.toThrow(
|
||||
'user info response body missing',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
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 {
|
||||
AvatarInfo,
|
||||
OidbUserInfoRequest,
|
||||
OidbUserInfoByUidRequest,
|
||||
OidbUserInfoResponse,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchUserProfile } from '../../../src/oidb-services/contacts/fetch-user-profile';
|
||||
import { FetchUserProfileByUid } from '../../../src/oidb-services/contacts/fetch-user-profile-by-uid';
|
||||
|
||||
function makeSender(body?: OidbUserInfoResponse) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbUserInfoResponse>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchUserProfile namespace', () => {
|
||||
it('declares 0xFE1_2 with uinForm=true', () => {
|
||||
expect(FetchUserProfile.command).toBe(0xfe1);
|
||||
expect(FetchUserProfile.subCommand).toBe(2);
|
||||
expect(FetchUserProfile.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it.each(['uin', 'uid'])(
|
||||
'preserves registration and membership through the %s request',
|
||||
async (form) => {
|
||||
const sender = makeSender({
|
||||
body: {
|
||||
uin: 10001,
|
||||
uid: 'u_test',
|
||||
properties: {
|
||||
numberProperties: [
|
||||
{ number1: 20026, number2: 1_200_000_000 },
|
||||
{ number1: 41756, number2: 0x103 },
|
||||
{ number1: 41757, number2: 1 << 5 },
|
||||
{ number1: 42241, number2: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const profile =
|
||||
form === 'uin'
|
||||
? await FetchUserProfile.invoke(sender, { uin: 10001 })
|
||||
: await FetchUserProfileByUid.invoke(sender, { uid: 'u_test' });
|
||||
expect(profile).toMatchObject({
|
||||
regTime: 1_200_000_000,
|
||||
vipFlag: true,
|
||||
yearVipFlag: true,
|
||||
svipFlag: true,
|
||||
vipLevel: 7,
|
||||
});
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const request =
|
||||
form === 'uin'
|
||||
? protobuf_decode<OidbBase<OidbUserInfoRequest>>(bytes)
|
||||
: protobuf_decode<OidbBase<OidbUserInfoByUidRequest>>(bytes);
|
||||
expect(request.body?.keys?.map((k) => k.key)).toEqual(
|
||||
expect.arrayContaining([20026, 41756, 41757, 42241]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[0, 0, 0],
|
||||
[1, 0, 1],
|
||||
[0x100, 1, 2],
|
||||
[0x103, 0x801, 13],
|
||||
])(
|
||||
'decodes membership flags %i and level mask %i as level %i',
|
||||
async (flags, mask, level) => {
|
||||
const sender = makeSender({
|
||||
body: {
|
||||
uin: 10001,
|
||||
properties: {
|
||||
numberProperties: [
|
||||
{ number1: 41756, number2: flags },
|
||||
{ number1: 41757, number2: mask },
|
||||
{ number1: 42241, number2: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await FetchUserProfile.invoke(sender, { uin: 10001 })).toMatchObject({
|
||||
vipFlag: !!(flags & 1),
|
||||
yearVipFlag: !!(flags & 2),
|
||||
svipFlag: !!(flags & 0x100),
|
||||
vipLevel: level,
|
||||
profile: { vipLevel: level },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps missing registration and membership fields unavailable', async () => {
|
||||
const profile = await FetchUserProfile.invoke(makeSender({ body: { uin: 10001 } }), {
|
||||
uin: 10001,
|
||||
});
|
||||
|
||||
for (const name of [
|
||||
'regTime',
|
||||
'vipFlag',
|
||||
'yearVipFlag',
|
||||
'svipFlag',
|
||||
'vipLevel',
|
||||
'birthdayYear',
|
||||
'birthdayMonth',
|
||||
'birthdayDay',
|
||||
]) {
|
||||
expect(profile).not.toHaveProperty(name);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['uin', 'uid'])(
|
||||
'decodes the birthday bytes-property through the %s request',
|
||||
async (form) => {
|
||||
const sender = makeSender({
|
||||
body: {
|
||||
uin: 10001,
|
||||
uid: 'u_test',
|
||||
properties: {
|
||||
bytesProperties: [
|
||||
{ code: 20031, value: new Uint8Array([0x07, 0xd0, 3, 15]) },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const profile =
|
||||
form === 'uin'
|
||||
? await FetchUserProfile.invoke(sender, { uin: 10001 })
|
||||
: await FetchUserProfileByUid.invoke(sender, { uid: 'u_test' });
|
||||
expect(profile).toMatchObject({
|
||||
birthdayYear: 2000,
|
||||
birthdayMonth: 3,
|
||||
birthdayDay: 15,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[12, 31],
|
||||
[2, 29],
|
||||
])('keeps a hidden-year birthday %i/%i', async (month, day) => {
|
||||
const sender = makeSender({
|
||||
body: {
|
||||
uin: 10001,
|
||||
properties: {
|
||||
bytesProperties: [
|
||||
{ code: 20031, value: new Uint8Array([0, 0, month, day]) },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const profile = await FetchUserProfile.invoke(sender, { uin: 10001 });
|
||||
expect(profile).toMatchObject({ birthdayMonth: month, birthdayDay: day });
|
||||
expect(profile).not.toHaveProperty('birthdayYear');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an unset birthday', [0x07, 0xd0, 0, 0]],
|
||||
['an all-zero value', [0, 0, 0, 0]],
|
||||
['an out-of-range month', [0x07, 0xd0, 13, 1]],
|
||||
['an out-of-range day', [0x07, 0xd0, 1, 32]],
|
||||
['April 31', [0x07, 0xd0, 4, 31]],
|
||||
['a leap day in 1900', [0x07, 0x6c, 2, 29]],
|
||||
['a truncated value', [0x07, 0xd0, 3]],
|
||||
['an oversized value', [0x07, 0xd0, 3, 15, 0]],
|
||||
])('leaves the birthday unavailable for %s', async (_case, value) => {
|
||||
const sender = makeSender({
|
||||
body: {
|
||||
uin: 10001,
|
||||
properties: {
|
||||
bytesProperties: [{ code: 20031, value: new Uint8Array(value) }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const profile = await FetchUserProfile.invoke(sender, { uin: 10001 });
|
||||
|
||||
for (const name of ['birthdayYear', 'birthdayMonth', 'birthdayDay']) {
|
||||
expect(profile).not.toHaveProperty(name);
|
||||
}
|
||||
});
|
||||
|
||||
it('routes to OidbSvcTrpcTcp.0xfe1_2 with reserved=1', async () => {
|
||||
const sender = makeSender({ body: { uin: 10001, uid: 'u' } as any });
|
||||
await FetchUserProfile.invoke(sender, { uin: 10001 });
|
||||
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0xfe1_2');
|
||||
const env = protobuf_decode<OidbBase<OidbUserInfoRequest>>(bytes);
|
||||
expect(env.reserved).toBe(1);
|
||||
});
|
||||
|
||||
it('requests every property key in the catalogue (nickname, level, …)', async () => {
|
||||
const sender = makeSender({ body: { uin: 10001, uid: 'u' } as any });
|
||||
await FetchUserProfile.invoke(sender, { uin: 10001 });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbUserInfoRequest>>(bytes);
|
||||
const keys = env.body?.keys?.map((k) => k.key);
|
||||
expect(keys).toContain(20002); // nickname
|
||||
expect(keys).toContain(105); // level
|
||||
expect(keys).toContain(101); // avatar
|
||||
expect(keys).toContain(102); // sign
|
||||
expect(keys).toContain(103); // remark
|
||||
expect(keys).toContain(20031); // birthday
|
||||
expect(keys).toContain(40410); // qidian crew flag (企点员工标志)
|
||||
expect(keys).toContain(42031); // qidian master flag (企点主号标志)
|
||||
});
|
||||
|
||||
it('decodes qidian flags from number-properties (0 when absent)', async () => {
|
||||
// 企点员工号:两个标志均为 1;普通账号服务器不返回这两个 key → 缺省 0
|
||||
const qidian = makeSender({
|
||||
body: {
|
||||
uin: 10002,
|
||||
uid: 'u',
|
||||
properties: {
|
||||
bytesProperties: [],
|
||||
numberProperties: [
|
||||
{ number1: 40410, number2: 1 },
|
||||
{ number1: 42031, number2: 1 },
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const out = await FetchUserProfile.invoke(qidian, { uin: 10002 });
|
||||
expect(out.qidianMasterFlag).toBe(1);
|
||||
expect(out.qidianCrewFlag).toBe(1);
|
||||
expect(out.qidianCrewFlag2).toBe(0);
|
||||
|
||||
// 普通账号:无企点 key → 全 0
|
||||
const normal = makeSender({
|
||||
body: {
|
||||
uin: 10001,
|
||||
uid: 'u',
|
||||
properties: {
|
||||
bytesProperties: [],
|
||||
numberProperties: [{ number1: 20009, number2: 1 }],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const out2 = await FetchUserProfile.invoke(normal, { uin: 10001 });
|
||||
expect(out2.qidianMasterFlag).toBe(0);
|
||||
expect(out2.qidianCrewFlag).toBe(0);
|
||||
expect(out2.qidianCrewFlag2).toBe(0);
|
||||
});
|
||||
|
||||
it('decodes nickname / remark / qid / sign from bytes-properties', async () => {
|
||||
const enc = (s: string) => new TextEncoder().encode(s);
|
||||
const sender = makeSender({
|
||||
body: {
|
||||
uin: 10001,
|
||||
uid: 'u',
|
||||
properties: {
|
||||
bytesProperties: [
|
||||
{ code: 20002, value: enc('Nick') },
|
||||
{ code: 103, value: enc('Bestie') },
|
||||
{ code: 27394, value: enc('myqid') },
|
||||
{ code: 102, value: enc('Hello') },
|
||||
],
|
||||
numberProperties: [],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const out = await FetchUserProfile.invoke(sender, { uin: 10001 });
|
||||
expect(out.nickname).toBe('Nick');
|
||||
expect(out.remark).toBe('Bestie');
|
||||
expect(out.qid).toBe('myqid');
|
||||
expect(out.sign).toBe('Hello');
|
||||
});
|
||||
|
||||
it('decodes sex / age / level from number-properties (sex enum)', async () => {
|
||||
const cases: Array<[number, string]> = [
|
||||
[1, 'male'],
|
||||
[2, 'female'],
|
||||
[255, 'unknown'],
|
||||
[0, 'unknown'],
|
||||
];
|
||||
|
||||
for (const [sexNum, expected] of cases) {
|
||||
const sender = makeSender({
|
||||
body: {
|
||||
uin: 1,
|
||||
uid: 'u',
|
||||
properties: {
|
||||
bytesProperties: [],
|
||||
numberProperties: [
|
||||
{ number1: 20009, number2: sexNum },
|
||||
{ number1: 20037, number2: 25 },
|
||||
{ number1: 105, number2: 7 },
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const out = await FetchUserProfile.invoke(sender, { uin: 1 });
|
||||
expect(out.sex).toBe(expected);
|
||||
expect(out.age).toBe(25);
|
||||
expect(out.level).toBe(7);
|
||||
}
|
||||
});
|
||||
|
||||
it('decodes the avatar URL with the "640" size suffix appended', async () => {
|
||||
const avatarBytes = protobuf_encode<AvatarInfo>({ url: 'https://q.qlogo.cn/abc/' });
|
||||
const sender = makeSender({
|
||||
body: {
|
||||
uin: 10001,
|
||||
uid: 'u',
|
||||
properties: {
|
||||
bytesProperties: [{ code: 101, value: avatarBytes }],
|
||||
numberProperties: [],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const out = await FetchUserProfile.invoke(sender, { uin: 10001 });
|
||||
expect(out.avatar).toBe('https://q.qlogo.cn/abc/640');
|
||||
});
|
||||
|
||||
it('defaults uin to the requested value when the server omits its echo', async () => {
|
||||
const sender = makeSender({ body: { uid: 'u' } as any });
|
||||
const out = await FetchUserProfile.invoke(sender, { uin: 99999 });
|
||||
expect(out.uin).toBe(99999);
|
||||
});
|
||||
|
||||
it('throws when body is entirely missing from the envelope', async () => {
|
||||
const sender = makeSender({});
|
||||
await expect(FetchUserProfile.invoke(sender, { uin: 1 })).rejects.toThrow(
|
||||
'user info response body missing',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbBuddyRecommendArkResp } from '@snowluma/proto-defs/oidb-actions/contact-ark';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { GetBuddyRecommendArk } from '../../../src/oidb-services/contacts/get-buddy-recommend-ark';
|
||||
import { env, v, s } from '../_pb-oracle';
|
||||
|
||||
function makeSender(ark = '{"app":"com.tencent.contact.lua"}') {
|
||||
const respEnv: OidbBase<OidbBuddyRecommendArkResp> = {
|
||||
command: 0x12b6,
|
||||
subCommand: 0,
|
||||
body: { ark },
|
||||
};
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<OidbBase<OidbBuddyRecommendArkResp>>(respEnv)),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('GetBuddyRecommendArk namespace', () => {
|
||||
it('declares command 0x12b6 sub 0, plain OIDB envelope', () => {
|
||||
expect(GetBuddyRecommendArk.command).toBe(0x12b6);
|
||||
expect(GetBuddyRecommendArk.subCommand).toBe(0);
|
||||
expect(GetBuddyRecommendArk.uinForm).toBe(false);
|
||||
});
|
||||
|
||||
it('serializes uin + phone placeholder + the hard-coded jump_url template', () => {
|
||||
const out = GetBuddyRecommendArk.serialize({} as any, { userId: 5 });
|
||||
expect(out).toMatchObject({ uin: 5, phoneNumber: '-' });
|
||||
expect(out.jumpUrl).toBe(
|
||||
'mqqapi://card/show_pslcard?src_type=internal&source=sharecard&version=1&uin=5',
|
||||
);
|
||||
});
|
||||
|
||||
it('byte-oracle: routes to 0x12b6_0 and locks req tags {1:uin, 2:phone, 3:jumpUrl}', async () => {
|
||||
const sender = makeSender();
|
||||
const ark = await GetBuddyRecommendArk.invoke(sender, { userId: 10000 });
|
||||
expect(ark).toBe('{"app":"com.tencent.contact.lua"}');
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x12b6_0');
|
||||
const jumpUrl =
|
||||
'mqqapi://card/show_pslcard?src_type=internal&source=sharecard&version=1&uin=10000';
|
||||
const body = [...v(1, 10000), ...s(2, '-'), ...s(3, jumpUrl)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0x12b6, 0, body, false));
|
||||
});
|
||||
|
||||
it('includes phone tag 2 when a phone number is provided', () => {
|
||||
const out = GetBuddyRecommendArk.serialize({} as any, { userId: 7, phoneNumber: '123' });
|
||||
expect(out.phoneNumber).toBe('123');
|
||||
});
|
||||
|
||||
it('deserialize returns the ark json string ("" when absent)', () => {
|
||||
expect(GetBuddyRecommendArk.deserialize({} as any, { ark: 'x' })).toBe('x');
|
||||
expect(GetBuddyRecommendArk.deserialize({} as any, {})).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbGroupRecommendArkResp } from '@snowluma/proto-defs/oidb-actions/contact-ark';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { GetGroupRecommendArk } from '../../../src/oidb-services/contacts/get-group-recommend-ark';
|
||||
import { env, v } from '../_pb-oracle';
|
||||
|
||||
function makeSender(arkJson = '{"app":"com.tencent.structmsg"}') {
|
||||
const respEnv: OidbBase<OidbGroupRecommendArkResp> = {
|
||||
command: 0x8b7,
|
||||
subCommand: 5,
|
||||
body: { errCode: 0, arkJson },
|
||||
};
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<OidbBase<OidbGroupRecommendArkResp>>(respEnv)),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('GetGroupRecommendArk namespace', () => {
|
||||
it('declares command 0x8B7 sub 5, uin form', () => {
|
||||
expect(GetGroupRecommendArk.command).toBe(0x8b7);
|
||||
expect(GetGroupRecommendArk.subCommand).toBe(5);
|
||||
expect(GetGroupRecommendArk.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes the constant reqType=1 / flag=1 + group code', () => {
|
||||
expect(GetGroupRecommendArk.serialize({} as any, { groupId: 999 })).toEqual({
|
||||
reqType: 1,
|
||||
groupCode: 999,
|
||||
flag: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('byte-oracle: routes to 0x8b7_5 and locks req tags {1:reqType, 2:groupCode, 5:flag}', async () => {
|
||||
const sender = makeSender();
|
||||
const ark = await GetGroupRecommendArk.invoke(sender, { groupId: 12345 });
|
||||
expect(ark).toBe('{"app":"com.tencent.structmsg"}');
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x8b7_5');
|
||||
const body = [...v(1, 1), ...v(2, 12345), ...v(5, 1)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0x8b7, 5, body, true));
|
||||
});
|
||||
|
||||
it('deserialize returns the ark json from response field 5', () => {
|
||||
expect(GetGroupRecommendArk.deserialize({} as any, { errCode: 0, arkJson: 'g' })).toBe('g');
|
||||
expect(GetGroupRecommendArk.deserialize({} as any, {})).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { GetGroupShareLink } from '../../../src/oidb-services/contacts/get-group-share-link';
|
||||
import type { OidbSender } from '../../../src/oidb-service';
|
||||
import { env, m, s, v } from '../_pb-oracle';
|
||||
|
||||
const shortUrl = 'https://qm.qq.com/q/example';
|
||||
const params: GetGroupShareLink.Params = {
|
||||
groupId: 12345,
|
||||
needShortUrl: true,
|
||||
srcId: 73,
|
||||
additionalParam: '',
|
||||
};
|
||||
|
||||
function sender(body: number[] = m(12, s(1, shortUrl))) {
|
||||
const packet: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(env(0x9060, 3, body, true), 'hex'),
|
||||
};
|
||||
return {
|
||||
packet,
|
||||
sendRawPacket: vi.fn<OidbSender['sendRawPacket']>().mockResolvedValue(packet),
|
||||
};
|
||||
}
|
||||
|
||||
describe('GetGroupShareLink', () => {
|
||||
it('requests a short group link using the verified packet layout', async () => {
|
||||
const deps = sender();
|
||||
|
||||
await expect(GetGroupShareLink.invoke(deps, params)).resolves.toBe(shortUrl);
|
||||
|
||||
const body = [...v(1, 2), ...m(12, [...v(1, 12345), ...s(3, ''), ...v(4, 1)])];
|
||||
expect(deps.sendRawPacket).toHaveBeenCalledExactlyOnceWith(
|
||||
'OidbSvcTrpcTcp.0x9060_3',
|
||||
new Uint8Array(Buffer.from(env(0x9060, 3, body, true), 'hex')),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a false short-link flag, additional parameters, and the returned long URL', async () => {
|
||||
const url = 'https://qm.qq.com/cgi-bin/qm/qr?k=example&jump_from=webapi';
|
||||
const additionalParam = 'source=群二维码&test=1';
|
||||
const deps = sender(m(12, s(1, url)));
|
||||
|
||||
await expect(
|
||||
GetGroupShareLink.invoke(deps, { ...params, needShortUrl: false, additionalParam }),
|
||||
).resolves.toBe(url);
|
||||
|
||||
const body = [...v(1, 2), ...m(12, [...v(1, 12345), ...s(3, additionalParam), ...v(4, 0)])];
|
||||
expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe(
|
||||
env(0x9060, 3, body, true),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([0, 73, 0xffffffff])(
|
||||
'retains client-compatible behavior for source %s',
|
||||
async (srcId) => {
|
||||
const deps = sender();
|
||||
await GetGroupShareLink.invoke(deps, { ...params, srcId });
|
||||
|
||||
const body = [...v(1, 2), ...m(12, [...v(1, 12345), ...s(3, ''), ...v(4, 1)])];
|
||||
expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe(
|
||||
env(0x9060, 3, body, true),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['missing result', []],
|
||||
['missing URL', m(12, [])],
|
||||
['empty URL', m(12, s(1, ''))],
|
||||
['blank URL', m(12, s(1, ' '))],
|
||||
] as const)('rejects a successful response with %s', async (_name, body) => {
|
||||
await expect(GetGroupShareLink.invoke(sender([...body]), params)).rejects.toThrow(
|
||||
'group share link response did not include a URL',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves server errors even if the response includes a URL', async () => {
|
||||
const deps = sender();
|
||||
deps.packet.responseData = Buffer.from([
|
||||
...v(1, 0x9060),
|
||||
...v(2, 3),
|
||||
...v(3, 403),
|
||||
...m(4, m(12, s(1, shortUrl))),
|
||||
...s(5, 'permission denied'),
|
||||
]);
|
||||
|
||||
await expect(GetGroupShareLink.invoke(deps, params)).rejects.toMatchObject({
|
||||
name: 'OidbError',
|
||||
code: 403,
|
||||
serverMsg: 'permission denied',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a transport timeout instead of inventing a URL', async () => {
|
||||
const deps = sender();
|
||||
deps.packet.success = false;
|
||||
deps.packet.gotResponse = false;
|
||||
deps.packet.errorMessage = 'request timed out';
|
||||
|
||||
await expect(GetGroupShareLink.invoke(deps, params)).rejects.toThrow('request timed out');
|
||||
});
|
||||
|
||||
it('rejects a truncated response', async () => {
|
||||
const deps = sender();
|
||||
deps.packet.responseData = Buffer.from([0x22, 0x03, 0x62, 0xff]);
|
||||
|
||||
await expect(GetGroupShareLink.invoke(deps, params)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase, OidbSvcTrpcTcp0xFD4_1Response } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbUserInfoResponse } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import { FetchFriendListPage } from '../../../src/oidb-services/contacts/fetch-friend-list-page';
|
||||
import { FetchUserProfile } from '../../../src/oidb-services/contacts/fetch-user-profile';
|
||||
import { FetchUserProfileByUid } from '../../../src/oidb-services/contacts/fetch-user-profile-by-uid';
|
||||
import { decodePublicProfile } from '../../../src/oidb-services/contacts/public-profile';
|
||||
|
||||
// Synthetic wire fixture, independent of the privilege schema: top-level tag 3;
|
||||
// body tags 2 (jump), 3 (open), 4 (closed); both entry URLs are present.
|
||||
const icons = Buffer.from(
|
||||
'1a2c1204636172641a1e08022000280032066e6f726d616c3a0664656c75786542046a756d704801220408032001',
|
||||
'hex',
|
||||
);
|
||||
const bytes = [
|
||||
{ code: 102, value: Buffer.from('') },
|
||||
{ code: 20011, value: Buffer.from('mail@example.test') },
|
||||
{ code: 20016, value: Buffer.from('') },
|
||||
{ code: 20003, value: Buffer.from('中国') },
|
||||
{ code: 20004, value: Buffer.from('省份') },
|
||||
{ code: 20020, value: Buffer.from('城市') },
|
||||
{ code: 20005, value: Buffer.from('邮编') },
|
||||
{ code: 20021, value: Buffer.from('学校') },
|
||||
{ code: 20031, value: Uint8Array.from([0, 0, 2, 29]) },
|
||||
{ code: 107, value: icons },
|
||||
];
|
||||
const numbers = [
|
||||
[20037, 0],
|
||||
[105, 0],
|
||||
[20009, 2],
|
||||
[41756, 0],
|
||||
[41757, 0],
|
||||
[42241, 0],
|
||||
] as const;
|
||||
|
||||
function sender(responseData: Uint8Array) {
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(responseData),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('public contact profiles', () => {
|
||||
it.each(['roster', 'uin', 'uid'])(
|
||||
'preserves presence and privilege lists through the %s wire codec',
|
||||
async (form) => {
|
||||
let profile;
|
||||
|
||||
if (form === 'roster') {
|
||||
const page = await FetchFriendListPage.invoke(
|
||||
sender(
|
||||
protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFD4_1Response>>({
|
||||
body: {
|
||||
friends: [
|
||||
{
|
||||
uin: 10001,
|
||||
customGroup: 0,
|
||||
additional: [
|
||||
{
|
||||
type: 1,
|
||||
layer1: {
|
||||
properties: bytes,
|
||||
numberProperties: numbers.map(
|
||||
([key, value]) => ({ key, value }),
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
{},
|
||||
);
|
||||
expect(page.entries[0]?.friend.categoryId).toBe(0);
|
||||
profile = page.entries[0]?.friend.profile;
|
||||
} else {
|
||||
const deps = sender(
|
||||
protobuf_encode<OidbBase<OidbUserInfoResponse>>({
|
||||
body: {
|
||||
body: {
|
||||
uin: 10001,
|
||||
uid: 'u_test',
|
||||
properties: {
|
||||
bytesProperties: bytes,
|
||||
numberProperties: numbers.map(([number1, number2]) => ({
|
||||
number1,
|
||||
number2,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const user =
|
||||
form === 'uin'
|
||||
? await FetchUserProfile.invoke(deps, { uin: 10001 })
|
||||
: await FetchUserProfileByUid.invoke(deps, { uid: 'u_test' });
|
||||
profile = user.profile;
|
||||
}
|
||||
|
||||
expect(profile).toEqual({
|
||||
sign: '',
|
||||
age: 0,
|
||||
level: 0,
|
||||
sex: 'female',
|
||||
email: 'mail@example.test',
|
||||
phoneNum: '',
|
||||
country: '中国',
|
||||
province: '省份',
|
||||
city: '城市',
|
||||
college: '学校',
|
||||
birthdayMonth: 2,
|
||||
birthdayDay: 29,
|
||||
vipFlag: false,
|
||||
svipFlag: false,
|
||||
yearVipFlag: false,
|
||||
vipLevel: 0,
|
||||
privilegeIcons: {
|
||||
jumpUrl: 'card',
|
||||
open: [
|
||||
{
|
||||
type: 2,
|
||||
level: 0,
|
||||
flag: 0,
|
||||
iconUrl: 'normal',
|
||||
deluxeIconUrl: 'deluxe',
|
||||
jumpUrl: 'jump',
|
||||
isBig: 1,
|
||||
},
|
||||
],
|
||||
closed: [{ type: 3, level: 1 }],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('distinguishes missing, unavailable and explicitly empty icon lists', () => {
|
||||
expect(decodePublicProfile(new Map(), new Map())).toEqual({});
|
||||
expect(decodePublicProfile(new Map([[107, Buffer.alloc(0)]]), new Map())).toEqual({});
|
||||
expect(decodePublicProfile(new Map([[107, Buffer.from([0x1a, 0])]]), new Map())).toEqual({
|
||||
privilegeIcons: { open: [], closed: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects truncated icons and invalid text without returning successful empty data', () => {
|
||||
expect(() =>
|
||||
decodePublicProfile(new Map([[107, Buffer.from([0x1a, 0xff])]]), new Map()),
|
||||
).toThrow('invalid privilege data');
|
||||
expect(() =>
|
||||
decodePublicProfile(new Map([[107, Buffer.from([0x18, 0])]]), new Map()),
|
||||
).toThrow('invalid privilege data');
|
||||
expect(() =>
|
||||
decodePublicProfile(new Map([[107, Buffer.from('1a041a020a00', 'hex')]]), new Map()),
|
||||
).toThrow('invalid privilege data');
|
||||
expect(() =>
|
||||
decodePublicProfile(new Map([[20011, Buffer.from([0xff])]]), new Map()),
|
||||
).toThrow('invalid UTF-8');
|
||||
});
|
||||
|
||||
it('rejects a profile response belonging to a different requested account', async () => {
|
||||
const deps = sender(
|
||||
protobuf_encode<OidbBase<OidbUserInfoResponse>>({ body: { body: { uin: 10002 } } }),
|
||||
);
|
||||
await expect(FetchUserProfile.invoke(deps, { uin: 10001 })).rejects.toThrow(
|
||||
'does not match',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { SendTuwenArk } from '../../../src/oidb-services/contacts/send-tuwen-ark';
|
||||
import { env, v, s, m } from '../_pb-oracle';
|
||||
|
||||
function makeSender(body = m(1, [...v(2, 10001), ...v(4, 0)])) {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(env(0xdc2, 34, body, false), 'hex'),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('SendTuwenArk namespace', () => {
|
||||
it('declares command 0xdc2 sub 34, SSO: OidbSvcTrpcTcp.0xdc2_34', () => {
|
||||
expect(SendTuwenArk.command).toBe(0xdc2);
|
||||
expect(SendTuwenArk.subCommand).toBe(34);
|
||||
expect(SendTuwenArk.uinForm).toBe(false);
|
||||
});
|
||||
|
||||
it('serializes fixed appId=100446242, field2=1, field3=0, field5={1:1}', () => {
|
||||
const out = SendTuwenArk.serialize({} as any, {
|
||||
targetId: 12345,
|
||||
peerType: 1,
|
||||
title: 'T',
|
||||
desc: 'D',
|
||||
summary: 'S',
|
||||
jumpUrl: 'https://example.com',
|
||||
previewUrl: '',
|
||||
});
|
||||
expect(out.appInfo).toMatchObject({
|
||||
appId: 100446242,
|
||||
field2: 1,
|
||||
field3: 0,
|
||||
field5: { field1: 1 },
|
||||
targetId: 12345,
|
||||
});
|
||||
});
|
||||
|
||||
it('byte-oracle: C2C (peerType=0) — peerType omitted on wire, field3=0 present, previewUrl present', async () => {
|
||||
const sender = makeSender();
|
||||
await SendTuwenArk.invoke(sender, {
|
||||
targetId: 2863253201,
|
||||
peerType: 0,
|
||||
title: 'QQ开放平台',
|
||||
desc: 'QQ小程序是连接广轻用户的新方式,覆盖8亿新生代活跃网民。轻便快捷的开发模式,将能在QQ内被轻松获取和传播',
|
||||
summary: '[分享] QQ开放平台',
|
||||
jumpUrl: 'https://q.qq.com/r',
|
||||
previewUrl:
|
||||
'https://tangram-1251316161.file.myqcloud.com/files/20210721/e50a8e37e08f29bf1ffc7466e1950690.png',
|
||||
});
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xdc2_34');
|
||||
|
||||
// AppInfo embedding: field5={1:1}, content={1:1, 10:title, 11:desc, 12:summary, 13:jumpUrl, 14:previewUrl}, then appId/field2/field3/targetId
|
||||
const field5 = m(5, [...v(1, 1)]);
|
||||
const content = m(12, [
|
||||
...v(1, 1),
|
||||
...s(10, 'QQ开放平台'),
|
||||
...s(
|
||||
11,
|
||||
'QQ小程序是连接广轻用户的新方式,覆盖8亿新生代活跃网民。轻便快捷的开发模式,将能在QQ内被轻松获取和传播',
|
||||
),
|
||||
...s(12, '[分享] QQ开放平台'),
|
||||
...s(13, 'https://q.qq.com/r'),
|
||||
...s(
|
||||
14,
|
||||
'https://tangram-1251316161.file.myqcloud.com/files/20210721/e50a8e37e08f29bf1ffc7466e1950690.png',
|
||||
),
|
||||
]);
|
||||
const appInfo = m(1, [
|
||||
...v(1, 100446242),
|
||||
...v(2, 1),
|
||||
...v(3, 0), // field3=0 is present in wire (pb_optional preserves zero as expected)
|
||||
...field5,
|
||||
...v(11, 2863253201),
|
||||
...content,
|
||||
]);
|
||||
// Meta: peerType=0 is present in wire (pb_optional preserves zero as expected)
|
||||
const meta = m(2, [...v(1, 0), ...v(2, 2863253201)]);
|
||||
const body = [...appInfo, ...meta];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xdc2, 34, body, false));
|
||||
});
|
||||
|
||||
it('byte-oracle: group (peerType=1) — peerType present, field3=0 present, empty previewUrl present', async () => {
|
||||
const sender = makeSender();
|
||||
await SendTuwenArk.invoke(sender, {
|
||||
targetId: 123456789,
|
||||
peerType: 1,
|
||||
title: '测试标题',
|
||||
desc: '测试描述',
|
||||
summary: '测试摘要',
|
||||
jumpUrl: 'https://example.com/jump',
|
||||
previewUrl: '',
|
||||
});
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xdc2_34');
|
||||
|
||||
const field5 = m(5, [...v(1, 1)]);
|
||||
const content = m(12, [
|
||||
...v(1, 1),
|
||||
...s(10, '测试标题'),
|
||||
...s(11, '测试描述'),
|
||||
...s(12, '测试摘要'),
|
||||
...s(13, 'https://example.com/jump'),
|
||||
...s(14, ''), // previewUrl='' is present in wire (pb_optional preserves empty string as expected)
|
||||
]);
|
||||
const appInfo = m(1, [
|
||||
...v(1, 100446242),
|
||||
...v(2, 1),
|
||||
...v(3, 0), // field3=0 is present
|
||||
...field5,
|
||||
...v(11, 123456789),
|
||||
...content,
|
||||
]);
|
||||
// Meta: peerType=1 present
|
||||
const meta = m(2, [...v(1, 1), ...v(2, 123456789)]);
|
||||
const body = [...appInfo, ...meta];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xdc2, 34, body, false));
|
||||
});
|
||||
|
||||
it('rejects a delivery refusal even when the outer envelope succeeds', async () => {
|
||||
const sender = makeSender(
|
||||
m(1, [
|
||||
...v(1, 0),
|
||||
...v(2, 10001),
|
||||
...s(3, ''),
|
||||
...v(4, 901501),
|
||||
...s(5, 'imagent service_error:319_[oidb] rule type not match appid'),
|
||||
]),
|
||||
);
|
||||
await expect(
|
||||
SendTuwenArk.invoke(sender, {
|
||||
targetId: 10001,
|
||||
peerType: 0,
|
||||
title: 'title',
|
||||
desc: '',
|
||||
summary: '',
|
||||
jumpUrl: 'https://example.com',
|
||||
previewUrl: '',
|
||||
}),
|
||||
).rejects.toThrow(/901501.*rule type not match appid/);
|
||||
});
|
||||
|
||||
it('checks every delivery result and requires an acknowledgement', () => {
|
||||
const sender = makeSender();
|
||||
expect(() => SendTuwenArk.deserialize(sender, {})).toThrow('no delivery result');
|
||||
expect(() => SendTuwenArk.deserialize(sender, { results: [] })).toThrow(
|
||||
'no delivery result',
|
||||
);
|
||||
expect(() =>
|
||||
SendTuwenArk.deserialize(sender, {
|
||||
results: [{ errorCode: 0 }, { errorCode: 901501, errorMessage: 'refused' }],
|
||||
}),
|
||||
).toThrow('refused');
|
||||
expect(SendTuwenArk.deserialize(sender, { results: [{}] })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb';
|
||||
import { SetFriendCategory } from '../../../src/oidb-services/contacts/set-friend-category';
|
||||
import { env, s, v } from '../_pb-oracle';
|
||||
|
||||
function packet(errorCode = 0, errorMessage = ''): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbEmpty>>({
|
||||
command: 0x1255,
|
||||
subCommand: 0,
|
||||
errorCode,
|
||||
errorMsg: errorMessage,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SetFriendCategory namespace', () => {
|
||||
it('uses the client command and UIN-form envelope confirmed by the native worker', () => {
|
||||
expect(SetFriendCategory.command).toBe(0x1255);
|
||||
expect(SetFriendCategory.subCommand).toBe(0);
|
||||
expect(SetFriendCategory.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes the friend UID and category ID with the verified field layout', async () => {
|
||||
const sendRawPacket = vi.fn(async () => packet());
|
||||
|
||||
await SetFriendCategory.invoke(
|
||||
{ sendRawPacket },
|
||||
{
|
||||
uid: 'u_friend',
|
||||
categoryId: 7,
|
||||
},
|
||||
);
|
||||
|
||||
expect(sendRawPacket).toHaveBeenCalledOnce();
|
||||
const [command, bytes] = sendRawPacket.mock.calls[0]!;
|
||||
expect(command).toBe('OidbSvcTrpcTcp.0x1255_0');
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(
|
||||
env(0x1255, 0, [...s(1, 'u_friend'), ...v(2, 7)], true),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts the idempotent success code used by the QQ client', async () => {
|
||||
const sendRawPacket = vi.fn(async () => packet(2_001_002));
|
||||
|
||||
await expect(
|
||||
SetFriendCategory.invoke(
|
||||
{ sendRawPacket },
|
||||
{
|
||||
uid: 'u_friend',
|
||||
categoryId: 7,
|
||||
},
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts the idempotent result when the packet layer reports it directly', async () => {
|
||||
const sendRawPacket = vi.fn(async (): Promise<SendPacketResult> => ({
|
||||
success: false,
|
||||
gotResponse: true,
|
||||
errorCode: 2_001_002,
|
||||
errorMessage: 'already applied',
|
||||
responseData: null,
|
||||
}));
|
||||
|
||||
await expect(
|
||||
SetFriendCategory.invoke(
|
||||
{ sendRawPacket },
|
||||
{
|
||||
uid: 'u_friend',
|
||||
categoryId: 7,
|
||||
},
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('surfaces explicit server rejections', async () => {
|
||||
const sendRawPacket = vi.fn(async () => packet(42, 'denied'));
|
||||
|
||||
await expect(
|
||||
SetFriendCategory.invoke(
|
||||
{ sendRawPacket },
|
||||
{
|
||||
uid: 'u_friend',
|
||||
categoryId: 7,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/42.*denied/);
|
||||
});
|
||||
|
||||
it('does not mistake a transport failure with code zero for success', async () => {
|
||||
const sendRawPacket = vi.fn(async (): Promise<SendPacketResult> => ({
|
||||
success: false,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: 'packet send failed',
|
||||
responseData: null,
|
||||
}));
|
||||
|
||||
await expect(
|
||||
SetFriendCategory.invoke(
|
||||
{ sendRawPacket },
|
||||
{
|
||||
uid: 'u_friend',
|
||||
categoryId: 7,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('packet send failed');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user