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,68 @@
|
||||
// Independent protobuf byte-oracle helpers for OIDB service tests.
|
||||
//
|
||||
// These build expected wire bytes from FIRST PRINCIPLES (the RE'd tag
|
||||
// numbers), NOT via proton — so asserting `proton.encode(...) === pb.env(...)`
|
||||
// goes red if a proto-defs field tag is wrong or silently changes. A
|
||||
// symmetric encode→decode round-trip can't catch a tag swap; this can.
|
||||
//
|
||||
// Emission rules mirror proton's observed behaviour: fields in ASCENDING
|
||||
// tag order, and proto3 defaults (0 / '' / false) OMITTED.
|
||||
|
||||
function uvarint(n: number): number[] {
|
||||
const out: number[] = [];
|
||||
let x = n;
|
||||
|
||||
do {
|
||||
let b = x % 128;
|
||||
x = Math.floor(x / 128);
|
||||
|
||||
if (x > 0) {
|
||||
b |= 0x80;
|
||||
}
|
||||
|
||||
out.push(b);
|
||||
} while (x > 0);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function tag(field: number, wire: number): number[] {
|
||||
return uvarint(field * 8 + wire);
|
||||
}
|
||||
|
||||
/** varint field (wire type 0). */
|
||||
export function v(field: number, value: number): number[] {
|
||||
return [...tag(field, 0), ...uvarint(value)];
|
||||
}
|
||||
|
||||
/** length-delimited string field (wire type 2). */
|
||||
export function s(field: number, str: string): number[] {
|
||||
const b = [...Buffer.from(str, 'utf8')];
|
||||
return [...tag(field, 2), ...uvarint(b.length), ...b];
|
||||
}
|
||||
|
||||
/** length-delimited sub-message field (wire type 2). */
|
||||
export function m(field: number, msg: number[]): number[] {
|
||||
return [...tag(field, 2), ...uvarint(msg.length), ...msg];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a body in the OidbBase envelope exactly as proton emits it:
|
||||
* command(1), subCommand(2) [omitted when 0], body(4), reserved(12)=1
|
||||
* [only when uinForm]. errorCode(3)/errorMsg(5) are defaults → omitted.
|
||||
*/
|
||||
export function env(command: number, subCommand: number, body: number[], uinForm: boolean): string {
|
||||
const out = [...v(1, command)];
|
||||
|
||||
if (subCommand !== 0) {
|
||||
out.push(...v(2, subCommand));
|
||||
}
|
||||
|
||||
out.push(...m(4, body));
|
||||
|
||||
if (uinForm) {
|
||||
out.push(...v(12, 1));
|
||||
}
|
||||
|
||||
return Buffer.from(out).toString('hex');
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { DeleteCustomFace } from '../../../src/oidb-services/custom-face/delete-custom-face';
|
||||
|
||||
function makeSender() {
|
||||
const resp: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => resp) };
|
||||
}
|
||||
|
||||
const SAMPLE_UIN = '2550419068';
|
||||
const SAMPLE_EMOJI_ID = '2550419068_0_0_0_2765030D118F8E04B1BFB8DC773C343B_0_0';
|
||||
|
||||
// Wire bytes captured from QQ 9.9.26-44343 (Faceroam.OpReq, opType=2, deleting
|
||||
// the emoji_id above).
|
||||
const DELETE_WIRE_HEX =
|
||||
'0a0e0801120a31302e302e323632303010fc9c91c00918022a370a35323535303431393036385f305f305f305f32373635303330443131384638453034423142464238444337373343333433425f305f30';
|
||||
|
||||
describe('DeleteCustomFace namespace', () => {
|
||||
describe('serialize + encode', () => {
|
||||
it('reproduces the captured delete wire bytes exactly', () => {
|
||||
const req = DeleteCustomFace.serialize({} as any, {
|
||||
uin: SAMPLE_UIN,
|
||||
emojiId: SAMPLE_EMOJI_ID,
|
||||
});
|
||||
const hex = Buffer.from(DeleteCustomFace.encode(req)).toString('hex');
|
||||
expect(hex).toBe(DELETE_WIRE_HEX);
|
||||
});
|
||||
|
||||
it('omits qqVersion from inner (delete inner is 14 bytes, not 28)', () => {
|
||||
const req = DeleteCustomFace.serialize({} as any, { uin: SAMPLE_UIN, emojiId: 'x' });
|
||||
expect(req.inner?.qqVersion).toBeUndefined();
|
||||
});
|
||||
|
||||
it('puts the emoji_id into field5.body.emojiId', () => {
|
||||
const req = DeleteCustomFace.serialize({} as any, {
|
||||
uin: SAMPLE_UIN,
|
||||
emojiId: SAMPLE_EMOJI_ID,
|
||||
});
|
||||
expect(req.body?.emojiId).toBe(SAMPLE_EMOJI_ID);
|
||||
});
|
||||
|
||||
it('uses field3=2 and leaves field6 unset', () => {
|
||||
const req = DeleteCustomFace.serialize({} as any, { uin: SAMPLE_UIN, emojiId: 'x' });
|
||||
expect(req.field3).toBe(2);
|
||||
expect(req.field6).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke', () => {
|
||||
it('routes through "Faceroam.OpReq"', async () => {
|
||||
const sender = makeSender();
|
||||
await DeleteCustomFace.invoke(sender, { uin: SAMPLE_UIN, emojiId: SAMPLE_EMOJI_ID });
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('Faceroam.OpReq');
|
||||
});
|
||||
|
||||
it('passes the captured wire body', async () => {
|
||||
const sender = makeSender();
|
||||
await DeleteCustomFace.invoke(sender, { uin: SAMPLE_UIN, emojiId: SAMPLE_EMOJI_ID });
|
||||
const [, body] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(Buffer.from(body as Uint8Array).toString('hex')).toBe(DELETE_WIRE_HEX);
|
||||
});
|
||||
|
||||
it('resolves on success without returning a body', async () => {
|
||||
const sender = makeSender();
|
||||
await expect(
|
||||
DeleteCustomFace.invoke(sender, { uin: SAMPLE_UIN, emojiId: 'x' }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when the sender reports no response', async () => {
|
||||
const sender = {
|
||||
sendRawPacket: vi.fn(async (): Promise<SendPacketResult> => ({
|
||||
success: false,
|
||||
gotResponse: false,
|
||||
errorCode: 0,
|
||||
errorMessage: 'timeout',
|
||||
responseData: null,
|
||||
})),
|
||||
};
|
||||
await expect(
|
||||
DeleteCustomFace.invoke(sender, { uin: SAMPLE_UIN, emojiId: 'x' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
CustomFaceModifyResp,
|
||||
CustomFaceMoveBody,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
import { FetchCustomFaceDetail } from '../../../src/oidb-services/custom-face/fetch-custom-face-detail';
|
||||
|
||||
const EMOJI_A_ID = '2550419068_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0';
|
||||
const EMOJI_A_MD5 = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||
const EMOJI_B_ID = '2550419068_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0';
|
||||
const EMOJI_B_MD5 = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';
|
||||
|
||||
function makeSender(body: CustomFaceModifyResp) {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<OidbBase<CustomFaceModifyResp>>({ body })),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchCustomFaceDetail namespace', () => {
|
||||
it('sends the IDA-confirmed 0x902e_1 opType=2 request with every id and md5', async () => {
|
||||
const sender = makeSender({ retCode: 0, entries: [] });
|
||||
await FetchCustomFaceDetail.invoke(sender, {
|
||||
emojis: [
|
||||
{ emojiId: EMOJI_A_ID, md5: EMOJI_A_MD5 },
|
||||
{ emojiId: EMOJI_B_ID, md5: EMOJI_B_MD5 },
|
||||
],
|
||||
});
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x902e_1');
|
||||
const env = protobuf_decode<OidbBase<CustomFaceMoveBody>>(bytes);
|
||||
expect(env.command).toBe(0x902e);
|
||||
expect(env.subCommand).toBe(1);
|
||||
expect(env.reserved).toBe(1);
|
||||
expect(env.body).toMatchObject({
|
||||
field1: 1,
|
||||
opType: 2,
|
||||
emojis: [
|
||||
{ emojiId: EMOJI_A_ID, md5: EMOJI_A_MD5 },
|
||||
{ emojiId: EMOJI_B_ID, md5: EMOJI_B_MD5 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns tag 3 descriptions and falls back to legacy tag 2', async () => {
|
||||
const sender = makeSender({
|
||||
retCode: 0,
|
||||
entries: [
|
||||
{ emoji: { emojiId: EMOJI_A_ID }, legacyDesc: '旧版描述' },
|
||||
{ emoji: { emojiId: EMOJI_B_ID }, legacyDesc: '旧值', desc: '新版描述' },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
FetchCustomFaceDetail.invoke(sender, {
|
||||
emojis: [{ emojiId: EMOJI_A_ID, md5: EMOJI_A_MD5 }],
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{ emojiId: EMOJI_A_ID, desc: '旧版描述' },
|
||||
{ emojiId: EMOJI_B_ID, desc: '新版描述' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not silently accept malformed or duplicate response entries', () => {
|
||||
expect(() =>
|
||||
FetchCustomFaceDetail.deserialize({} as any, {
|
||||
entries: [{ desc: 'missing id' }],
|
||||
}),
|
||||
).toThrow(/has no emoji id/);
|
||||
|
||||
expect(() =>
|
||||
FetchCustomFaceDetail.deserialize({} as any, {
|
||||
entries: [{ emoji: { emojiId: EMOJI_A_ID } }, { emoji: { emojiId: EMOJI_A_ID } }],
|
||||
}),
|
||||
).toThrow(/duplicate emoji id/);
|
||||
});
|
||||
|
||||
it('surfaces a non-zero business result', () => {
|
||||
expect(() =>
|
||||
FetchCustomFaceDetail.deserialize({} as any, {
|
||||
retCode: 5,
|
||||
errMsg: 'denied',
|
||||
}),
|
||||
).toThrow(/denied/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type { FaceroamOpReq, FaceroamOpResp } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
import { FetchCustomFaceList } from '../../../src/oidb-services/custom-face/fetch-custom-face-list';
|
||||
|
||||
const SAMPLE_UIN = '10001';
|
||||
const EMOJI_A = '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0';
|
||||
const EMOJI_B = '10001_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0';
|
||||
|
||||
function encodeLegacyList(uin: string): Uint8Array {
|
||||
return protobuf_encode<FaceroamOpReq>({
|
||||
inner: { field1: 1, osVersion: '10.0.26200', qqVersion: '9.9.28-46928' },
|
||||
uin: BigInt(uin),
|
||||
field3: 1,
|
||||
field6: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function makeSender(
|
||||
opts: {
|
||||
faces?: FaceroamOpResp;
|
||||
success?: boolean;
|
||||
gotResponse?: boolean;
|
||||
errorMessage?: string;
|
||||
responseData?: Uint8Array | null;
|
||||
} = {},
|
||||
) {
|
||||
const result: SendPacketResult = {
|
||||
success: opts.success ?? true,
|
||||
gotResponse: opts.gotResponse ?? true,
|
||||
errorCode: 0,
|
||||
errorMessage: opts.errorMessage ?? '',
|
||||
responseData:
|
||||
opts.responseData !== undefined
|
||||
? opts.responseData
|
||||
: Buffer.from(
|
||||
protobuf_encode<FaceroamOpResp>(
|
||||
opts.faces ?? { retCode: 0, item: { faceIds: [EMOJI_A, EMOJI_B] } },
|
||||
),
|
||||
),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => result) };
|
||||
}
|
||||
|
||||
describe('FetchCustomFaceList namespace', () => {
|
||||
describe('serialize + encode', () => {
|
||||
it('keeps the list request bytes the ProfileApi fetch already sent', () => {
|
||||
const req = FetchCustomFaceList.serialize({} as any, { uin: SAMPLE_UIN });
|
||||
const hex = Buffer.from(FetchCustomFaceList.encode(req)).toString('hex');
|
||||
expect(hex).toBe(Buffer.from(encodeLegacyList(SAMPLE_UIN)).toString('hex'));
|
||||
});
|
||||
|
||||
it('sends qqVersion on inner and sets field3=1 / field6=1', () => {
|
||||
const req = FetchCustomFaceList.serialize({} as any, { uin: SAMPLE_UIN });
|
||||
expect(req.inner?.qqVersion).toBe('9.9.28-46928');
|
||||
expect(req.inner?.osVersion).toBe('10.0.26200');
|
||||
expect(req.field3).toBe(1);
|
||||
expect(req.field6).toBe(1);
|
||||
expect(req.body).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke', () => {
|
||||
it('routes through "Faceroam.OpReq" and returns the id list', async () => {
|
||||
const sender = makeSender();
|
||||
await expect(FetchCustomFaceList.invoke(sender, { uin: SAMPLE_UIN })).resolves.toEqual([
|
||||
EMOJI_A,
|
||||
EMOJI_B,
|
||||
]);
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('Faceroam.OpReq');
|
||||
const [, body] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(Buffer.from(body as Uint8Array).toString('hex')).toBe(
|
||||
Buffer.from(encodeLegacyList(SAMPLE_UIN)).toString('hex'),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty list when the item is missing', async () => {
|
||||
const sender = makeSender({ faces: { retCode: 0 } });
|
||||
await expect(FetchCustomFaceList.invoke(sender, { uin: SAMPLE_UIN })).resolves.toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the sender reports no response', async () => {
|
||||
const sender = makeSender({
|
||||
success: false,
|
||||
gotResponse: false,
|
||||
errorMessage: 'timeout',
|
||||
responseData: null,
|
||||
});
|
||||
await expect(FetchCustomFaceList.invoke(sender, { uin: SAMPLE_UIN })).rejects.toThrow(
|
||||
/timeout/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the Faceroam retCode is non-zero', async () => {
|
||||
const sender = makeSender({ faces: { retCode: 1, message: 'denied' } });
|
||||
await expect(FetchCustomFaceList.invoke(sender, { uin: SAMPLE_UIN })).rejects.toThrow(
|
||||
/fetch custom face error: denied/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { CustomFaceModifyBody } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { ModifyCustomFace } from '../../../src/oidb-services/custom-face/modify-custom-face';
|
||||
|
||||
const SAMPLE_EMOJI_ID = '2550419068_0_0_0_ABCDEF0123456789ABCDEF0123456789_0_0';
|
||||
const SAMPLE_MD5 = 'ABCDEF0123456789ABCDEF0123456789';
|
||||
|
||||
// Independent byte-oracle: hand-decoded from the proto layout (NOT re-derived
|
||||
// from the encoder), so a field-number change goes red. Cross-checked against
|
||||
// the 9.9.26-44343 frida capture (cmd 0x902e_1, opType 3):
|
||||
// 08 aea002 f1 command = 0x902e
|
||||
// 10 01 f2 subCommand = 1
|
||||
// 22 73 f4 body (115B):
|
||||
// 08 01 f1 = 1
|
||||
// 12 0a "10.0.26200" f2 osVersion
|
||||
// 18 03 f3 opType = 3
|
||||
// 2a 5f f5 entry (95B):
|
||||
// 0a 59 f1 emoji (89B):
|
||||
// 0a 35 <emoji_id> f1
|
||||
// 12 20 <md5> f2
|
||||
// 12 02 "hi" f2 desc
|
||||
// 60 01 f12 = 1
|
||||
const MODIFY_WIRE_HEX =
|
||||
'08aea002100122730801120a31302e302e323632303018032a5f0a590a35323535303431393036385f305f305f305f41424344454630313233343536373839414243444546303132333435363738395f305f3012204142434445463031323334353637383941424344454630313233343536373839120268696001';
|
||||
|
||||
function makeSender() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('ModifyCustomFace namespace', () => {
|
||||
it('declares command 0x902e sub 1', () => {
|
||||
expect(ModifyCustomFace.command).toBe(0x902e);
|
||||
expect(ModifyCustomFace.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
it('serializes opType=3 with emoji {emojiId,md5} + desc in f5.entry, f12=1', () => {
|
||||
const out = ModifyCustomFace.serialize({} as any, {
|
||||
emojiId: SAMPLE_EMOJI_ID,
|
||||
md5: SAMPLE_MD5,
|
||||
desc: 'hi',
|
||||
});
|
||||
expect(out.opType).toBe(3);
|
||||
expect(out.entry?.emoji).toMatchObject({ emojiId: SAMPLE_EMOJI_ID, md5: SAMPLE_MD5 });
|
||||
expect(out.entry?.desc).toBe('hi');
|
||||
expect(out.field12).toBe(1);
|
||||
});
|
||||
|
||||
it('routes to OidbSvcTrpcTcp.0x902e_1 and encodes the exact wire bytes', async () => {
|
||||
const sender = makeSender();
|
||||
await ModifyCustomFace.invoke(sender, {
|
||||
emojiId: SAMPLE_EMOJI_ID,
|
||||
md5: SAMPLE_MD5,
|
||||
desc: 'hi',
|
||||
});
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x902e_1');
|
||||
expect(Buffer.from(bytes as Uint8Array).toString('hex')).toBe(MODIFY_WIRE_HEX);
|
||||
// Structure decodes back.
|
||||
const env = protobuf_decode<OidbBase<CustomFaceModifyBody>>(bytes as Uint8Array);
|
||||
expect(env.command).toBe(0x902e);
|
||||
expect(env.body?.opType).toBe(3);
|
||||
expect(env.body?.entry?.desc).toBe('hi');
|
||||
});
|
||||
|
||||
it('resolves on success (retCode 0 / empty body)', async () => {
|
||||
const sender = makeSender();
|
||||
await expect(
|
||||
ModifyCustomFace.invoke(sender, {
|
||||
emojiId: SAMPLE_EMOJI_ID,
|
||||
md5: SAMPLE_MD5,
|
||||
desc: '',
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when the business body carries a non-zero retCode', () => {
|
||||
expect(() =>
|
||||
ModifyCustomFace.deserialize({} as any, { retCode: 5, errMsg: 'bad' }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { CustomFaceMoveBody } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { MoveCustomFace } from '../../../src/oidb-services/custom-face/move-custom-face';
|
||||
|
||||
const EMOJI_A_ID = '2550419068_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0';
|
||||
const EMOJI_A_MD5 = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||
const EMOJI_B_ID = '2550419068_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0';
|
||||
const EMOJI_B_MD5 = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';
|
||||
|
||||
// Byte-oracle hand-decoded from the proto layout. Cross-checked against the
|
||||
// 9.9.26-44343 frida capture (fav25/fav27 ENC#3, the "upload new order" step
|
||||
// of the two-step move — step 2, must be preceded by OrderCustomFace 0x902f):
|
||||
// 08 aea002 f1 command = 0x902e ← move step 2: 0x902e opType=2
|
||||
// 10 01 f2 subCommand = 1
|
||||
// 22 <len> f4 body:
|
||||
// 08 01 f1 = 1
|
||||
// 12 0a "10.0.26200" f2 osVersion
|
||||
// 18 02 f3 opType = 2 ← move upload
|
||||
// 22 59 <emoji A> f4 repeated: {0a 35 emojiId, 12 20 md5}
|
||||
// 22 59 <emoji B> f4 repeated
|
||||
// 60 01 f12 = 1 ← envelope reserved (uinForm=true); without it the
|
||||
// server accepts the request but does NOT reorder.
|
||||
const MOVE_WIRE_HEX =
|
||||
'08aea002100122c6010801120a31302e302e3236323030180222590a35323535303431393036385f305f305f305f41414141414141414141414141414141414141414141414141414141414141415f305f301220414141414141414141414141414141414141414141414141414141414141414122590a35323535303431393036385f305f305f305f42424242424242424242424242424242424242424242424242424242424242425f305f30122042424242424242424242424242424242424242424242424242424242424242426001';
|
||||
|
||||
function makeSender() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('MoveCustomFace namespace', () => {
|
||||
it('declares command 0x902e sub 1 with uinForm (envelope reserved=1)', () => {
|
||||
expect(MoveCustomFace.command).toBe(0x902e);
|
||||
expect(MoveCustomFace.subCommand).toBe(1);
|
||||
expect(MoveCustomFace.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes opType=2 with the emoji list in f4 (repeated, in order)', () => {
|
||||
const out = MoveCustomFace.serialize({} as any, {
|
||||
emojis: [
|
||||
{ emojiId: EMOJI_A_ID, md5: EMOJI_A_MD5 },
|
||||
{ emojiId: EMOJI_B_ID, md5: EMOJI_B_MD5 },
|
||||
],
|
||||
});
|
||||
expect(out.opType).toBe(2);
|
||||
expect(out.emojis?.map((e) => e.emojiId)).toEqual([EMOJI_A_ID, EMOJI_B_ID]);
|
||||
});
|
||||
|
||||
it('routes to OidbSvcTrpcTcp.0x902e_1 and encodes the exact wire bytes (incl. envelope f12)', async () => {
|
||||
const sender = makeSender();
|
||||
await MoveCustomFace.invoke(sender, {
|
||||
emojis: [
|
||||
{ emojiId: EMOJI_A_ID, md5: EMOJI_A_MD5 },
|
||||
{ emojiId: EMOJI_B_ID, md5: EMOJI_B_MD5 },
|
||||
],
|
||||
});
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x902e_1');
|
||||
expect(Buffer.from(bytes as Uint8Array).toString('hex')).toBe(MOVE_WIRE_HEX);
|
||||
const env = protobuf_decode<OidbBase<CustomFaceMoveBody>>(bytes as Uint8Array);
|
||||
expect(env.command).toBe(0x902e);
|
||||
expect(env.reserved).toBe(1);
|
||||
expect(env.body?.opType).toBe(2);
|
||||
expect(env.body?.emojis?.map((e) => e.emojiId)).toEqual([EMOJI_A_ID, EMOJI_B_ID]);
|
||||
});
|
||||
|
||||
it('resolves on success (retCode 0 / empty body)', async () => {
|
||||
const sender = makeSender();
|
||||
await expect(
|
||||
MoveCustomFace.invoke(sender, { emojis: [{ emojiId: EMOJI_A_ID, md5: EMOJI_A_MD5 }] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when the business body carries a non-zero retCode', () => {
|
||||
expect(() =>
|
||||
MoveCustomFace.deserialize({} as any, { retCode: 5, errMsg: 'bad' }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { CustomFaceOrderBody } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { OrderCustomFace } from '../../../src/oidb-services/custom-face/order-custom-face';
|
||||
|
||||
const SAMPLE_EMOJI_ID = '2550419068_0_0_0_3BC06CE836D6134326EA2C1B37BF1584_0_0';
|
||||
|
||||
// Byte-oracle taken DIRECTLY from the 9.9.26-44343 frida capture (fav27/fav28
|
||||
// ENC#2, the real "move to front" instruction — the first of the two-step move).
|
||||
//
|
||||
// 08 afa002 f1 command = 0x902f ← move instruction cmd
|
||||
// 10 01 f2 subCommand = 1
|
||||
// 22 52 f4 body (82B):
|
||||
// 0a 17 f1 env (23B):
|
||||
// 08 8008 f1 = 1024 (client type flag)
|
||||
// 12 0a "10.0.26200" f2 osVersion
|
||||
// 1a 06 "9.9.26" f3 buildVersion (short)
|
||||
// 12 35 <emoji_id> f2 emojiId
|
||||
// 18 01 f3 position = 1 (front)
|
||||
// 60 01 f12 = 1 ← envelope reserved (uinForm)
|
||||
const ORDER_WIRE_HEX =
|
||||
'08afa002100122520a17088008120a31302e302e32363230301a06392e392e32361235323535303431393036385f305f305f305f33424330364345383336443631333433323645413243314233374246313538345f305f3018016001';
|
||||
|
||||
function makeSender() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('OrderCustomFace namespace', () => {
|
||||
it('declares command 0x902f sub 1 with uinForm (envelope reserved=1)', () => {
|
||||
expect(OrderCustomFace.command).toBe(0x902f);
|
||||
expect(OrderCustomFace.subCommand).toBe(1);
|
||||
expect(OrderCustomFace.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes env{1024, osVersion, "9.9.26"} + emojiId + position', () => {
|
||||
const out = OrderCustomFace.serialize({} as any, { emojiId: SAMPLE_EMOJI_ID, position: 1 });
|
||||
expect(out.env).toMatchObject({
|
||||
field1: 1024,
|
||||
osVersion: '10.0.26200',
|
||||
buildVersion: '9.9.26',
|
||||
});
|
||||
expect(out.emojiId).toBe(SAMPLE_EMOJI_ID);
|
||||
expect(out.position).toBe(1);
|
||||
});
|
||||
|
||||
it('routes to OidbSvcTrpcTcp.0x902f_1 and reproduces the captured wire exactly', async () => {
|
||||
const sender = makeSender();
|
||||
await OrderCustomFace.invoke(sender, { emojiId: SAMPLE_EMOJI_ID, position: 1 });
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x902f_1');
|
||||
expect(Buffer.from(bytes as Uint8Array).toString('hex')).toBe(ORDER_WIRE_HEX);
|
||||
const env = protobuf_decode<OidbBase<CustomFaceOrderBody>>(bytes as Uint8Array);
|
||||
expect(env.command).toBe(0x902f);
|
||||
expect(env.reserved).toBe(1);
|
||||
expect(env.body?.emojiId).toBe(SAMPLE_EMOJI_ID);
|
||||
expect(env.body?.position).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves on success (retCode 0 / empty body)', async () => {
|
||||
const sender = makeSender();
|
||||
await expect(
|
||||
OrderCustomFace.invoke(sender, { emojiId: SAMPLE_EMOJI_ID, position: 1 }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when the business body carries a non-zero retCode', () => {
|
||||
expect(() =>
|
||||
OrderCustomFace.deserialize({} as any, { retCode: 5, errMsg: 'bad' }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
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 {
|
||||
OidbAiVoiceListReq,
|
||||
OidbAiVoiceListResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/media';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchAiVoiceList } from '../../../src/oidb-services/extras/fetch-ai-voice-list';
|
||||
|
||||
function makeSender(body?: OidbAiVoiceListResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbAiVoiceListResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchAiVoiceList namespace', () => {
|
||||
it('declares 0x929D_0', () => {
|
||||
expect(FetchAiVoiceList.command).toBe(0x929d);
|
||||
expect(FetchAiVoiceList.subCommand).toBe(0);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('passes groupUin / chatType verbatim', () => {
|
||||
expect(FetchAiVoiceList.serialize({} as any, { groupId: 4242, chatType: 1 })).toEqual({
|
||||
groupUin: 4242,
|
||||
chatType: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('returns content array verbatim', () => {
|
||||
const out = FetchAiVoiceList.deserialize({} as any, {
|
||||
content: [
|
||||
{
|
||||
category: 'cute',
|
||||
voices: [
|
||||
{ voiceId: 'v1', voiceDisplayName: 'V1', voiceExampleUrl: 'http://x' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out).toEqual([
|
||||
{
|
||||
category: 'cute',
|
||||
voices: [
|
||||
{ voiceId: 'v1', voiceDisplayName: 'V1', voiceExampleUrl: 'http://x' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns [] when content is omitted', () => {
|
||||
expect(FetchAiVoiceList.deserialize({} as any, {})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0x929d_0', async () => {
|
||||
const sender = makeSender({ content: [] });
|
||||
await FetchAiVoiceList.invoke(sender, { groupId: 100, chatType: 1 });
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x929d_0');
|
||||
});
|
||||
|
||||
it('encodes envelope body correctly', async () => {
|
||||
const sender = makeSender({ content: [] });
|
||||
await FetchAiVoiceList.invoke(sender, { groupId: 4242, chatType: 2 });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbAiVoiceListReq>>(bytes);
|
||||
expect(env.body).toMatchObject({ groupUin: 4242, chatType: 2 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
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 { OidbAiVoiceReq, OidbAiVoiceResp } from '@snowluma/proto-defs/oidb-actions/media';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchAiVoice } from '../../../src/oidb-services/extras/fetch-ai-voice';
|
||||
|
||||
function makeSender(body?: OidbAiVoiceResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbAiVoiceResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchAiVoice namespace', () => {
|
||||
it('declares 0x929B_0', () => {
|
||||
expect(FetchAiVoice.command).toBe(0x929b);
|
||||
expect(FetchAiVoice.subCommand).toBe(0);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('packages every field including sessionId into session sub-message', () => {
|
||||
const out = FetchAiVoice.serialize({} as any, {
|
||||
groupId: 100,
|
||||
voiceId: 'v',
|
||||
text: 'hi',
|
||||
chatType: 1,
|
||||
sessionId: 0xcafe,
|
||||
});
|
||||
expect(out).toEqual({
|
||||
groupUin: 100,
|
||||
voiceId: 'v',
|
||||
text: 'hi',
|
||||
chatType: 1,
|
||||
session: { sessionId: 0xcafe },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('returns the first msgInfoBody index', () => {
|
||||
const node = { fileUuid: 'uuid', subType: 1 };
|
||||
expect(
|
||||
FetchAiVoice.deserialize(
|
||||
{} as any,
|
||||
{
|
||||
field2: 319,
|
||||
field3: 20,
|
||||
msgInfo: { msgInfoBody: [{ index: node }] },
|
||||
} as any,
|
||||
),
|
||||
).toEqual(node);
|
||||
});
|
||||
|
||||
it('returns null when msgInfo is empty / missing', () => {
|
||||
expect(FetchAiVoice.deserialize({} as any, {} as any)).toBeNull();
|
||||
expect(FetchAiVoice.deserialize({} as any, { msgInfo: {} } as any)).toBeNull();
|
||||
expect(
|
||||
FetchAiVoice.deserialize({} as any, { msgInfo: { msgInfoBody: [] } } as any),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0x929b_0', async () => {
|
||||
const sender = makeSender({
|
||||
msgInfo: { msgInfoBody: [{ index: { fileUuid: 'u' } }] },
|
||||
} as any);
|
||||
await FetchAiVoice.invoke(sender, {
|
||||
groupId: 1,
|
||||
voiceId: 'v',
|
||||
text: 't',
|
||||
chatType: 1,
|
||||
sessionId: 1,
|
||||
});
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x929b_0');
|
||||
});
|
||||
|
||||
it('returns the parsed media node', async () => {
|
||||
const node = { fileUuid: 'uuid', subType: 1 };
|
||||
const sender = makeSender({
|
||||
field2: 319,
|
||||
field3: 20,
|
||||
msgInfo: { msgInfoBody: [{ index: node }] },
|
||||
} as any);
|
||||
const out = await FetchAiVoice.invoke(sender, {
|
||||
groupId: 1,
|
||||
voiceId: 'v',
|
||||
text: 't',
|
||||
chatType: 1,
|
||||
sessionId: 1,
|
||||
});
|
||||
expect(out).toMatchObject(node);
|
||||
});
|
||||
|
||||
it('returns null when server polls back empty msgInfo (in-flight)', async () => {
|
||||
const sender = makeSender({} as any);
|
||||
const out = await FetchAiVoice.invoke(sender, {
|
||||
groupId: 1,
|
||||
voiceId: 'v',
|
||||
text: 't',
|
||||
chatType: 1,
|
||||
sessionId: 1,
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('passes the sessionId through to the wire envelope verbatim', async () => {
|
||||
const sender = makeSender({} as any);
|
||||
await FetchAiVoice.invoke(sender, {
|
||||
groupId: 1,
|
||||
voiceId: 'v',
|
||||
text: 't',
|
||||
chatType: 1,
|
||||
sessionId: 0xcafebabe,
|
||||
});
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbAiVoiceReq>>(bytes);
|
||||
expect(env.body?.session?.sessionId).toBe(0xcafebabe);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbQueryGroupTopBannersReq } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
import { GetGroupTodoList } from '../../../src/oidb-services/extras/get-group-todo-list';
|
||||
|
||||
// Fixed wire fixture shaped after a live QQ 3.2.32-51802 0x9474_0 response.
|
||||
// It deliberately uses the current common-banner representation rather than
|
||||
// constructing the response with the production protobuf encoder.
|
||||
const ACTIVE_TODO_RESPONSE = Buffer.from(
|
||||
'08f4a802100018002291010a8e01080f10021a06353633315f3040d09ba8bb06' +
|
||||
'a2016a0a3f0a1d68747470733a2f2f6578616d706c652e746573742f746f646f' +
|
||||
'2e706e67120ce7bea4e5be85e58a9eefbd9c1a0ce6b58be8af95e5be85e58a9e' +
|
||||
'28023003121b08031a177b22736571223a353633312c2272616e646f6d223a307d' +
|
||||
'18c0ffa7bb0620c1ffa7bb06c802a29c01d002c801e80201f002012a006000',
|
||||
'hex',
|
||||
);
|
||||
|
||||
function makeSender(responseData = Buffer.alloc(0)) {
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => result) };
|
||||
}
|
||||
|
||||
describe('GetGroupTodoList namespace', () => {
|
||||
it('queries 0x9474_0 with the todo-only banner flag', async () => {
|
||||
const sender = makeSender();
|
||||
|
||||
await GetGroupTodoList.invoke(sender, { groupId: 941657197 });
|
||||
|
||||
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0x9474_0');
|
||||
expect(GetGroupTodoList.subCommand).toBe(0);
|
||||
const envelope = protobuf_decode<OidbBase<OidbQueryGroupTopBannersReq>>(bytes);
|
||||
expect(envelope).toMatchObject({
|
||||
command: 0x9474,
|
||||
body: { groupId: 941657197n, bannerFlag: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes the current common-banner todo and validates both identity sources', async () => {
|
||||
const sender = makeSender(ACTIVE_TODO_RESPONSE);
|
||||
|
||||
await expect(GetGroupTodoList.invoke(sender, { groupId: 941657197 })).resolves.toEqual([
|
||||
{
|
||||
sourceId: '5631_0',
|
||||
sequence: 5631,
|
||||
random: 0,
|
||||
text: '测试待办',
|
||||
createdAt: 1735000000,
|
||||
updatedAt: 1735000001,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters disappeared todo tombstones', () => {
|
||||
expect(
|
||||
GetGroupTodoList.deserialize({} as never, {
|
||||
banners: [{ bizType: 15, bannerType: 2, isDisappear: true, bizId: 20002 }],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('supports the legacy todo text shape without inventing timestamps', () => {
|
||||
expect(
|
||||
GetGroupTodoList.deserialize({} as never, {
|
||||
banners: [
|
||||
{
|
||||
msgId: Buffer.from('88_0'),
|
||||
isDisappear: false,
|
||||
todoBanner: { text: '旧版待办' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sourceId: '88_0',
|
||||
sequence: 88,
|
||||
random: 0,
|
||||
text: '旧版待办',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an active todo whose message identity cannot be mapped', () => {
|
||||
expect(() =>
|
||||
GetGroupTodoList.deserialize({} as never, {
|
||||
banners: [
|
||||
{
|
||||
bizType: 15,
|
||||
bannerType: 2,
|
||||
msgId: Buffer.from('not-a-message-id'),
|
||||
isDisappear: false,
|
||||
bizId: 20002,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(/invalid group todo message identity/);
|
||||
});
|
||||
|
||||
it('rejects disagreement between msgId and the jump payload', () => {
|
||||
expect(() =>
|
||||
GetGroupTodoList.deserialize({} as never, {
|
||||
banners: [
|
||||
{
|
||||
bizType: 15,
|
||||
bannerType: 2,
|
||||
msgId: Buffer.from('5631_0'),
|
||||
isDisappear: false,
|
||||
bizId: 20002,
|
||||
commonBanner: {
|
||||
jumpInfo: { jumpParam: Buffer.from('{"seq":5632,"random":0}') },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(/identity mismatch/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
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 } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
OidbStrangerStatusReq,
|
||||
OidbStrangerStatusResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { GetStrangerStatus } from '../../../src/oidb-services/extras/get-stranger-status';
|
||||
|
||||
interface ObservedStatusProperty {
|
||||
key?: pb<1, uint_32>;
|
||||
value?: pb<2, uint_64>;
|
||||
}
|
||||
|
||||
interface ObservedStatusProperties {
|
||||
entries?: pb_repeated<1, ObservedStatusProperty>;
|
||||
}
|
||||
|
||||
interface ObservedStatusData {
|
||||
targetUin?: pb<1, uint_32>;
|
||||
properties?: pb<2, ObservedStatusProperties>;
|
||||
uin?: pb<3, uint_32>;
|
||||
}
|
||||
|
||||
interface ObservedStatusResponse {
|
||||
data?: pb<1, ObservedStatusData>;
|
||||
}
|
||||
|
||||
function makeSender(body?: OidbStrangerStatusResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbStrangerStatusResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('GetStrangerStatus namespace', () => {
|
||||
it('declares 0xFE1_2 with uinForm=true', () => {
|
||||
expect(GetStrangerStatus.command).toBe(0xfe1);
|
||||
expect(GetStrangerStatus.subCommand).toBe(2);
|
||||
expect(GetStrangerStatus.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('always queries only key 27372 (status property)', () => {
|
||||
const out = GetStrangerStatus.serialize({} as any, { uin: 100200 });
|
||||
expect(out).toEqual({ uin: 100200, key: [{ key: 27372 }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('low-band (≤10) values map to (value*10, 0)', () => {
|
||||
expect(
|
||||
GetStrangerStatus.deserialize({} as any, {
|
||||
data: { properties: { entries: [{ key: 27372, value: 7n }] } },
|
||||
}),
|
||||
).toEqual({ status: 70, ext_status: 0 });
|
||||
expect(
|
||||
GetStrangerStatus.deserialize({} as any, {
|
||||
data: { properties: { entries: [{ key: 27372, value: 10n }] } },
|
||||
}),
|
||||
).toEqual({ status: 100, ext_status: 0 });
|
||||
});
|
||||
|
||||
it('high-band values decompose into (0xff00 + (>>16 & 0xff)) ext_status', () => {
|
||||
// 0x42F100 → bits 8..15 = 0xF1<<8 = 0xF100, bits 16..23 = 0x42 → 0xF142
|
||||
expect(
|
||||
GetStrangerStatus.deserialize({} as any, {
|
||||
data: { properties: { entries: [{ key: 27372, value: 0x42f100n }] } },
|
||||
}),
|
||||
).toEqual({ status: 10, ext_status: 0xf142 });
|
||||
});
|
||||
|
||||
it('returns null when the requested status property is missing', () => {
|
||||
expect(GetStrangerStatus.deserialize({} as any, {})).toBeNull();
|
||||
expect(GetStrangerStatus.deserialize({} as any, { data: {} })).toBeNull();
|
||||
expect(
|
||||
GetStrangerStatus.deserialize({} as any, { data: { properties: {} } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
GetStrangerStatus.deserialize({} as any, {
|
||||
data: { properties: { entries: [{ key: 10000, value: 7n }] } },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('decodes the property-list hierarchy returned by 0xFE1_2', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<ObservedStatusResponse>>({
|
||||
body: {
|
||||
data: {
|
||||
targetUin: 100200,
|
||||
properties: {
|
||||
entries: [
|
||||
{ key: 10000, value: 1n },
|
||||
{ key: 27372, value: 4n },
|
||||
],
|
||||
},
|
||||
uin: 100200,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
const sender = { sendRawPacket: vi.fn(async () => result) };
|
||||
|
||||
await expect(GetStrangerStatus.invoke(sender, { uin: 100200 })).resolves.toEqual({
|
||||
status: 40,
|
||||
ext_status: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('routes to OidbSvcTrpcTcp.0xfe1_2 with uinForm=true (envelope reserved=1)', async () => {
|
||||
const sender = makeSender({
|
||||
data: { properties: { entries: [{ key: 27372, value: 5n }] } },
|
||||
});
|
||||
await GetStrangerStatus.invoke(sender, { uin: 100200 });
|
||||
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0xfe1_2');
|
||||
const env = protobuf_decode<OidbBase<OidbStrangerStatusReq>>(bytes);
|
||||
expect(env.reserved).toBe(1);
|
||||
});
|
||||
|
||||
it('returns the decoded status object', async () => {
|
||||
const sender = makeSender({
|
||||
data: { properties: { entries: [{ key: 27372, value: 5n }] } },
|
||||
});
|
||||
const out = await GetStrangerStatus.invoke(sender, { uin: 1 });
|
||||
expect(out).toEqual({ status: 50, ext_status: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbGroupTodo } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { GroupTodo } from '../../../src/oidb-services/extras/group-todo';
|
||||
|
||||
function makeSender() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('GroupTodo namespace', () => {
|
||||
it('declares 0xF90', () => {
|
||||
expect(GroupTodo.command).toBe(0xf90);
|
||||
});
|
||||
|
||||
describe('resolveSubCommand', () => {
|
||||
it('maps set → 1', () => {
|
||||
expect(GroupTodo.resolveSubCommand({ groupId: 1, msgSeq: 1n, action: 'set' })).toBe(1);
|
||||
});
|
||||
it('maps complete → 2', () => {
|
||||
expect(
|
||||
GroupTodo.resolveSubCommand({ groupId: 1, msgSeq: 1n, action: 'complete' }),
|
||||
).toBe(2);
|
||||
});
|
||||
it('maps cancel → 3', () => {
|
||||
expect(GroupTodo.resolveSubCommand({ groupId: 1, msgSeq: 1n, action: 'cancel' })).toBe(
|
||||
3,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('packages groupUin + msgSeq verbatim regardless of action', () => {
|
||||
for (const action of ['set', 'complete', 'cancel'] as const) {
|
||||
const out = GroupTodo.serialize({} as any, {
|
||||
groupId: 12345,
|
||||
msgSeq: 9876543210n,
|
||||
action,
|
||||
});
|
||||
expect(out).toEqual({ groupUin: 12345, msgSeq: 9876543210n });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it.each([
|
||||
['set', 'OidbSvcTrpcTcp.0xf90_1'] as const,
|
||||
['complete', 'OidbSvcTrpcTcp.0xf90_2'] as const,
|
||||
['cancel', 'OidbSvcTrpcTcp.0xf90_3'] as const,
|
||||
])('action %s routes to %s', async (action, expectedWire) => {
|
||||
const sender = makeSender();
|
||||
await GroupTodo.invoke(sender, { groupId: 12345, msgSeq: 100n, action });
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe(expectedWire);
|
||||
});
|
||||
|
||||
it('encodes envelope body correctly', async () => {
|
||||
const sender = makeSender();
|
||||
await GroupTodo.invoke(sender, { groupId: 100, msgSeq: 999n, action: 'set' });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupTodo>>(bytes);
|
||||
expect(env.body).toEqual({ groupUin: 100, msgSeq: 999n });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { protobuf_encode, protobuf_decode } from '@snowluma/proton';
|
||||
import type { FlashApplyUploadReq } from '@snowluma/proto-defs/oidb-actions/flash-transfer';
|
||||
|
||||
// ENC#8: 0x12a9 sub=103 apply-upload 真实客户端请求 body(frida 抓包,QQ 9.9.26-44343,
|
||||
// 2026-06-19)。用 ENC#8 的精确值手工构造 req,encode 后必须 byte-for-byte 匹配 ENC#8 hex。
|
||||
// 这是真正的 byte-oracle:字段号/wire type/0值显式编码任一错误都会 hex 不等。
|
||||
const ENC8_HEX =
|
||||
'0a190a0508ac011067120ca80602b00604b80616c00c051a020801629d030a81020a870108b4cdcc01122063396631303334663062303431653933353339653937663839356363333139621a28306636326234663763646463373965326662666431343065646531326431633434646462373937612222e5b18fe5b995e5bd95e588b620323032352d30312d3135203030323133302e6d70342a0808001000180020003000380040004801126745685150597254337a64783534767639464137654574484554647435656869307a637742494c56304b4b4b516938796a6b3555444d675277636d396b5549447153566f51496f38546b56454c72776f454c5746544c6a433268586f445947505f676745435a336f180120fae4d4d1062880ea493000120208021a0808001000180022005288010a2438363866633433312d336361372d346130632d383538322d666135656365613062666134122438363866633433312d336361372d346130632d383538322d6661356563656130626661341a2436656639316166322d363830302d373633632d313936642d38353231303537353536343020012800300138024200480150005800600068007000';
|
||||
|
||||
// ENC#8 抓包值(sub=103 mp4)。
|
||||
const ENC8_REQ: FlashApplyUploadReq = {
|
||||
head: {
|
||||
sub: { seq: 172, sub: 103 },
|
||||
config: { field101: 2, field102: 4, field103: 22, field200: 5 },
|
||||
field3: { field1: 1 },
|
||||
},
|
||||
payload: {
|
||||
wrapper: {
|
||||
fileInfo: {
|
||||
fileSize: 3352244,
|
||||
md5: 'c9f1034f0b041e93539e97f895cc319b',
|
||||
sha1: '0f62b4f7cddc79e2fbfd140ede12d1c44ddb797a',
|
||||
fileName: '屏幕录制 2025-01-15 002130.mp4',
|
||||
field5: { field1: 0, field2: 0, field3: 0, field4: 0 },
|
||||
field6: 0,
|
||||
field7: 0,
|
||||
field8: 0,
|
||||
field9: 1,
|
||||
},
|
||||
fileId: 'EhQPYrT3zdx54vv9FA7eEtHETdt5ehi0zcwBILV0KKKQi8yjk5UDMgRwcm9kUIDqSVoQIo8TkVELrwoELWFTLjC2hXoDYGP_ggECZ3o',
|
||||
field3: 1,
|
||||
field4: 1781871226,
|
||||
field5: 1209600,
|
||||
field6: 0,
|
||||
},
|
||||
flag2: { field1: 2 },
|
||||
field3: { field1: 0, field2: 0, field3: 0, field4: {} },
|
||||
filesetWrap: {
|
||||
filesetUuid: '868fc431-3ca7-4a0c-8582-fa5ecea0bfa4',
|
||||
uploadKey: '868fc431-3ca7-4a0c-8582-fa5ecea0bfa4',
|
||||
fileUuid: '6ef91af2-6800-763c-196d-852105755640',
|
||||
field4: 1,
|
||||
field5: 0,
|
||||
field6: 1,
|
||||
field7: 2,
|
||||
field8: {},
|
||||
field9: 1,
|
||||
field10: 0,
|
||||
field11: 0,
|
||||
field12: 0,
|
||||
field13: 0,
|
||||
field14: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('ApplyUpload byte-oracle (0x12a9_103 encode == ENC#8)', () => {
|
||||
it('encodes the ENC#8 field values to the exact frida-captured wire bytes', () => {
|
||||
const out = Buffer.from(protobuf_encode<FlashApplyUploadReq>(ENC8_REQ)).toString('hex');
|
||||
|
||||
if (out !== ENC8_HEX) {
|
||||
// 定位首个差异字节
|
||||
const n = Math.min(out.length, ENC8_HEX.length);
|
||||
let i = 0;
|
||||
|
||||
while (i < n && out[i] === ENC8_HEX[i]) {
|
||||
i++;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`hex mismatch at char ${i} (byte ${i >> 1}):\n` +
|
||||
` bot: ...${out.slice(Math.max(0, i - 20), i + 20)}\n` +
|
||||
` ENC#8: ...${ENC8_HEX.slice(Math.max(0, i - 20), i + 20)}\n` +
|
||||
` bot len=${out.length / 2}B, ENC#8 len=${ENC8_HEX.length / 2}B`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(out).toBe(ENC8_HEX);
|
||||
});
|
||||
|
||||
it('round-trips: encode → decode preserves the field tree', () => {
|
||||
const bytes = protobuf_encode<FlashApplyUploadReq>(ENC8_REQ);
|
||||
const back = protobuf_decode<FlashApplyUploadReq>(bytes);
|
||||
expect(back.head?.sub?.sub).toBe(103);
|
||||
expect(back.head?.config?.field103).toBe(22);
|
||||
expect(back.payload?.wrapper?.fileInfo?.field9).toBe(1);
|
||||
expect(back.payload?.wrapper?.fileInfo?.field5?.field4).toBe(0);
|
||||
expect(back.payload?.filesetWrap?.fileUuid).toBe('6ef91af2-6800-763c-196d-852105755640');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { FlashApplyUploadReq } from '@snowluma/proto-defs/oidb-actions/flash-transfer';
|
||||
import { ApplyUpload } from '../../../src/oidb-services/flash-transfer/apply-upload';
|
||||
|
||||
// ENC#8: 0x12a9 sub=103 apply-upload 真实客户端请求 body(frida 抓包,QQ 9.9.26-44343,
|
||||
// 2026-06-19)。字段树逐字节解码见 flash_transfer_protocol_full.md §9.5。
|
||||
// 独立 byte-oracle:从 frida 抓的 hex decode 回来,字段号/wire type 错则值读不出。
|
||||
const ENC8_HEX =
|
||||
'0a190a0508ac011067120ca80602b00604b80616c00c051a020801629d030a81020a870108b4cdcc01122063396631303334663062303431653933353339653937663839356363333139621a28306636326234663763646463373965326662666431343065646531326431633434646462373937612222e5b18fe5b995e5bd95e588b620323032352d30312d3135203030323133302e6d70342a0808001000180020003000380040004801126745685150597254337a64783534767639464137654574484554647435656869307a637742494c56304b4b4b516938796a6b3555444d675277636d396b5549447153566f51496f38546b56454c72776f454c5746544c6a433268586f445947505f676745435a336f180120fae4d4d1062880ea493000120208021a0808001000180022005288010a2438363866633433312d336361372d346130632d383538322d666135656365613062666134122438363866633433312d336361372d346130632d383538322d6661356563656130626661341a2436656639316166322d363830302d373633632d313936642d38353231303537353536343020012800300138024200480150005800600068007000';
|
||||
|
||||
describe('ApplyUpload namespace (0x12a9_103)', () => {
|
||||
it('declares command 0x12a9 sub 103 uinForm=true', () => {
|
||||
expect(ApplyUpload.command).toBe(0x12a9);
|
||||
expect(ApplyUpload.subCommand).toBe(103);
|
||||
expect(ApplyUpload.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('decodes ENC#8 frida capture with the exact field tree', () => {
|
||||
const req = protobuf_decode<FlashApplyUploadReq>(Buffer.from(ENC8_HEX, 'hex'));
|
||||
|
||||
// head: { f1:{seq,sub}, f2:config{f101,f102,f103,f200}, f3:{f1:1} }
|
||||
expect(req.head?.sub?.seq).toBe(172);
|
||||
expect(req.head?.sub?.sub).toBe(103);
|
||||
expect(req.head?.config).toMatchObject({
|
||||
field101: 2,
|
||||
field102: 4,
|
||||
field103: 22,
|
||||
field200: 5,
|
||||
});
|
||||
expect(req.head?.field3?.field1).toBe(1);
|
||||
|
||||
// payload.wrapper.fileInfo(f12.f1.f1)
|
||||
const fi = req.payload?.wrapper?.fileInfo;
|
||||
expect(fi?.fileSize).toBe(3352244);
|
||||
expect(fi?.md5).toBe('c9f1034f0b041e93539e97f895cc319b');
|
||||
expect(fi?.sha1).toBe('0f62b4f7cddc79e2fbfd140ede12d1c44ddb797a');
|
||||
expect(fi?.fileName).toBe('屏幕录制 2025-01-15 002130.mp4');
|
||||
// field5 = {f1:0,f2:0,f3:0,f4:0},f4 是 varint(不是 message)
|
||||
expect(fi?.field5).toMatchObject({ field1: 0, field2: 0, field3: 0, field4: 0 });
|
||||
expect(fi?.field9).toBe(1);
|
||||
|
||||
// payload.wrapper(f12.f1):fileId + 时间戳 + TTL
|
||||
expect(req.payload?.wrapper?.fileId).toBe(
|
||||
'EhQPYrT3zdx54vv9FA7eEtHETdt5ehi0zcwBILV0KKKQi8yjk5UDMgRwcm9kUIDqSVoQIo8TkVELrwoELWFTLjC2hXoDYGP_ggECZ3o',
|
||||
);
|
||||
expect(req.payload?.wrapper?.field3).toBe(1);
|
||||
expect(req.payload?.wrapper?.field4).toBe(1781871226);
|
||||
expect(req.payload?.wrapper?.field5).toBe(1209600);
|
||||
|
||||
// payload.flag2(f12.f2 = {f1:2})
|
||||
expect(req.payload?.flag2?.field1).toBe(2);
|
||||
|
||||
// payload.field3(f12.f3 = {f1:0,f2:0,f3:0,f4:空message})
|
||||
expect(req.payload?.field3).toMatchObject({ field1: 0, field2: 0, field3: 0 });
|
||||
|
||||
// payload.filesetWrap(f12.f10 = filesetUuid + fileUuid + flags)
|
||||
const fw = req.payload?.filesetWrap;
|
||||
expect(fw?.filesetUuid).toBe('868fc431-3ca7-4a0c-8582-fa5ecea0bfa4');
|
||||
expect(fw?.uploadKey).toBe('868fc431-3ca7-4a0c-8582-fa5ecea0bfa4');
|
||||
expect(fw?.fileUuid).toBe('6ef91af2-6800-763c-196d-852105755640');
|
||||
expect(fw?.field4).toBe(1);
|
||||
expect(fw?.field6).toBe(1);
|
||||
expect(fw?.field7).toBe(2);
|
||||
expect(fw?.field9).toBe(1);
|
||||
});
|
||||
});
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { FlashGetDownloadUrlResp } from '@snowluma/proto-defs/oidb-actions/flash-transfer';
|
||||
import { GetDownloadUrl } from '@snowluma/protocol/oidb-services/flash-transfer/get-download-url';
|
||||
|
||||
// 0x93d4 真实响应(send_packet 抓于 QQ 9.9.26-44343, 2026-06-20, fileset e7453377 已发送)。
|
||||
// byte-oracle:decode + deserialize 必须从 f1.f3.f14.f1 提取到主文件 fileId。
|
||||
// 0x93d4 响应结构与 0x93d3 不同(downloadUrl 在深层嵌套,非 f9.fileIdWrap),这个测试
|
||||
// 锁定该结构,防止 deserialize 退回错误的 FlashFileEntry 解读。
|
||||
const RESP_HEX =
|
||||
'08d4a7021001180022b8090ab50910011afa080a2465373435333337372d633865612d343034652d393238352d353361613566616431393832122466613038663938332d323337662d303833632d656536332d6139646164336161616565352801300138024222e5b18fe5b995e5bd95e588b620323032352d30352d3033203135313234312e6d70344a2162696e676d756c757a686920323032352d30352d3033203135313234312e6d703458bcf7e3036ae9050a6645685330442d4c3261494b306b74314d51424f764f5377746465753574426a7939774567746e516f724c6d736d5961566c514d794248427962325251674f704a5768424d3744724a3276364843594c766576376b6f30587565674d4d61502d4341514a6e656712a804080212a30468747470733a2f2f6d756c74696d656469612e7166696c652e71712e636f6d2f646f776e6c6f61643f61707069643d313439303226636c69656e745f747970653d77696e26636c69656e745f7665723d392e322e33342666696c6569643d45685330442d4c3261494b306b74314d51424f764f5377746465753574426a7939774567746e516f724c6d736d5961566c514d794248427962325251674f704a5768424d3744724a3276364843594c766576376b6f30587565674d4d61502d4341514a6e656726666c64633d696358456459355a6b5346395f59347239514f526e58562d6a6b31536d397753546b4a316a6876334f357a5f747331484966612d4c6a7662416b52494531526c4373704a4776336c453469635434467437414a794c33577242304b78696a555f423270386a75367a3371314d26726b65793d43414d5371414878697a55514532647437517975726675552d78446d3568666a4d727073325a69723764535f42354d55694349644e594c495167792d5264776d495a797a584a6f31714a687834464d756b346a337042306a5f37787850315a7a6748706469315f3158376178422d736541556c5f3646546f4679597638564d5337645a577036414275597a3875517a79306c487574676a6762714271477138336d70417a6465426475555833513043732d426b54676952767768664b3835696661774a3874477371576e70784871394f444c762d5f37566a78526e424456587046314f306343551a286234306665326636363838326234393264643463343031336166333932633264373565626239623420f2f7012a20393365643461306234383164623936343335323034363339626366393133313830800438c002726b0a674568512d63526273544d5338304c79484637745658626c38536c5a4e63426938392d4d44494c56304b497154305a6d476c5a55444d675277636d396b5549447153566f5156637648434d53585865556b54726c384e555f4a41586f444c6b625f676745435a336f18027a2466613038663938332d323337662d303833632d656536332d613964616433616161656535900103a2012833653731313665633463633462636430626338373137626235353564623937633461353634643730a801bcf7e303b2010308d547ca0120346365613333363331386338303864646431646630613333633938316339316122327b226f6666736574223a31382c22637572736f725f74696d65223a22303030312d30312d30315430303a30303a30305a227d28012a005a1d0a0f636c69656e745f636f6e6e5f736571120a313738313932393033395a200a1a747270632d6f632d7072696f726974792d706173732d6261636b12022d315a1c0a0e73736f5f647965696e675f6b6579120a323535303431393036385a160a0c73736f5f636c69656e74696412063133333034375a9c040a08747270632d73736f128f04000000147061d586907300000ba05fe3000207b7000001fb0073c9510000000e3235353034313930363820071b1b000008040000000000000000000003000000004c668066ca67e24ac9a81bd08aa343840338fa2f632ec020158218d5d2a35f1a2ab856dcbdcc486af66c8ba961db5532e2a065d882604a01539050c7ad17748f95521b4dac5e1834160000001b4f696462537663547270635463702e3078393364345f31000000188e0800005f3a366a000000008d0b00000000000000000000000000002462376234353837623661333666373535346564353235613563396230303166660000000000000000000000040000011d1a007a91020a047061d58610081a06392e322e333420c19e0c4220633165633037346532323533303539633230303530333039653965643932666552005a10b7b4587b6a36f7554ed525a5c9b001ff62206237623435383762366133366637353534656435323561356339623030316666688008720757696e646f7773820118755f6c5253732d3243757359575a6a455851707045694d518a01009001019a015208081248668066ca67e24ac9a81bd08aa343840338fa2f632ec020158218d5d2a35f1a2ab856dcbdcc486af66c8ba961db5532e2a065d882604a01539050c7ad17748f95521b4dac5e18341618c4acf8fa05a00100b80165c00100d201174f696462537663547270635463702e3078393364345f3190010d';
|
||||
|
||||
describe('GetDownloadUrl byte-oracle (0x93d4 deserialize)', () => {
|
||||
it('extracts main file fileId from f1.f3.f14.f1', () => {
|
||||
const bytes = Uint8Array.from(Buffer.from(RESP_HEX, 'hex'));
|
||||
const env = protobuf_decode<OidbBase<FlashGetDownloadUrlResp>>(bytes);
|
||||
const result = GetDownloadUrl.deserialize(null as never, env.body!);
|
||||
// 0x93d4 f1.f3 是 repeated fileInfo,单文件场景返回一个元素的数组。
|
||||
expect(result).toHaveLength(1);
|
||||
const meta = result[0];
|
||||
// 主文件 fileId(0x93d4 f14.f1),用于 0x12a9 sub=200 拿主文件下载直链。
|
||||
expect(meta.fileId).toBe(
|
||||
'EhQ-cRbsTMS80LyHF7tVXbl8SlZNcBi89-MDILV0KIqT0ZmGlZUDMgRwcm9kUIDqSVoQVcvHCMSXXeUkTrl8NU_JAXoDLkb_ggECZ3o',
|
||||
);
|
||||
expect(meta.filesetUuid).toBe('e7453377-c8ea-404e-9285-53aa5fad1982');
|
||||
expect(meta.fileName).toContain('mp4');
|
||||
expect(meta.fileSize).toBe(7928764);
|
||||
expect(meta.fileIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { protobuf_encode, protobuf_decode } from '@snowluma/proton';
|
||||
import type { FlashPrepareUploadReq } from '@snowluma/proto-defs/oidb-actions/flash-transfer';
|
||||
|
||||
// 真实 sub=100 (mp4) frida 抓包(QQ 9.9.26-44343, 2026-06-19)。payload @ f2(不是 f12!)。
|
||||
// byte-oracle:用抓包值构造 req,encode 必须 byte-for-byte 匹配。
|
||||
const SUB100_MP4_HEX =
|
||||
'0a180a0408081064120ca80602b00604b80616c00c051a0208011298020a6b0a6708a0d2e40212001a28643138333762626336666131653031396136326330343734616332386563363131343565656138612222e5b18fe5b995e5bd95e588b620323032352d31322d3232203135313432392e6d70342a08080010001800200030003800400048011000100118002000280032120a040800120012021a001a045a0062005000380040004a88010a2432383564346530622d363465342d343961642d396430632d666234383938393930626162122432383564346530622d363465342d343961642d396430632d6662343839383939306261621a2432343166356438382d333637352d623030612d303261302d30633035306138343032656520012800300038024200480150005800600068007000';
|
||||
|
||||
const SUB100_MP4_REQ: FlashPrepareUploadReq = {
|
||||
head: {
|
||||
sub: { seq: 8, sub: 100 },
|
||||
config: { field101: 2, field102: 4, field103: 22, field200: 5 },
|
||||
field3: { field1: 1 },
|
||||
},
|
||||
payload: {
|
||||
wrapper: {
|
||||
fileInfo: {
|
||||
fileSize: 5843232,
|
||||
md5: '',
|
||||
sha1: 'd1837bbc6fa1e019a62c0474ac28ec61145eea8a',
|
||||
fileName: '屏幕录制 2025-12-22 151429.mp4',
|
||||
field5: { field1: 0, field2: 0, field3: 0, field4: 0 },
|
||||
field6: 0,
|
||||
field7: 0,
|
||||
field8: 0,
|
||||
field9: 1,
|
||||
},
|
||||
field2: 0,
|
||||
},
|
||||
field2: 1,
|
||||
field3: 0,
|
||||
field4: 0,
|
||||
field5: 0,
|
||||
field6: {
|
||||
field1: { field1: 0, field2: {} },
|
||||
field2: { field3: {} },
|
||||
field3: { field11: {}, field12: {} },
|
||||
field10: 0,
|
||||
},
|
||||
field7: 0,
|
||||
field8: 0,
|
||||
filesetWrap: {
|
||||
filesetUuid: '285d4e0b-64e4-49ad-9d0c-fb4898990bab',
|
||||
uploadKey: '285d4e0b-64e4-49ad-9d0c-fb4898990bab',
|
||||
fileUuid: '241f5d88-3675-b00a-02a0-0c050a8402ee',
|
||||
field4: 1,
|
||||
field5: 0,
|
||||
field6: 0,
|
||||
field7: 2,
|
||||
field8: {},
|
||||
field9: 1,
|
||||
field10: 0,
|
||||
field11: 0,
|
||||
field12: 0,
|
||||
field13: 0,
|
||||
field14: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('PrepareUpload byte-oracle (0x12a9_100 encode == frida 抓包)', () => {
|
||||
it('encodes the sub=100 mp4 capture to the exact wire bytes', () => {
|
||||
const out = Buffer.from(protobuf_encode<FlashPrepareUploadReq>(SUB100_MP4_REQ)).toString(
|
||||
'hex',
|
||||
);
|
||||
|
||||
if (out !== SUB100_MP4_HEX) {
|
||||
const n = Math.min(out.length, SUB100_MP4_HEX.length);
|
||||
let i = 0;
|
||||
|
||||
while (i < n && out[i] === SUB100_MP4_HEX[i]) {
|
||||
i++;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`hex mismatch at char ${i} (byte ${i >> 1}):\n` +
|
||||
` bot: ...${out.slice(Math.max(0, i - 24), i + 24)}\n` +
|
||||
` frida: ...${SUB100_MP4_HEX.slice(Math.max(0, i - 24), i + 24)}\n` +
|
||||
` bot len=${out.length / 2}B, frida len=${SUB100_MP4_HEX.length / 2}B`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(out).toBe(SUB100_MP4_HEX);
|
||||
});
|
||||
|
||||
it('round-trips: encode → decode preserves key fields', () => {
|
||||
const bytes = protobuf_encode<FlashPrepareUploadReq>(SUB100_MP4_REQ);
|
||||
const back = protobuf_decode<FlashPrepareUploadReq>(bytes);
|
||||
expect(back.head?.sub?.sub).toBe(100);
|
||||
expect(back.payload?.wrapper?.fileInfo?.fileSize).toBe(5843232);
|
||||
expect(back.payload?.filesetWrap?.fileUuid).toBe('241f5d88-3675-b00a-02a0-0c050a8402ee');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { protobuf_encode, protobuf_decode } from '@snowluma/proton';
|
||||
import type { FlashSendReq } from '@snowluma/proto-defs/oidb-actions/flash-transfer';
|
||||
|
||||
// 0x93d7 send_flash_msg 真实客户端请求 body(frida 抓包,QQ 9.9.26-44343, 2026-06-20)。
|
||||
// fileset e7453377-c8ea-404e-9285-53aa5fad1982 发送给 uid u_0aHrwL1tyskEWkhChJoeJg。
|
||||
// byte-oracle:用抓包值构造 req,encode 必须 byte-for-byte 匹配。
|
||||
const SEND_HEX =
|
||||
'0a1e0801121a0a18755f30614872774c317479736b45576b6843684a6f654a67122465373435333337372d633865612d343034652d393238352d353361613566616431393832';
|
||||
|
||||
const SEND_REQ: FlashSendReq = {
|
||||
target: { field1: 1, targetUid: { targetUid: 'u_0aHrwL1tyskEWkhChJoeJg' } },
|
||||
filesetUuid: 'e7453377-c8ea-404e-9285-53aa5fad1982',
|
||||
};
|
||||
|
||||
describe('SendFlashMsg byte-oracle (0x93d7 encode == frida 抓包)', () => {
|
||||
it('encodes the send capture to the exact wire bytes', () => {
|
||||
const out = Buffer.from(protobuf_encode<FlashSendReq>(SEND_REQ)).toString('hex');
|
||||
|
||||
if (out !== SEND_HEX) {
|
||||
const n = Math.min(out.length, SEND_HEX.length);
|
||||
let i = 0;
|
||||
|
||||
while (i < n && out[i] === SEND_HEX[i]) {
|
||||
i++;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`hex mismatch at char ${i} (byte ${i >> 1}):\n` +
|
||||
` bot: ...${out.slice(Math.max(0, i - 20), i + 20)}\n` +
|
||||
` frida: ...${SEND_HEX.slice(Math.max(0, i - 20), i + 20)}\n` +
|
||||
` bot len=${out.length / 2}B, frida len=${SEND_HEX.length / 2}B`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(out).toBe(SEND_HEX);
|
||||
});
|
||||
|
||||
it('round-trips: encode → decode preserves target + filesetUuid', () => {
|
||||
const bytes = protobuf_encode<FlashSendReq>(SEND_REQ);
|
||||
const back = protobuf_decode<FlashSendReq>(bytes);
|
||||
expect(back.target?.field1).toBe(1);
|
||||
expect(back.target?.targetUid?.targetUid).toBe('u_0aHrwL1tyskEWkhChJoeJg');
|
||||
expect(back.filesetUuid).toBe('e7453377-c8ea-404e-9285-53aa5fad1982');
|
||||
});
|
||||
});
|
||||
|
||||
// 群聊 0x93d7:f1={f1:2, f3:{f1:groupId}}, f2=filesetUuid(groupId 直接用群号,无需转 uid)。
|
||||
// 真实客户端 frida 抓包(2026-06-20),fileset e7453377 发送给群 1017438661。
|
||||
const SEND_GROUP_HEX =
|
||||
'0a0a08021a0608c5c393e503122465373435333337372d633865612d343034652d393238352d353361613566616431393832';
|
||||
const SEND_GROUP_REQ: FlashSendReq = {
|
||||
target: { field1: 2, targetGroup: { groupId: 1017438661 } },
|
||||
filesetUuid: 'e7453377-c8ea-404e-9285-53aa5fad1982',
|
||||
};
|
||||
|
||||
describe('SendFlashMsg byte-oracle (0x93d7 群聊 encode == frida 抓包)', () => {
|
||||
it('encodes the group send capture to the exact wire bytes', () => {
|
||||
const out = Buffer.from(protobuf_encode<FlashSendReq>(SEND_GROUP_REQ)).toString('hex');
|
||||
expect(out).toBe(SEND_GROUP_HEX);
|
||||
});
|
||||
|
||||
it('round-trips: encode → decode preserves groupId', () => {
|
||||
const back = protobuf_decode<FlashSendReq>(protobuf_encode<FlashSendReq>(SEND_GROUP_REQ));
|
||||
expect(back.target?.field1).toBe(2);
|
||||
expect(back.target?.targetGroup?.groupId).toBe(1017438661);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { ApproveDoubtBuddyReq } from '../../../src/oidb-services/friend/approve-doubt-buddy-req';
|
||||
import { env, s, v } from '../_pb-oracle';
|
||||
|
||||
function makeSender(selfUid = 'u_self', selfUin = '10001') {
|
||||
const respEnv: OidbBase<OidbEmpty> = { command: 0xd72, subCommand: 0, body: {} };
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<OidbBase<OidbEmpty>>(respEnv)),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async (_cmd: string, _bytes: Uint8Array) => r),
|
||||
identity: { selfUid, uin: selfUin } as ApproveDoubtBuddyReq.Deps['identity'],
|
||||
resolveUserUid: vi.fn(async () => 'u_lookup_self'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ApproveDoubtBuddyReq namespace', () => {
|
||||
it('keeps the approving account separate from the applicant', async () => {
|
||||
const sender = makeSender();
|
||||
expect(await ApproveDoubtBuddyReq.serialize(sender, { uid: 'u_applicant' })).toEqual({
|
||||
selfUid: 'u_self',
|
||||
targetUid: 'u_applicant',
|
||||
field3: 0,
|
||||
field4: '',
|
||||
});
|
||||
expect(sender.resolveUserUid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches the approval request bytes including explicit default options', async () => {
|
||||
const sender = makeSender();
|
||||
await ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' });
|
||||
|
||||
expect(sender.sendRawPacket).toHaveBeenCalledOnce();
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xd72_0');
|
||||
const body = [...s(1, 'u_self'), ...s(2, 'u_applicant'), ...v(3, 0), ...s(4, '')];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xd72, 0, body, false));
|
||||
});
|
||||
|
||||
it('resolves the current account when its self profile is not cached', async () => {
|
||||
const sender = makeSender('');
|
||||
await ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' });
|
||||
expect(sender.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
const body = [...s(1, 'u_lookup_self'), ...s(2, 'u_applicant'), ...v(3, 0), ...s(4, '')];
|
||||
expect(Buffer.from(sender.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe(
|
||||
env(0xd72, 0, body, false),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not send when the current account cannot be identified', async () => {
|
||||
const sender = makeSender('', '0');
|
||||
await expect(ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' })).rejects.toThrow(
|
||||
'self uid is unavailable',
|
||||
);
|
||||
expect(sender.sendRawPacket).not.toHaveBeenCalled();
|
||||
expect(sender.resolveUserUid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates self identity lookup errors without sending', async () => {
|
||||
const sender = makeSender('');
|
||||
sender.resolveUserUid.mockRejectedValue(new Error('profile lookup failed'));
|
||||
await expect(ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' })).rejects.toThrow(
|
||||
'profile lookup failed',
|
||||
);
|
||||
expect(sender.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an empty resolved self identity before sending', async () => {
|
||||
const sender = makeSender('');
|
||||
sender.resolveUserUid.mockResolvedValue('');
|
||||
await expect(ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' })).rejects.toThrow(
|
||||
'self uid is unavailable',
|
||||
);
|
||||
expect(sender.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['', ' '])('rejects an absent applicant identity (%j)', async (uid) => {
|
||||
const sender = makeSender();
|
||||
await expect(ApproveDoubtBuddyReq.invoke(sender, { uid })).rejects.toThrow(
|
||||
'applicant uid is unavailable',
|
||||
);
|
||||
expect(sender.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates a rejected approval without retrying a different operation', async () => {
|
||||
const sender = makeSender();
|
||||
sender.sendRawPacket.mockResolvedValue({
|
||||
success: false,
|
||||
gotResponse: true,
|
||||
errorCode: 1201,
|
||||
errorMessage: 'request rejected',
|
||||
responseData: Buffer.alloc(0),
|
||||
});
|
||||
await expect(ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' })).rejects.toThrow(
|
||||
'request rejected',
|
||||
);
|
||||
expect(sender.sendRawPacket).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not mistake a successful transport for an accepted approval', async () => {
|
||||
const sender = makeSender();
|
||||
sender.sendRawPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbEmpty>>({
|
||||
command: 0xd72,
|
||||
subCommand: 0,
|
||||
errorCode: 1201,
|
||||
errorMsg: 'approval denied',
|
||||
body: {},
|
||||
}),
|
||||
),
|
||||
});
|
||||
await expect(ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' })).rejects.toThrow(
|
||||
'approval denied',
|
||||
);
|
||||
expect(sender.sendRawPacket).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ClearFriendRemark } from '../../../src/oidb-services/friend/clear-friend-remark';
|
||||
import { env, m, s } from '../_pb-oracle';
|
||||
|
||||
function makeDeps() {
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => result),
|
||||
resolveUserUid: vi.fn(async () => 'resolved-uid'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ClearFriendRemark namespace', () => {
|
||||
it('sends the current QQ 0x912F_0 wire shape', async () => {
|
||||
const deps = makeDeps();
|
||||
await ClearFriendRemark.invoke(deps, { userId: 10001 });
|
||||
const [wireName, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const body = m(1, s(7, 'resolved-uid'));
|
||||
expect({
|
||||
wireName,
|
||||
hex: Buffer.from(bytes).toString('hex'),
|
||||
}).toEqual({
|
||||
wireName: 'OidbSvcTrpcTcp.0x912f_0',
|
||||
hex: env(0x912f, 0, body, false),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbDeleteFriend } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { DeleteFriend } from '../../../src/oidb-services/friend/delete-friend';
|
||||
import { env, m, s, v } from '../_pb-oracle';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
resolveUserUid: vi.fn(async () => 'resolved-uid'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('DeleteFriend namespace', () => {
|
||||
it('declares 0x126B_0', () => {
|
||||
expect(DeleteFriend.command).toBe(0x126b);
|
||||
expect(DeleteFriend.subCommand).toBe(0);
|
||||
});
|
||||
|
||||
describe('invoke', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0x126b_0', async () => {
|
||||
const deps = makeDeps();
|
||||
await DeleteFriend.invoke(deps, { userId: 10001 });
|
||||
expect(deps.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x126b_0');
|
||||
});
|
||||
|
||||
it('resolves the target uid before encoding', async () => {
|
||||
const deps = makeDeps();
|
||||
await DeleteFriend.invoke(deps, { userId: 10001 });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
});
|
||||
|
||||
it('packages the nested envelope with verbatim magic constants', async () => {
|
||||
const deps = makeDeps();
|
||||
await DeleteFriend.invoke(deps, { userId: 10001, block: true });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbDeleteFriend>>(bytes);
|
||||
expect(env.body?.field1).toMatchObject({
|
||||
targetUid: 'resolved-uid',
|
||||
block: true,
|
||||
field2: {
|
||||
field1: 130,
|
||||
field2: 109,
|
||||
field3: { field1: 8, field2: 8, field3: 50 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('matches the current QQ wire for a non-blocking delete', async () => {
|
||||
const deps = makeDeps();
|
||||
await DeleteFriend.invoke(deps, { userId: 10001 });
|
||||
const [wireName, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const discriminator = [
|
||||
...v(1, 130),
|
||||
...v(2, 109),
|
||||
...m(3, [...v(1, 8), ...v(2, 8), ...v(3, 50)]),
|
||||
];
|
||||
const body = m(1, [
|
||||
...s(1, 'resolved-uid'),
|
||||
...m(2, discriminator),
|
||||
// Current QQ explicitly emits both booleans.
|
||||
...v(3, 0),
|
||||
...v(4, 1),
|
||||
]);
|
||||
expect({
|
||||
wireName,
|
||||
hex: Buffer.from(bytes).toString('hex'),
|
||||
}).toEqual({
|
||||
wireName: 'OidbSvcTrpcTcp.0x126b_0',
|
||||
hex: env(0x126b, 0, body, false),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
08e91a22a60c080112a10c0a7e0880c2d72f12066e69636b3030180020fe012a056d73673030320851512067726f75703a1766696c746572656420667269656e6420726571756573744080c3bbc2064880cee4cd02500062006a30abc8a360ea075bcf8bbfa1e1e0e010beb1ac5228138eb82586704b41691e9efea640392a2a65e9dd50de82f154e0201b0a7d08c7aa9b3012066e69636b3031180020012a056d73673031320851512067726f75703a1766696c746572656420667269656e6420726571756573744080e6c0c206488dcee4cd02500162006a30ee591d017a9bb6807f7db7c8d3665e34923c7db6f6252d672a8f1f8986c7c9e3d21415ea86987a1b689c8d675d2a26800a7d088e93df3012066e69636b3032180020012a056d73673032320851512067726f75703a1766696c746572656420667269656e642072657175657374408089c6c206489acee4cd02500062006a305fe9e2cce8e6bc04c4f76fe8872347907e73a73b9184c3a7cbbd9c85a1d319c252129d950d06920d50732964df4afcf20a810108d5fba23112066e69636b3033180020ffffffff0f2a056d73673033320851512067726f75703a1766696c746572656420667269656e6420726571756573744080accbc20648a7cee4cd02500062006a3006b881debd01f92c98ef3c1726406ebeb88cae9d12d89e9fa36c20a77495dd0a3eb9fbbe7b14f65d657e979adfda5c600a7d089ce4e63112066e69636b3034180020012a056d73673034320851512067726f75703a1766696c746572656420667269656e6420726571756573744080cfd0c20648b4cee4cd02500062006a30316f76c654c33c8f6f3689a9475d8b871f74654789edb72d3f1a87bb1ba11cf07286b8d748f4d08d6fbe5afee8208e010a870108e3ccaa3212066e69636b3035185920ffffffff0f2a056d73673035320851512067726f75703a1766696c746572656420667269656e6420726571756573744080f2d5c20648c1cee4cd0250006206e4b89ce4baac6a30a3038a382c30ee6a8686410089e1c618f0b11bf92fdf2726da2910d2082a147a5569d04815e7c42287b424ef45b00ff70a860108aab5ee3212066e69636b3036180120002a056d73673036320851512067726f75703a1766696c746572656420667269656e642072657175657374408095dbc20648cecee4cd0250006209e7a59ee5a588e5b79d6a3097d6cc1f21ca290ab424147c3181e68490c49de47eda96eb422ec861eefdc8f580578655ac4f874ab7e8467cfc0e9fd30a810108f19db23312066e69636b3037180020ffffffff0f2a056d73673037320851512067726f75703a1766696c746572656420667269656e6420726571756573744080b8e0c20648dbcee4cd02500062006a303c1421acb295e3a5a4c914625ef1dbee99b552716ed28046cfa3b9171518dab5c247e0ddd3a24cb5f318a1cb60d6c9b30a810108b886f63312066e69636b3038180020ffffffff0f2a056d73673038320851512067726f75703a1766696c746572656420667269656e6420726571756573744080dbe5c20648e8cee4cd02500162006a3020ac3ddb705e3246f8fe7d266becea5bf92ec584a2cc12d4a83197d782047ca61d0ed90c5c4419e0866940f22a3eb1420a7d08ffeeb93412066e69636b3039180020012a056d73673039320851512067726f75703a1766696c746572656420667269656e6420726571756573744080feeac20648f5cee4cd02500162006a3047da9cf4f576624c5edd21b6bcfda8898c91143711f0bd251e00dfb1c273738290fa190cf6dc231c41dc31ad2086c6050a7d08c6d7fd3412066e69636b3130181420002a056d73673130320851512067726f75703a1766696c746572656420667269656e6420726571756573744080a1f0c2064882cfe4cd02500062006a300ab4598cfe36f3f340ec5237766213a06ddd99d900c3b3ac4d18530d4308f17943a22ef6b85d4d3f08086dcaf36465550a8401088dc0c13512066e69636b3131181220fe012a056d73673131320851512067726f75703a1766696c746572656420667269656e6420726571756573744080c4f5c206488fcfe4cd0250016206e697a0e994a16a3006e504c8f85b64fa8d5cb196d4b8cebc8098c752d879880bd8ab0108a829a1b29f952ff4e4b3807af4d54f70fb0dbf50
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbDoubtGetResp } from '@snowluma/proto-defs/oidb-actions/doubt-buddy';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { GetDoubtBuddyReq } from '../../../src/oidb-services/friend/get-doubt-buddy-req';
|
||||
import { env, v, s, m } from '../_pb-oracle';
|
||||
|
||||
function makeSender(): { sendRawPacket: ReturnType<typeof vi.fn> } {
|
||||
const respEnv: OidbBase<OidbDoubtGetResp> = {
|
||||
command: 0xd69,
|
||||
subCommand: 0,
|
||||
body: {
|
||||
status: 1,
|
||||
body: {
|
||||
list: [
|
||||
{
|
||||
uid: 'u_alice',
|
||||
nick: 'Alice',
|
||||
source: '可能认识',
|
||||
reason: '',
|
||||
msg: 'hi',
|
||||
reqTime: 1700000000n,
|
||||
},
|
||||
],
|
||||
reason: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<OidbBase<OidbDoubtGetResp>>(respEnv)),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('GetDoubtBuddyReq namespace', () => {
|
||||
it('declares command 0xD69 sub 0, uin form', () => {
|
||||
expect(GetDoubtBuddyReq.command).toBe(0xd69);
|
||||
expect(GetDoubtBuddyReq.subCommand).toBe(0);
|
||||
expect(GetDoubtBuddyReq.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes {1:1, 2:{1:count, 2:uk}} (reqId is not on the wire)', () => {
|
||||
expect(GetDoubtBuddyReq.serialize({} as any, { count: 50 })).toEqual({
|
||||
field1: 1,
|
||||
inner: { num: 50, uk: '' },
|
||||
});
|
||||
});
|
||||
|
||||
it('byte-oracle: routes to 0xd69_0 and locks nested req tags {1:const1, 2:{1:num, 2:uk}}', async () => {
|
||||
const sender = makeSender();
|
||||
await GetDoubtBuddyReq.invoke(sender, { count: 50 });
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xd69_0');
|
||||
// uk '' omitted (proto3 default); inner carries only num.
|
||||
const inner = [...v(1, 50)];
|
||||
const body = [...v(1, 1), ...m(2, inner)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xd69, 0, body, true));
|
||||
});
|
||||
|
||||
it('deserializes the string uid item to the OneBot shape', async () => {
|
||||
const list = await GetDoubtBuddyReq.invoke(makeSender(), { count: 10 });
|
||||
expect(list).toEqual([
|
||||
{
|
||||
uid: 'u_alice',
|
||||
user_id: 0,
|
||||
nick: 'Alice',
|
||||
source: '可能认识',
|
||||
reason: '',
|
||||
msg: 'hi',
|
||||
group_code: '',
|
||||
reqTime: 1700000000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('deserialize returns [] when the body has no list', () => {
|
||||
expect(GetDoubtBuddyReq.deserialize({} as any, { status: 1 })).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps an empty uid when the reply only carries the account number', () => {
|
||||
expect(
|
||||
GetDoubtBuddyReq.deserialize({} as any, {
|
||||
status: 1,
|
||||
body: { list: [{ nick: 'Alice', uin: 12345n, reqTime: 1700000000n }] },
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
uid: '',
|
||||
user_id: 12345,
|
||||
nick: 'Alice',
|
||||
source: '',
|
||||
reason: '',
|
||||
msg: '',
|
||||
group_code: '',
|
||||
reqTime: 1700000000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the uid string and does not reread it as the account number', async () => {
|
||||
const item = [
|
||||
...s(1, 'u_alice'),
|
||||
...s(2, 'Alice'),
|
||||
...s(5, 'hi'),
|
||||
...s(6, 'QQ号查找'),
|
||||
...s(7, 'caution'),
|
||||
];
|
||||
const sender = {
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
env(0xd69, 0, [...v(1, 1), ...m(2, m(1, item))], false),
|
||||
'hex',
|
||||
),
|
||||
})),
|
||||
};
|
||||
const list = await GetDoubtBuddyReq.invoke(sender, { count: 10 });
|
||||
expect(list).toEqual([
|
||||
{
|
||||
uid: 'u_alice',
|
||||
user_id: 0,
|
||||
nick: 'Alice',
|
||||
source: 'QQ号查找',
|
||||
reason: 'caution',
|
||||
msg: 'hi',
|
||||
group_code: '',
|
||||
reqTime: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// Live capture from QQ 3.2.32-52194 on Linux. Locks the real wire shape:
|
||||
// tag1 is the applicant's account number (not a string uid), tag8 is the
|
||||
// request unix time, and tag9 is the source group. Before this, a tag8-as-uin
|
||||
// read made the uin->uid fallback query the request time as an account.
|
||||
it('decodes a captured Linux reply: account at tag1, time at tag8, group at tag9', () => {
|
||||
const hex = readFileSync(
|
||||
fileURLToPath(new URL('./fixtures/d69-linux-3.2.32.hex', import.meta.url)),
|
||||
'utf8',
|
||||
).trim();
|
||||
const list = GetDoubtBuddyReq.deserialize(
|
||||
{} as any,
|
||||
GetDoubtBuddyReq.decode(Buffer.from(hex, 'hex')).body!,
|
||||
);
|
||||
|
||||
expect(list).toHaveLength(12);
|
||||
expect(list[0]).toMatchObject({
|
||||
uid: '',
|
||||
user_id: 100000000,
|
||||
nick: 'nick00',
|
||||
source: 'QQ group',
|
||||
reason: 'filtered friend request',
|
||||
msg: 'msg00',
|
||||
group_code: '700000000',
|
||||
reqTime: 1750000000,
|
||||
});
|
||||
expect(list[1]).toMatchObject({
|
||||
uid: '',
|
||||
user_id: 101111111,
|
||||
group_code: '700000013',
|
||||
reqTime: 1750086400,
|
||||
});
|
||||
|
||||
// Account numbers stay in the QQ range; request times land in 2025-2026.
|
||||
for (const item of list) {
|
||||
expect(item.user_id).toBeGreaterThan(100000);
|
||||
expect(item.user_id).toBeLessThan(4294967296);
|
||||
expect(item.reqTime).toBeGreaterThan(1700000000);
|
||||
expect(item.reqTime).toBeLessThan(2000000000);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbFriendRequestAction } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { HandleFriendRequest } from '../../../src/oidb-services/friend/handle-friend-request';
|
||||
|
||||
function makeDeps(resolvedUid = 'resolved-uid') {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
resolveUserUid: vi.fn(async () => resolvedUid),
|
||||
};
|
||||
}
|
||||
|
||||
describe('HandleFriendRequest namespace', () => {
|
||||
it('declares 0xB5D_44', () => {
|
||||
expect(HandleFriendRequest.command).toBe(0xb5d);
|
||||
expect(HandleFriendRequest.subCommand).toBe(44);
|
||||
});
|
||||
|
||||
describe('invoke', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0xb5d_44', async () => {
|
||||
const deps = makeDeps();
|
||||
await HandleFriendRequest.invoke(deps, { uidOrFlag: 'uid', approve: true });
|
||||
expect(deps.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xb5d_44');
|
||||
});
|
||||
|
||||
it('detects digit-only flag → resolves via resolveUserUid', async () => {
|
||||
const deps = makeDeps('resolved-from-uin');
|
||||
await HandleFriendRequest.invoke(deps, { uidOrFlag: '10001', approve: true });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbFriendRequestAction>>(bytes);
|
||||
expect(env.body?.targetUid).toBe('resolved-from-uin');
|
||||
});
|
||||
|
||||
it('passes non-numeric flag through verbatim (no resolveUserUid call)', async () => {
|
||||
const deps = makeDeps();
|
||||
await HandleFriendRequest.invoke(deps, { uidOrFlag: 'flag-abc', approve: false });
|
||||
expect(deps.resolveUserUid).not.toHaveBeenCalled();
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbFriendRequestAction>>(bytes);
|
||||
expect(env.body?.targetUid).toBe('flag-abc');
|
||||
});
|
||||
|
||||
it('encodes accept=3 when approve=true', async () => {
|
||||
const deps = makeDeps();
|
||||
await HandleFriendRequest.invoke(deps, { uidOrFlag: 'u', approve: true });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbFriendRequestAction>>(bytes);
|
||||
expect(env.body?.accept).toBe(3);
|
||||
});
|
||||
|
||||
it('encodes accept=5 when approve=false', async () => {
|
||||
const deps = makeDeps();
|
||||
await HandleFriendRequest.invoke(deps, { uidOrFlag: 'u', approve: false });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbFriendRequestAction>>(bytes);
|
||||
expect(env.body?.accept).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { RejectDoubtBuddyReq } from '../../../src/oidb-services/friend/reject-doubt-buddy-req';
|
||||
import { env, v, s, m } from '../_pb-oracle';
|
||||
|
||||
function makeSender() {
|
||||
const respEnv: OidbBase<OidbEmpty> = { command: 0xd69, subCommand: 0, body: {} };
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<OidbBase<OidbEmpty>>(respEnv)),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('RejectDoubtBuddyReq namespace', () => {
|
||||
it('declares command 0xD69 sub 0, uin form', () => {
|
||||
expect(RejectDoubtBuddyReq.command).toBe(0xd69);
|
||||
expect(RejectDoubtBuddyReq.subCommand).toBe(0);
|
||||
expect(RejectDoubtBuddyReq.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes {1: const 3 (op discriminator), 3: {1: uid}}', () => {
|
||||
expect(RejectDoubtBuddyReq.serialize({} as any, { uid: 'u_x' })).toEqual({
|
||||
field1: 3,
|
||||
inner: { uid: 'u_x' },
|
||||
});
|
||||
});
|
||||
|
||||
it('byte-oracle: routes to 0xd69_0 and locks req tags {1:const3, 3:{1:uid}}', async () => {
|
||||
const sender = makeSender();
|
||||
await RejectDoubtBuddyReq.invoke(sender, { uid: 'u_abc' });
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xd69_0');
|
||||
const inner = [...s(1, 'u_abc')];
|
||||
const body = [...v(1, 3), ...m(3, inner)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xd69, 0, body, true));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { subscribeLogs, type LogEntry } from '@snowluma/common/logger';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetFriendRemark } from '../../../src/oidb-services/friend/set-friend-remark';
|
||||
import { env, m, s, v } from '../_pb-oracle';
|
||||
|
||||
function successBody(uid = 'resolved-uid', remark = 'best-friend', uin = 0): number[] {
|
||||
return m(1, [...m(1, [...(uin > 0 ? v(3, uin) : []), ...s(7, uid)]), ...s(2, remark)]);
|
||||
}
|
||||
|
||||
function makeDeps(responseBody = successBody()) {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(env(0x912e, 0, responseBody, false), 'hex'),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
resolveUserUid: vi.fn(async () => 'resolved-uid'),
|
||||
identity: { updateFriendRemark: vi.fn() },
|
||||
};
|
||||
}
|
||||
|
||||
describe('SetFriendRemark namespace', () => {
|
||||
it('sends the current QQ 0x912E_0 wire shape for a non-empty remark', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetFriendRemark.invoke(deps, { userId: 10001, remark: 'best-friend' });
|
||||
const [wireName, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const body = [
|
||||
...m(1, [...m(1, s(7, 'resolved-uid')), ...s(2, 'best-friend')]),
|
||||
// QQ explicitly emits scene=0 for an ordinary friend.
|
||||
...v(2, 0),
|
||||
];
|
||||
expect({
|
||||
wireName,
|
||||
hex: Buffer.from(bytes).toString('hex'),
|
||||
}).toEqual({
|
||||
wireName: 'OidbSvcTrpcTcp.0x912e_0',
|
||||
hex: env(0x912e, 0, body, false),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves the target UID before encoding', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetFriendRemark.invoke(deps, { userId: 10001, remark: 'best-friend' });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
});
|
||||
|
||||
it('updates identity only after the server confirms the requested value', async () => {
|
||||
const deps = makeDeps(successBody('resolved-uid', 'best-friend', 10001));
|
||||
|
||||
await SetFriendRemark.invoke(deps, { userId: 10001, remark: 'best-friend' });
|
||||
|
||||
expect(deps.identity.updateFriendRemark).toHaveBeenCalledWith(
|
||||
'resolved-uid',
|
||||
10001,
|
||||
'best-friend',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports local synchronization failures without retrying a confirmed remote update', async () => {
|
||||
const deps = makeDeps(successBody('resolved-uid', 'best-friend', 10001));
|
||||
deps.identity.updateFriendRemark.mockImplementation(() => {
|
||||
throw new Error('identity cache unavailable');
|
||||
});
|
||||
const captured: LogEntry[] = [];
|
||||
const unsubscribe = subscribeLogs((entry) => {
|
||||
if (entry.scope === 'Bridge.Friend') {
|
||||
captured.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
SetFriendRemark.invoke(deps, {
|
||||
userId: 10001,
|
||||
remark: 'best-friend',
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
expect(deps.sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
expect(captured).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: 'error',
|
||||
message: expect.stringContaining('identity cache unavailable'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a business failure carried inside a successful OIDB response', async () => {
|
||||
const deps = makeDeps(m(3, [...v(2, 120), ...s(3, 'remark rejected')]));
|
||||
|
||||
await expect(
|
||||
SetFriendRemark.invoke(deps, {
|
||||
userId: 10001,
|
||||
remark: 'best-friend',
|
||||
}),
|
||||
).rejects.toThrow(/remark rejected/);
|
||||
expect(deps.identity.updateFriendRemark).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a response that contains neither a result nor a business error', async () => {
|
||||
const deps = makeDeps([]);
|
||||
|
||||
await expect(
|
||||
SetFriendRemark.invoke(deps, {
|
||||
userId: 10001,
|
||||
remark: 'best-friend',
|
||||
}),
|
||||
).rejects.toThrow(/missing result/);
|
||||
});
|
||||
|
||||
it('rejects a success result for a different target', async () => {
|
||||
const deps = makeDeps(successBody('another-uid'));
|
||||
|
||||
await expect(
|
||||
SetFriendRemark.invoke(deps, {
|
||||
userId: 10001,
|
||||
remark: 'best-friend',
|
||||
}),
|
||||
).rejects.toThrow(/target mismatch/);
|
||||
});
|
||||
|
||||
it('rejects a success result that does not confirm the requested remark', async () => {
|
||||
const deps = makeDeps(successBody('resolved-uid', 'old-remark'));
|
||||
|
||||
await expect(
|
||||
SetFriendRemark.invoke(deps, {
|
||||
userId: 10001,
|
||||
remark: 'best-friend',
|
||||
}),
|
||||
).rejects.toThrow(/does not match/);
|
||||
});
|
||||
|
||||
it('rejects a success result carrying a different numeric account', async () => {
|
||||
const deps = makeDeps(successBody('resolved-uid', 'best-friend', 20002));
|
||||
|
||||
await expect(
|
||||
SetFriendRemark.invoke(deps, {
|
||||
userId: 10001,
|
||||
remark: 'best-friend',
|
||||
}),
|
||||
).rejects.toThrow(/expected UIN 10001, got 20002/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { SetAdminPermissions } from '../../../src/oidb-services/group-admin/set-admin-permissions';
|
||||
import { GetAdminPermissions } from '../../../src/oidb-services/group-admin/get-admin-permissions';
|
||||
import { SetAdminReceiveVerify } from '../../../src/oidb-services/group-admin/set-admin-receive-verify';
|
||||
import { env, m, v } from '../_pb-oracle';
|
||||
|
||||
const target = { groupId: 12345, userId: 67890 };
|
||||
|
||||
function sender(body: number[] = []) {
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(body),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async (_cmd: string, _bytes: Uint8Array) => result) };
|
||||
}
|
||||
|
||||
function member(uin: number, permissions: number[]) {
|
||||
return [...v(1, uin), ...m(3, m(52, permissions))];
|
||||
}
|
||||
|
||||
function snapshot(permissions: number[], group = target.groupId, uin = target.userId) {
|
||||
return m(4, [...v(1, group), ...m(3, member(uin, permissions))]);
|
||||
}
|
||||
|
||||
function permission(id: number, value: number, extra?: number[]) {
|
||||
return m(1, [...v(1, id), ...v(2, value), ...(extra === undefined ? [] : m(3, extra))]);
|
||||
}
|
||||
|
||||
describe('administrator permission requests', () => {
|
||||
it('writes both permissions with the service-specific envelope and nested daily limit', async () => {
|
||||
const deps = sender();
|
||||
await SetAdminPermissions.invoke(deps, {
|
||||
...target,
|
||||
recallMemberMessage: false,
|
||||
kickMember: true,
|
||||
kickMemberPerDay: 20,
|
||||
});
|
||||
const [cmd, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x9c1c_2');
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(
|
||||
env(
|
||||
0x9c1c,
|
||||
2,
|
||||
[
|
||||
...v(1, target.groupId),
|
||||
...v(2, target.userId),
|
||||
...m(3, [...v(1, 1), ...v(2, 1), ...m(3, v(1, 20))]),
|
||||
...m(3, [...v(1, 2), ...v(2, 2)]),
|
||||
],
|
||||
false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not write omitted permissions and encodes disable as 2', async () => {
|
||||
const deps = sender();
|
||||
await SetAdminPermissions.invoke(deps, { ...target, kickMember: false });
|
||||
expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe(
|
||||
env(
|
||||
0x9c1c,
|
||||
2,
|
||||
[
|
||||
...v(1, target.groupId),
|
||||
...v(2, target.userId),
|
||||
...m(3, [...v(1, 1), ...v(2, 2)]),
|
||||
],
|
||||
false,
|
||||
),
|
||||
);
|
||||
await SetAdminPermissions.invoke(deps, { ...target, recallMemberMessage: true });
|
||||
expect(Buffer.from(deps.sendRawPacket.mock.calls[1]![1]).toString('hex')).toBe(
|
||||
env(
|
||||
0x9c1c,
|
||||
2,
|
||||
[
|
||||
...v(1, target.groupId),
|
||||
...v(2, target.userId),
|
||||
...m(3, [...v(1, 2), ...v(2, 1)]),
|
||||
],
|
||||
false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts explicit unlimited without manufacturing an extra limit field', async () => {
|
||||
const deps = sender();
|
||||
await SetAdminPermissions.invoke(deps, {
|
||||
...target,
|
||||
kickMember: true,
|
||||
kickMemberPerDay: 0,
|
||||
});
|
||||
expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe(
|
||||
env(
|
||||
0x9c1c,
|
||||
2,
|
||||
[
|
||||
...v(1, target.groupId),
|
||||
...v(2, target.userId),
|
||||
...m(3, [...v(1, 1), ...v(2, 1)]),
|
||||
],
|
||||
false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{},
|
||||
{ kickMemberPerDay: 20 },
|
||||
{ kickMember: true },
|
||||
{ kickMember: false, kickMemberPerDay: 20 },
|
||||
{ kickMember: true, kickMemberPerDay: -1 },
|
||||
{ kickMember: true, kickMemberPerDay: 1 },
|
||||
{ kickMember: true, kickMemberPerDay: 201 },
|
||||
{ kickMember: true, kickMemberPerDay: NaN },
|
||||
])('rejects invalid patches before sending: %j', async (patch) => {
|
||||
const deps = sender();
|
||||
await expect(SetAdminPermissions.invoke(deps, { ...target, ...patch })).rejects.toThrow();
|
||||
expect(deps.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates envelope rejection instead of accepting an empty response body', async () => {
|
||||
const deps = sender(v(3, 1287));
|
||||
await expect(
|
||||
SetAdminPermissions.invoke(deps, { ...target, recallMemberMessage: false }),
|
||||
).rejects.toThrow('OIDB error 1287');
|
||||
});
|
||||
|
||||
it('does not retry transport failures', async () => {
|
||||
const deps = sender();
|
||||
deps.sendRawPacket.mockRejectedValueOnce(new Error('transport disconnected'));
|
||||
await expect(
|
||||
SetAdminPermissions.invoke(deps, { ...target, recallMemberMessage: false }),
|
||||
).rejects.toThrow('transport disconnected');
|
||||
expect(deps.sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('administrator permission snapshot', () => {
|
||||
it('requests only permission data and reads the matching member', async () => {
|
||||
const deps = sender(snapshot([...permission(2, 2), ...permission(1, 1, v(1, 50))]));
|
||||
await expect(GetAdminPermissions.invoke(deps, target)).resolves.toEqual({
|
||||
recallMemberMessage: false,
|
||||
kickMember: true,
|
||||
kickMemberPerDay: 50,
|
||||
});
|
||||
const [cmd, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x9164_2');
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(
|
||||
env(
|
||||
0x9164,
|
||||
2,
|
||||
[...v(1, target.groupId), ...v(2, target.userId), ...m(4, m(52, [1]))],
|
||||
true,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('finds the requested member rather than using the first returned member', async () => {
|
||||
const deps = sender(
|
||||
m(4, [
|
||||
...v(1, target.groupId),
|
||||
...m(3, member(1, permission(2, 1))),
|
||||
...m(3, member(target.userId, permission(2, 2))),
|
||||
]),
|
||||
);
|
||||
await expect(GetAdminPermissions.invoke(deps, target)).resolves.toEqual({
|
||||
recallMemberMessage: false,
|
||||
kickMember: null,
|
||||
kickMemberPerDay: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the protobuf zero limit only when removal is explicitly enabled', async () => {
|
||||
await expect(
|
||||
GetAdminPermissions.invoke(sender(snapshot(permission(1, 1))), target),
|
||||
).resolves.toEqual({ recallMemberMessage: null, kickMember: true, kickMemberPerDay: 0 });
|
||||
await expect(
|
||||
GetAdminPermissions.invoke(sender(snapshot(permission(1, 2))), target),
|
||||
).resolves.toEqual({
|
||||
recallMemberMessage: null,
|
||||
kickMember: false,
|
||||
kickMemberPerDay: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not turn missing permissions or future item IDs into enabled defaults', async () => {
|
||||
await expect(
|
||||
GetAdminPermissions.invoke(
|
||||
sender(snapshot([...permission(999, 1), ...permission(2, 0)])),
|
||||
target,
|
||||
),
|
||||
).resolves.toEqual({ recallMemberMessage: null, kickMember: null, kickMemberPerDay: null });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing body', []],
|
||||
['wrong group', snapshot([], 1)],
|
||||
['wrong member', snapshot([], target.groupId, 1)],
|
||||
['no member', m(4, v(1, target.groupId))],
|
||||
[
|
||||
'duplicate member',
|
||||
m(4, [
|
||||
...v(1, target.groupId),
|
||||
...m(3, member(target.userId, [])),
|
||||
...m(3, member(target.userId, [])),
|
||||
]),
|
||||
],
|
||||
['duplicate permission', snapshot([...permission(1, 1), ...permission(1, 2)])],
|
||||
['unsupported value', snapshot(permission(2, 3))],
|
||||
['malformed limit', snapshot(permission(1, 1, [8, 128]))],
|
||||
] as const)('rejects %s', async (_name, bytes) => {
|
||||
await expect(GetAdminPermissions.invoke(sender([...bytes]), target)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('administrator join verification', () => {
|
||||
it.each([true, false])(
|
||||
'encodes enable=%s and requires a successful target acknowledgement',
|
||||
async (enable) => {
|
||||
const deps = sender(m(4, [...v(1, target.groupId), ...m(2, v(1, target.userId))]));
|
||||
await expect(
|
||||
SetAdminReceiveVerify.invoke(deps, { ...target, enable }),
|
||||
).resolves.toBeUndefined();
|
||||
const [cmd, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x904_1');
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(
|
||||
env(
|
||||
0x904,
|
||||
1,
|
||||
[
|
||||
...v(1, target.groupId),
|
||||
...m(2, [...v(1, target.userId), ...v(2, 1), ...v(3, enable ? 1 : 0)]),
|
||||
],
|
||||
true,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(
|
||||
[
|
||||
[],
|
||||
m(4, v(1, target.groupId)),
|
||||
m(4, [...v(1, 1), ...m(2, v(1, target.userId))]),
|
||||
m(4, [...v(1, target.groupId), ...m(2, v(1, 1))]),
|
||||
].map((response) => ({ response })),
|
||||
)('rejects an unconfirmed target: %j', async ({ response }) => {
|
||||
await expect(
|
||||
SetAdminReceiveVerify.invoke(sender(response), { ...target, enable: false }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { GetAdminReceiveVerify } from '../../../src/oidb-services/group-admin/get-admin-receive-verify';
|
||||
import { env, m, s, v } from '../_pb-oracle';
|
||||
|
||||
const target = { groupId: 12345, userId: 67890 };
|
||||
const uid = 'test-member-uid';
|
||||
|
||||
function member(flag?: number, memberUid = uid, uin: number | undefined = target.userId) {
|
||||
return m(2, [
|
||||
...m(1, [...s(2, memberUid), ...(uin === undefined ? [] : v(4, uin))]),
|
||||
...(flag === undefined ? [] : v(104, flag)),
|
||||
]);
|
||||
}
|
||||
|
||||
function response(members: number[], group: number | undefined = target.groupId) {
|
||||
return m(4, [...(group === undefined ? [] : v(1, group)), ...members]);
|
||||
}
|
||||
|
||||
function sender(body = response(member(0))) {
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(body),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async (_command: string, _bytes: Uint8Array) => result),
|
||||
resolveUserUid: vi.fn(async (_userId: number, _groupId?: number) => uid),
|
||||
};
|
||||
}
|
||||
|
||||
describe('administrator receive-verification read', () => {
|
||||
it('resolves the member once in its group and requests only the verification state', async () => {
|
||||
const deps = sender();
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, target)).resolves.toBe(true);
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledExactlyOnceWith(target.userId, target.groupId);
|
||||
expect(deps.sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
const [command, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(command).toBe('OidbSvcTrpcTcp.0xfe7_4');
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(
|
||||
env(
|
||||
0xfe7,
|
||||
4,
|
||||
[
|
||||
...v(1, target.groupId),
|
||||
...v(2, 3),
|
||||
...v(3, 0),
|
||||
...m(4, v(104, 1)),
|
||||
...m(5, s(2, uid)),
|
||||
],
|
||||
false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, true],
|
||||
[64, false],
|
||||
[32, true],
|
||||
[96, false],
|
||||
[0x80000000, true],
|
||||
[0xffffffff, false],
|
||||
[undefined, null],
|
||||
] as const)('decodes flag %s as %s while preserving missing data', async (flag, enabled) => {
|
||||
const deps = sender(response(member(flag)));
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, target)).resolves.toBe(enabled);
|
||||
});
|
||||
|
||||
it('selects the requested member even when unrelated members are returned first', async () => {
|
||||
const deps = sender(response([...member(64, 'unrelated-member', 10001), ...member(0)]));
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, target)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('accepts UID-bound responses without an optional numeric identity', async () => {
|
||||
const deps = sender(response(m(2, [...m(1, s(2, uid)), ...v(104, 64)])));
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, target)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('preserves large numeric group and member identifiers', async () => {
|
||||
const large = { groupId: 2 ** 40 + 17, userId: 2 ** 41 + 19 };
|
||||
const deps = sender(response(member(0, uid, large.userId), large.groupId));
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, large)).resolves.toBe(true);
|
||||
expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe(
|
||||
env(
|
||||
0xfe7,
|
||||
4,
|
||||
[
|
||||
...v(1, large.groupId),
|
||||
...v(2, 3),
|
||||
...v(3, 0),
|
||||
...m(4, v(104, 1)),
|
||||
...m(5, s(2, uid)),
|
||||
],
|
||||
false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing group', m(4, member(0)), 'missing or mismatched group'],
|
||||
['wrong group', response(member(0), 999), 'missing or mismatched group'],
|
||||
['missing member', response([]), 'exactly one matching member'],
|
||||
['wrong member', response(member(0, 'other-uid')), 'exactly one matching member'],
|
||||
[
|
||||
'missing UID',
|
||||
response(m(2, [...m(1, v(4, target.userId)), ...v(104, 0)])),
|
||||
'exactly one matching member',
|
||||
],
|
||||
[
|
||||
'duplicate member',
|
||||
response([...member(0), ...member(64)]),
|
||||
'exactly one matching member',
|
||||
],
|
||||
['conflicting numeric identity', response(member(0, uid, 999)), 'mismatched member UIN'],
|
||||
] as const)(
|
||||
'rejects %s rather than reporting a successful state',
|
||||
async (_label, body, error) => {
|
||||
const deps = sender([...body]);
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, target)).rejects.toThrow(error);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not send when identity resolution fails', async () => {
|
||||
const deps = sender();
|
||||
deps.resolveUserUid.mockRejectedValueOnce(new Error('identity unavailable'));
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, target)).rejects.toThrow(
|
||||
'identity unavailable',
|
||||
);
|
||||
expect(deps.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates server rejection even if the body contains a valid state', async () => {
|
||||
const deps = sender([...v(3, 1287), ...response(member(0))]);
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, target)).rejects.toThrow('OIDB error 1287');
|
||||
});
|
||||
|
||||
it('propagates transport failures without retrying', async () => {
|
||||
const deps = sender();
|
||||
deps.sendRawPacket.mockRejectedValueOnce(new Error('transport disconnected'));
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, target)).rejects.toThrow(
|
||||
'transport disconnected',
|
||||
);
|
||||
expect(deps.sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects truncated responses', async () => {
|
||||
const deps = sender([0x22, 0x80]);
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, target)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
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 { Oidb0x8a7Req, Oidb0x8a7Resp } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { GetAtAllRemain } from '../../../src/oidb-services/group-admin/get-at-all-remain';
|
||||
|
||||
function makeDeps(body?: Oidb0x8a7Resp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<Oidb0x8a7Resp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
identity: { uin: '10001' } as any,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GetAtAllRemain namespace', () => {
|
||||
it('declares 0x8A7_0', () => {
|
||||
expect(GetAtAllRemain.command).toBe(0x8a7);
|
||||
expect(GetAtAllRemain.subCommand).toBe(0);
|
||||
});
|
||||
|
||||
it('emits the basic{1,2,3}=1,2,1 cookie + bot uin + groupId, routes to 0x8a7_0', async () => {
|
||||
const deps = makeDeps({ canAtAll: true, groupRemain: 12, uinRemain: 5 });
|
||||
await GetAtAllRemain.invoke(deps, { groupId: 12345 });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x8a7_0');
|
||||
const env = protobuf_decode<OidbBase<Oidb0x8a7Req>>(bytes);
|
||||
expect(env.body).toMatchObject({
|
||||
basic1: 1,
|
||||
basic2: 2,
|
||||
basic3: 1,
|
||||
uin: 10001n,
|
||||
groupId: 12345n,
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes canAtAll + groupRemain + uinRemain as plain numbers / boolean', async () => {
|
||||
const deps = makeDeps({ canAtAll: true, groupRemain: 12, uinRemain: 5 });
|
||||
const out = await GetAtAllRemain.invoke(deps, { groupId: 12345 });
|
||||
expect(out).toEqual({
|
||||
can_at_all: true,
|
||||
remain_at_all_count_for_group: 12,
|
||||
remain_at_all_count_for_uin: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to false / 0 when the response body is empty', async () => {
|
||||
const deps = makeDeps({} as any);
|
||||
const out = await GetAtAllRemain.invoke(deps, { groupId: 1 });
|
||||
expect(out).toEqual({
|
||||
can_at_all: false,
|
||||
remain_at_all_count_for_group: 0,
|
||||
remain_at_all_count_for_uin: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
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 {
|
||||
OidbGetGroupExtReq,
|
||||
OidbGetGroupExtResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/group-ext';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { FetchGroupExtInfo } from '../../../src/oidb-services/group-admin/get-group-ext-info';
|
||||
import { env, v, m } from '../_pb-oracle';
|
||||
|
||||
function makeSender(body?: OidbGetGroupExtResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbGetGroupExtResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => result) };
|
||||
}
|
||||
|
||||
describe('FetchGroupExtInfo namespace', () => {
|
||||
it('declares command 0xEF0 sub 1, uin form', () => {
|
||||
expect(FetchGroupExtInfo.command).toBe(0xef0);
|
||||
expect(FetchGroupExtInfo.subCommand).toBe(1);
|
||||
expect(FetchGroupExtInfo.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('byte-oracle: routes to 0xef0_1 and locks {1:groupCode, 2:{29:1,30:1}}', async () => {
|
||||
const sender = makeSender({
|
||||
items: [
|
||||
{
|
||||
groupCode: 12345n,
|
||||
ext: { inviteRobotMemberSwitch: 1, inviteRobotMemberExamine: 2 },
|
||||
},
|
||||
],
|
||||
});
|
||||
await FetchGroupExtInfo.invoke(sender, { groupId: 12345 });
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xef0_1');
|
||||
const filter = [...v(29, 1), ...v(30, 1)];
|
||||
const body = [...v(1, 12345), ...m(2, filter)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xef0, 1, body, true));
|
||||
const envelope = protobuf_decode<OidbBase<OidbGetGroupExtReq>>(bytes);
|
||||
expect(envelope.reserved).toBe(1);
|
||||
expect(envelope.body?.groupCodes).toEqual([12345n]);
|
||||
});
|
||||
|
||||
it('returns robot-add switch/examine from the first item', async () => {
|
||||
const sender = makeSender({
|
||||
items: [
|
||||
{
|
||||
groupCode: 12345n,
|
||||
ext: { inviteRobotMemberSwitch: 1, inviteRobotMemberExamine: 2 },
|
||||
},
|
||||
],
|
||||
});
|
||||
await expect(FetchGroupExtInfo.invoke(sender, { groupId: 12345 })).resolves.toEqual({
|
||||
robotMemberSwitch: 1,
|
||||
robotMemberExamine: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats omitted zeros as 0', async () => {
|
||||
const sender = makeSender({ items: [{ groupCode: 12345n, ext: {} }] });
|
||||
await expect(FetchGroupExtInfo.invoke(sender, { groupId: 12345 })).resolves.toEqual({
|
||||
robotMemberSwitch: 0,
|
||||
robotMemberExamine: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the list is empty', async () => {
|
||||
const sender = makeSender({ items: [] });
|
||||
await expect(FetchGroupExtInfo.invoke(sender, { groupId: 12345 })).rejects.toThrow(
|
||||
/unable to read group robot-add option/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on a non-zero item result', async () => {
|
||||
const sender = makeSender({
|
||||
items: [{ groupCode: 12345n, resultCode: 5, ext: { inviteRobotMemberSwitch: 1 } }],
|
||||
});
|
||||
await expect(FetchGroupExtInfo.invoke(sender, { groupId: 12345 })).rejects.toThrow(
|
||||
/result=5/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbKickMember } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { KickMember } from '../../../src/oidb-services/group-admin/kick-member';
|
||||
import { m, s, v } from '../_pb-oracle';
|
||||
|
||||
/** 24-byte uid: success items on the wire are `{ result:0, uid }` with uid length 0x18. */
|
||||
const TRACE_UID = 'u_abcdefghijklmnopqrstuv';
|
||||
|
||||
function kickResultItem(result: number, uid: string): number[] {
|
||||
return [...v(1, result), ...s(2, uid)];
|
||||
}
|
||||
|
||||
function kickResponseBytes(groupUin: number, items: number[][]): Buffer {
|
||||
const body = [...v(1, groupUin), ...items.flatMap((item) => m(2, item))];
|
||||
return Buffer.from(m(4, body));
|
||||
}
|
||||
|
||||
function makeDeps(responseData: Buffer = Buffer.alloc(0)) {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
resolveUserUid: vi.fn(async () => 'resolved-uid'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('KickMember namespace', () => {
|
||||
it('declares 0x8A0_1', () => {
|
||||
expect(KickMember.command).toBe(0x8a0);
|
||||
expect(KickMember.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves uid per-group and forwards reject + reason', async () => {
|
||||
const deps = makeDeps();
|
||||
await KickMember.invoke(deps, {
|
||||
groupId: 12345,
|
||||
userId: 67890,
|
||||
reject: true,
|
||||
reason: 'bye',
|
||||
});
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x8a0_1');
|
||||
const env = protobuf_decode<OidbBase<OidbKickMember>>(bytes);
|
||||
expect(env.body).toMatchObject({
|
||||
groupUin: 12345,
|
||||
targetUid: 'resolved-uid',
|
||||
rejectAddRequest: true,
|
||||
reason: 'bye',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults reason to empty string', async () => {
|
||||
const deps = makeDeps();
|
||||
await KickMember.invoke(deps, { groupId: 1, userId: 2, reject: false });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbKickMember>>(bytes);
|
||||
expect(env.body?.reason ?? '').toBe('');
|
||||
expect(env.body?.rejectAddRequest ?? false).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an empty body as success', async () => {
|
||||
await expect(
|
||||
KickMember.invoke(makeDeps(), {
|
||||
groupId: 1,
|
||||
userId: 2,
|
||||
reject: false,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats a zero per-member result plus uid as success (#413)', async () => {
|
||||
const bytes = kickResponseBytes(12345, [kickResultItem(0, TRACE_UID)]);
|
||||
const decoded = KickMember.decode(bytes);
|
||||
expect(decoded.body?.groupUin).toBe(12345);
|
||||
expect(decoded.body?.results).toEqual([{ result: 0, uid: TRACE_UID }]);
|
||||
|
||||
await expect(
|
||||
KickMember.invoke(makeDeps(bytes), {
|
||||
groupId: 12345,
|
||||
userId: 67890,
|
||||
reject: false,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a non-zero per-member result with the numeric code, not the uid (#298)', async () => {
|
||||
const bytes = kickResponseBytes(12345, [kickResultItem(7, TRACE_UID)]);
|
||||
await expect(
|
||||
KickMember.invoke(makeDeps(bytes), {
|
||||
groupId: 12345,
|
||||
userId: 67890,
|
||||
reject: false,
|
||||
}),
|
||||
).rejects.toThrow('kick member failed: result=7');
|
||||
});
|
||||
|
||||
it('deserialize: omitted/zero results succeed; non-zero throws', () => {
|
||||
expect(() => KickMember.deserialize({} as never, {})).not.toThrow();
|
||||
expect(() => KickMember.deserialize({} as never, { results: [] })).not.toThrow();
|
||||
expect(() =>
|
||||
KickMember.deserialize({} as never, {
|
||||
results: [{ result: 0, uid: TRACE_UID }],
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
KickMember.deserialize({} as never, {
|
||||
results: [{ uid: TRACE_UID }],
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
KickMember.deserialize({} as never, {
|
||||
results: [{ result: 1, uid: TRACE_UID }],
|
||||
}),
|
||||
).toThrow('kick member failed: result=1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0x8a0Req } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { KickMembers } from '../../../src/oidb-services/group-admin/kick-members';
|
||||
import { m, s, v } from '../_pb-oracle';
|
||||
|
||||
const TRACE_UID_A = 'u_abcdefghijklmnopqrstuv';
|
||||
const TRACE_UID_B = 'u_bbbbbbbbbbbbbbbbbbbbbb';
|
||||
|
||||
function kickResultItem(result: number, uid: string): number[] {
|
||||
return [...v(1, result), ...s(2, uid)];
|
||||
}
|
||||
|
||||
function kickResponseBytes(groupUin: number, items: number[][]): Buffer {
|
||||
const body = [...v(1, groupUin), ...items.flatMap((item) => m(2, item))];
|
||||
return Buffer.from(m(4, body));
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
resolveSequence: string[] = ['uid-a', 'uid-b'],
|
||||
responseData: Buffer = Buffer.alloc(0),
|
||||
) {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
const resolveUserUid = vi.fn();
|
||||
|
||||
for (const uid of resolveSequence) {
|
||||
resolveUserUid.mockResolvedValueOnce(uid);
|
||||
}
|
||||
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
resolveUserUid,
|
||||
};
|
||||
}
|
||||
|
||||
describe('KickMembers namespace', () => {
|
||||
it('declares 0x8A0_1 (same as KickMember; disambiguated by proto body)', () => {
|
||||
expect(KickMembers.command).toBe(0x8a0);
|
||||
expect(KickMembers.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves each uid in parallel and packages targetUids[]', async () => {
|
||||
const deps = makeDeps(['uid-a', 'uid-b']);
|
||||
await KickMembers.invoke(deps, { groupId: 12345, userIds: [11, 22], reject: false });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledTimes(2);
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x8a0_1');
|
||||
const env = protobuf_decode<OidbBase<Oidb0x8a0Req>>(bytes);
|
||||
expect(env.body?.targetUids).toEqual(['uid-a', 'uid-b']);
|
||||
expect(env.body?.groupId).toBe(12345n);
|
||||
expect(env.body?.rejectAddRequest ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('reject=true => rejectAddRequest=1', async () => {
|
||||
const deps = makeDeps(['u']);
|
||||
await KickMembers.invoke(deps, { groupId: 1, userIds: [2], reject: true });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<Oidb0x8a0Req>>(bytes);
|
||||
expect(env.body?.rejectAddRequest).toBe(1);
|
||||
});
|
||||
|
||||
it('treats an empty body as success', async () => {
|
||||
await expect(
|
||||
KickMembers.invoke(makeDeps(['u']), {
|
||||
groupId: 1,
|
||||
userIds: [2],
|
||||
reject: false,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats zero per-member results as success (#413)', async () => {
|
||||
const bytes = kickResponseBytes(12345, [
|
||||
kickResultItem(0, TRACE_UID_A),
|
||||
kickResultItem(0, TRACE_UID_B),
|
||||
]);
|
||||
const decoded = KickMembers.decode(bytes);
|
||||
expect(decoded.body?.results).toEqual([
|
||||
{ result: 0, uid: TRACE_UID_A },
|
||||
{ result: 0, uid: TRACE_UID_B },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
KickMembers.invoke(makeDeps(['uid-a', 'uid-b'], bytes), {
|
||||
groupId: 12345,
|
||||
userIds: [11, 22],
|
||||
reject: false,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects when any per-member result is non-zero, with the numeric code', async () => {
|
||||
const bytes = kickResponseBytes(12345, [
|
||||
kickResultItem(0, TRACE_UID_A),
|
||||
kickResultItem(7, TRACE_UID_B),
|
||||
]);
|
||||
await expect(
|
||||
KickMembers.invoke(makeDeps(['uid-a', 'uid-b'], bytes), {
|
||||
groupId: 12345,
|
||||
userIds: [11, 22],
|
||||
reject: false,
|
||||
}),
|
||||
).rejects.toThrow('kick members failed: result=7');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbLeaveGroup } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { LeaveGroup } from '../../../src/oidb-services/group-admin/leave-group';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('LeaveGroup namespace', () => {
|
||||
it('declares 0x1097_1', () => {
|
||||
expect(LeaveGroup.command).toBe(0x1097);
|
||||
expect(LeaveGroup.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
it('routes to 0x1097_1 with groupUin only', async () => {
|
||||
const deps = makeDeps();
|
||||
await LeaveGroup.invoke(deps, { groupId: 12345 });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x1097_1');
|
||||
const env = protobuf_decode<OidbBase<OidbLeaveGroup>>(bytes);
|
||||
expect(env.body).toMatchObject({ groupUin: 12345 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbModifyGroupExtResp } from '@snowluma/proto-defs/oidb-actions/group-ext';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { ModifyGroupExtInfo } from '../../../src/oidb-services/group-admin/modify-group-ext-info';
|
||||
import { env, v, m } from '../_pb-oracle';
|
||||
|
||||
function makeSender(result = 0) {
|
||||
const respEnv: OidbBase<OidbModifyGroupExtResp> = {
|
||||
command: 0xf00,
|
||||
subCommand: 3,
|
||||
body: { groupCode: 12345, result },
|
||||
};
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<OidbBase<OidbModifyGroupExtResp>>(respEnv)),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('ModifyGroupExtInfo namespace', () => {
|
||||
it('declares command 0xF00 sub 3, uin form', () => {
|
||||
expect(ModifyGroupExtInfo.command).toBe(0xf00);
|
||||
expect(ModifyGroupExtInfo.subCommand).toBe(3);
|
||||
expect(ModifyGroupExtInfo.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('only includes the fields the caller provides', () => {
|
||||
expect(
|
||||
ModifyGroupExtInfo.serialize({} as any, { groupId: 1, robotMemberSwitch: 1 }),
|
||||
).toEqual({
|
||||
groupCode: 1,
|
||||
info: { groupCode: 1, ext: { inviteRobotMemberSwitch: 1 } },
|
||||
});
|
||||
expect(ModifyGroupExtInfo.serialize({} as any, { groupId: 1 })).toEqual({
|
||||
groupCode: 1,
|
||||
info: { groupCode: 1, ext: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it('byte-oracle: routes to 0xf00_3 and locks nested tags {1:gc,2:{1:gc,2:{29:switch,30:examine}}}', async () => {
|
||||
const sender = makeSender();
|
||||
await ModifyGroupExtInfo.invoke(sender, {
|
||||
groupId: 12345,
|
||||
robotMemberSwitch: 1,
|
||||
robotMemberExamine: 2,
|
||||
});
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xf00_3');
|
||||
const ext = [...v(29, 1), ...v(30, 2)];
|
||||
const info = [...v(1, 12345), ...m(2, ext)];
|
||||
const body = [...v(1, 12345), ...m(2, info)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xf00, 3, body, true));
|
||||
});
|
||||
|
||||
it('emits switch=0 on the wire (explicit presence; omit would look like "unchanged")', async () => {
|
||||
const sender = makeSender();
|
||||
await ModifyGroupExtInfo.invoke(sender, { groupId: 12345, robotMemberSwitch: 0 });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const ext = [...v(29, 0)];
|
||||
const info = [...v(1, 12345), ...m(2, ext)];
|
||||
const body = [...v(1, 12345), ...m(2, info)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xf00, 3, body, true));
|
||||
});
|
||||
|
||||
it('emits both zeros when the caller sets switch and examine to 0', async () => {
|
||||
const sender = makeSender();
|
||||
await ModifyGroupExtInfo.invoke(sender, {
|
||||
groupId: 12345,
|
||||
robotMemberSwitch: 0,
|
||||
robotMemberExamine: 0,
|
||||
});
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const ext = [...v(29, 0), ...v(30, 0)];
|
||||
const info = [...v(1, 12345), ...m(2, ext)];
|
||||
const body = [...v(1, 12345), ...m(2, info)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xf00, 3, body, true));
|
||||
});
|
||||
|
||||
it('does not send when neither field is provided', async () => {
|
||||
const sender = makeSender();
|
||||
await ModifyGroupExtInfo.invoke(sender, { groupId: 12345 });
|
||||
expect(sender.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deserialize throws on a non-zero body result', () => {
|
||||
expect(() => ModifyGroupExtInfo.deserialize({} as any, { result: 5 })).toThrow(/result=5/);
|
||||
expect(() => ModifyGroupExtInfo.deserialize({} as any, { result: 0 })).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbMuteAll } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { MuteAll } from '../../../src/oidb-services/group-admin/mute-all';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('MuteAll namespace', () => {
|
||||
it('declares 0x89A_0', () => {
|
||||
expect(MuteAll.command).toBe(0x89a);
|
||||
expect(MuteAll.subCommand).toBe(0);
|
||||
});
|
||||
|
||||
it('enable=true emits the 0xFFFFFFFF "permanent" state', async () => {
|
||||
const deps = makeDeps();
|
||||
await MuteAll.invoke(deps, { groupId: 12345, enable: true });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x89a_0');
|
||||
const env = protobuf_decode<OidbBase<OidbMuteAll>>(bytes);
|
||||
expect(env.body).toMatchObject({ groupUin: 12345, muteState: { state: 0xffffffff } });
|
||||
});
|
||||
|
||||
it('enable=false forces state=0 ONTO the wire (so the server can disambiguate)', async () => {
|
||||
const deps = makeDeps();
|
||||
await MuteAll.invoke(deps, { groupId: 12345, enable: false });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbMuteAll>>(bytes);
|
||||
expect(env.body?.muteState?.state).toBe(0);
|
||||
// The fix (issue #70): state=0 is a `pb_optional<>` field, so it must be
|
||||
// PRESENT on the wire — field 17 (varint tag 0x88 0x01) + value 0x00 — not
|
||||
// omitted. An empty `muteState` is indistinguishable from the other
|
||||
// commands sharing OIDB (0x89A, 0), so the server rejects unmute with 1007.
|
||||
const arr = Array.from(bytes);
|
||||
const hasStateField = arr.some(
|
||||
(_, i) => arr[i] === 0x88 && arr[i + 1] === 0x01 && arr[i + 2] === 0x00,
|
||||
);
|
||||
expect(hasStateField).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbMuteMember } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { MuteMember } from '../../../src/oidb-services/group-admin/mute-member';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
resolveUserUid: vi.fn(async () => 'resolved-uid'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('MuteMember namespace', () => {
|
||||
it('declares 0x1253_1', () => {
|
||||
expect(MuteMember.command).toBe(0x1253);
|
||||
expect(MuteMember.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves uid before encoding and routes to 0x1253_1', async () => {
|
||||
const deps = makeDeps();
|
||||
await MuteMember.invoke(deps, { groupId: 12345, userId: 67890, duration: 600 });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x1253_1');
|
||||
const env = protobuf_decode<OidbBase<OidbMuteMember>>(bytes);
|
||||
expect(env.body).toMatchObject({
|
||||
groupUin: 12345,
|
||||
type: 1,
|
||||
body: { targetUid: 'resolved-uid', duration: 600 },
|
||||
});
|
||||
});
|
||||
|
||||
it('duration = 0 acts as unmute', async () => {
|
||||
const deps = makeDeps();
|
||||
await MuteMember.invoke(deps, { groupId: 1, userId: 2, duration: 0 });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbMuteMember>>(bytes);
|
||||
// duration=0 is the proto3 default and is omitted on the wire.
|
||||
expect(env.body?.body?.duration ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0x89a_0AddOption } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import {
|
||||
isQuestionAddType,
|
||||
SetAddOption,
|
||||
wireAddType,
|
||||
} from '../../../src/oidb-services/group-admin/set-add-option';
|
||||
import { env, v, s, m } from '../_pb-oracle';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('SetAddOption namespace', () => {
|
||||
it('declares 0x89A_0 (shared cmd with MuteAll / SetSearch / SetGroupName)', () => {
|
||||
expect(SetAddOption.command).toBe(0x89a);
|
||||
expect(SetAddOption.subCommand).toBe(0);
|
||||
});
|
||||
|
||||
it('packages settings.addType and routes to 0x89a_0', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetAddOption.invoke(deps, { groupId: 12345, addType: 2 });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x89a_0');
|
||||
const decoded = protobuf_decode<OidbBase<Oidb0x89a_0AddOption>>(bytes);
|
||||
expect(decoded.body).toMatchObject({ groupUin: 12345n, settings: { addType: 2 } });
|
||||
expect(decoded.body?.settings?.groupQuestion ?? undefined).toBeUndefined();
|
||||
expect(decoded.body?.settings?.groupAnswer ?? undefined).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats 4 as question+answer and 5/55 as question-only', () => {
|
||||
expect(isQuestionAddType(4)).toBe(true);
|
||||
expect(isQuestionAddType(5)).toBe(true);
|
||||
expect(isQuestionAddType(55)).toBe(true);
|
||||
expect(isQuestionAddType(2)).toBe(false);
|
||||
expect(wireAddType(55)).toBe(5);
|
||||
expect(wireAddType(4)).toBe(4);
|
||||
});
|
||||
|
||||
it('byte-oracle: type 4 writes question and answer', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetAddOption.invoke(deps, {
|
||||
groupId: 12345,
|
||||
addType: 4,
|
||||
groupQuestion: 'q',
|
||||
groupAnswer: 'a',
|
||||
});
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const settings = [...v(16, 4), ...s(30, 'q'), ...s(31, 'a')];
|
||||
const body = [...v(1, 12345), ...m(2, settings)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0x89a, 0, body, false));
|
||||
});
|
||||
|
||||
it('byte-oracle: type 5 writes question and clears answer', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetAddOption.invoke(deps, {
|
||||
groupId: 12345,
|
||||
addType: 5,
|
||||
groupQuestion: 'why',
|
||||
groupAnswer: 'ignored',
|
||||
});
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const settings = [...v(16, 5), ...s(30, 'why'), ...s(31, '')];
|
||||
const body = [...v(1, 12345), ...m(2, settings)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0x89a, 0, body, false));
|
||||
});
|
||||
|
||||
it('maps add_type 55 onto the official question-only mode', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetAddOption.invoke(deps, { groupId: 1, addType: 55, groupQuestion: 'q' });
|
||||
const decoded = protobuf_decode<OidbBase<Oidb0x89a_0AddOption>>(
|
||||
deps.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(decoded.body?.settings).toMatchObject({
|
||||
addType: 5,
|
||||
groupQuestion: 'q',
|
||||
groupAnswer: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbGroupRequestAction } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetAddRequest } from '../../../src/oidb-services/group-admin/set-add-request';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('SetAddRequest namespace', () => {
|
||||
it('declares 0x10C8 with uinForm=true and dynamic subCommand', () => {
|
||||
expect(SetAddRequest.command).toBe(0x10c8);
|
||||
expect(SetAddRequest.uinForm).toBe(true);
|
||||
expect(
|
||||
SetAddRequest.resolveSubCommand({
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
eventType: 0,
|
||||
approve: true,
|
||||
filtered: false,
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(
|
||||
SetAddRequest.resolveSubCommand({
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
eventType: 0,
|
||||
approve: true,
|
||||
filtered: true,
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('approve=true => accept=1, filtered=false => 0x10c8_1, reserved=1 (uinForm)', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetAddRequest.invoke(deps, {
|
||||
groupId: 12345,
|
||||
sequence: 5,
|
||||
eventType: 1,
|
||||
approve: true,
|
||||
reason: 'ok',
|
||||
});
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x10c8_1');
|
||||
const env = protobuf_decode<OidbBase<OidbGroupRequestAction>>(bytes);
|
||||
expect(env.reserved).toBe(1);
|
||||
expect(env.body?.accept).toBe(1);
|
||||
expect(env.body?.body).toMatchObject({
|
||||
sequence: 5n,
|
||||
eventType: 1,
|
||||
groupUin: 12345,
|
||||
message: 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
it('approve=false + filtered=true => accept=2, 0x10c8_2', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetAddRequest.invoke(deps, {
|
||||
groupId: 12345,
|
||||
sequence: 5,
|
||||
eventType: 1,
|
||||
approve: false,
|
||||
reason: 'no',
|
||||
filtered: true,
|
||||
});
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x10c8_2');
|
||||
const env = protobuf_decode<OidbBase<OidbGroupRequestAction>>(bytes);
|
||||
expect(env.body?.accept).toBe(2);
|
||||
});
|
||||
|
||||
it('writes a space when the reason is empty so field 4 stays present', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetAddRequest.invoke(deps, { groupId: 1, sequence: 1, eventType: 1, approve: true });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupRequestAction>>(bytes);
|
||||
expect(env.body?.body?.message).toBe(' ');
|
||||
const arr = Array.from(bytes);
|
||||
const hasMessageField = arr.some(
|
||||
(_, i) => arr[i] === 0x22 && arr[i + 1] === 0x01 && arr[i + 2] === 0x20,
|
||||
);
|
||||
expect(hasMessageField).toBe(true);
|
||||
});
|
||||
|
||||
it('forwards operateTransInfo as body field 7', async () => {
|
||||
const deps = makeDeps();
|
||||
const operateTransInfo = Uint8Array.from([0x01, 0x02, 0x03]);
|
||||
await SetAddRequest.invoke(deps, {
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
eventType: 1,
|
||||
approve: true,
|
||||
reason: 'ok',
|
||||
operateTransInfo,
|
||||
});
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupRequestAction>>(bytes);
|
||||
expect(env.body?.body?.operateTransInfo).toEqual(operateTransInfo);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbSetAdmin } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetAdmin } from '../../../src/oidb-services/group-admin/set-admin';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
resolveUserUid: vi.fn(async () => 'resolved-uid'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SetAdmin namespace', () => {
|
||||
it('declares 0x1096_1', () => {
|
||||
expect(SetAdmin.command).toBe(0x1096);
|
||||
expect(SetAdmin.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
it('enable=true promotes (isAdmin=true)', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetAdmin.invoke(deps, { groupId: 12345, userId: 67890, enable: true });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x1096_1');
|
||||
const env = protobuf_decode<OidbBase<OidbSetAdmin>>(bytes);
|
||||
expect(env.body).toMatchObject({ groupUin: 12345, uid: 'resolved-uid', isAdmin: true });
|
||||
});
|
||||
|
||||
it('enable=false demotes (isAdmin omitted on the wire)', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetAdmin.invoke(deps, { groupId: 1, userId: 2, enable: false });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbSetAdmin>>(bytes);
|
||||
expect(env.body?.isAdmin ?? false).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbRenameGroup } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetGroupName } from '../../../src/oidb-services/group-admin/set-group-name';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('SetGroupName namespace', () => {
|
||||
it('declares 0x89A_15', () => {
|
||||
expect(SetGroupName.command).toBe(0x89a);
|
||||
expect(SetGroupName.subCommand).toBe(15);
|
||||
});
|
||||
|
||||
it('routes to 0x89a_15 with the new name', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetGroupName.invoke(deps, { groupId: 12345, name: 'newName' });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x89a_15');
|
||||
const env = protobuf_decode<OidbBase<OidbRenameGroup>>(bytes);
|
||||
expect(env.body).toMatchObject({ groupUin: 12345, body: { targetName: 'newName' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0xf16Req } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetGroupRemark } from '../../../src/oidb-services/group-admin/set-group-remark';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
identity: { uin: '10001', updateGroupRemark: vi.fn(() => true) },
|
||||
};
|
||||
}
|
||||
|
||||
describe('SetGroupRemark namespace', () => {
|
||||
it('declares 0xF16_1', () => {
|
||||
expect(SetGroupRemark.command).toBe(0xf16);
|
||||
expect(SetGroupRemark.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
it('routes to 0xf16_1 with groupId + remark wrapped in inner', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetGroupRemark.invoke(deps, { groupId: 12345, remark: 'my group' });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0xf16_1');
|
||||
const env = protobuf_decode<OidbBase<Oidb0xf16Req>>(bytes);
|
||||
expect(env.body?.inner).toMatchObject({ groupId: 12345n, remark: 'my group' });
|
||||
});
|
||||
|
||||
it('updates the local group roster after QQ confirms the remark', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetGroupRemark.invoke(deps as any, { groupId: 12345, remark: 'my group' });
|
||||
|
||||
expect(deps.identity.updateGroupRemark).toHaveBeenCalledWith(12345, 'my group');
|
||||
});
|
||||
|
||||
it('does not update the local roster when QQ rejects the mutation', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.sendRawPacket.mockResolvedValueOnce({
|
||||
success: false,
|
||||
gotResponse: true,
|
||||
errorCode: 120,
|
||||
errorMessage: 'denied',
|
||||
responseData: Buffer.alloc(0),
|
||||
});
|
||||
|
||||
await expect(
|
||||
SetGroupRemark.invoke(deps as any, { groupId: 12345, remark: 'not-applied' }),
|
||||
).rejects.toThrow();
|
||||
expect(deps.identity.updateGroupRemark).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetMemberCard } from '../../../src/oidb-services/group-admin/set-member-card';
|
||||
import { env, m, s, v } from '../_pb-oracle';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn<SetMemberCard.Deps['sendRawPacket']>().mockResolvedValue(r),
|
||||
resolveUserUid: vi.fn(async () => 'resolved-uid'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SetMemberCard namespace', () => {
|
||||
it('declares 0x8FC_3', () => {
|
||||
expect(SetMemberCard.command).toBe(0x8fc);
|
||||
expect(SetMemberCard.subCommand).toBe(3);
|
||||
});
|
||||
|
||||
it.each(['', 'newCard', '群名片'])('sends card %j in field 8', async (card) => {
|
||||
const deps = makeDeps();
|
||||
await SetMemberCard.invoke(deps, { groupId: 12345, userId: 67890, card });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x8fc_3');
|
||||
|
||||
const member = [...s(1, 'resolved-uid'), ...s(8, card)];
|
||||
const body = [...v(1, 12345), ...m(3, member)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0x8fc, 3, body, false));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0x89a_0InvitePolicy } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
import {
|
||||
MEMBER_INVITE_PRIVILEGE_MASK,
|
||||
decodeMemberInvitePolicy,
|
||||
mergeMemberInvitePrivilegeFlag,
|
||||
SetMemberInvitePolicy,
|
||||
type GroupMemberInvitePolicy,
|
||||
} from '../../../src/oidb-services/group-admin/set-member-invite-policy';
|
||||
|
||||
function makeSender() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('SetMemberInvitePolicy namespace', () => {
|
||||
it.each<[GroupMemberInvitePolicy, number, number]>([
|
||||
['disabled', 0x04000000, 0],
|
||||
['require_approval', 0, 1],
|
||||
['no_approval', 0x00100000, 1],
|
||||
['no_approval_under_100', 0x02000000, 1],
|
||||
])('maps %s to the verified privilege bits', async (policy, policyBits, allowMemberInvite) => {
|
||||
const sender = makeSender();
|
||||
const unrelatedBits = 0x80000001;
|
||||
|
||||
await SetMemberInvitePolicy.invoke(sender, {
|
||||
groupId: 12345,
|
||||
currentPrivilegeFlag: unrelatedBits + 0x06100000,
|
||||
policy,
|
||||
});
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x89a_0');
|
||||
const env = protobuf_decode<OidbBase<Oidb0x89a_0InvitePolicy>>(bytes);
|
||||
expect(env.command).toBe(0x89a);
|
||||
expect(env.body?.groupUin).toBe(12345n);
|
||||
expect(env.body?.settings).toEqual({
|
||||
appPrivilegeFlag: unrelatedBits + policyBits,
|
||||
appPrivilegeMask: MEMBER_INVITE_PRIVILEGE_MASK,
|
||||
allowMemberInvite,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves unrelated bits while replacing only the member-invite mask', () => {
|
||||
expect(mergeMemberInvitePrivilegeFlag(0xe7100001, 'require_approval')).toBe(0xe1000001);
|
||||
});
|
||||
|
||||
it.each<[number, GroupMemberInvitePolicy]>([
|
||||
[0x04000000, 'disabled'],
|
||||
[0, 'require_approval'],
|
||||
[0x00100000, 'no_approval'],
|
||||
[0x02000000, 'no_approval_under_100'],
|
||||
[0x80100001, 'no_approval'],
|
||||
[0x84000001, 'disabled'],
|
||||
])('decodes privilege %# as %s', (privilegeFlag, policy) => {
|
||||
expect(decodeMemberInvitePolicy(privilegeFlag)).toBe(policy);
|
||||
});
|
||||
|
||||
it('round-trips each invite policy through merge then decode', () => {
|
||||
const policies: GroupMemberInvitePolicy[] = [
|
||||
'disabled',
|
||||
'require_approval',
|
||||
'no_approval',
|
||||
'no_approval_under_100',
|
||||
];
|
||||
|
||||
for (const policy of policies) {
|
||||
expect(
|
||||
decodeMemberInvitePolicy(mergeMemberInvitePrivilegeFlag(0x80000001, policy)),
|
||||
).toBe(policy);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps explicit zero fields on the wire', async () => {
|
||||
const sender = makeSender();
|
||||
await SetMemberInvitePolicy.invoke(sender, {
|
||||
groupId: 1,
|
||||
currentPrivilegeFlag: MEMBER_INVITE_PRIVILEGE_MASK,
|
||||
policy: 'require_approval',
|
||||
});
|
||||
const env = protobuf_decode<OidbBase<Oidb0x89a_0InvitePolicy>>(
|
||||
sender.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(env.body?.settings?.appPrivilegeFlag).toBe(0);
|
||||
expect(env.body?.settings?.allowMemberInvite).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects an invalid current flag before sending a packet', async () => {
|
||||
const sender = makeSender();
|
||||
await expect(
|
||||
SetMemberInvitePolicy.invoke(sender, {
|
||||
groupId: 1,
|
||||
currentPrivilegeFlag: -1,
|
||||
policy: 'disabled',
|
||||
}),
|
||||
).rejects.toThrow(/unsigned 32-bit/);
|
||||
expect(sender.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0x89a_0MemberPermission } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
import {
|
||||
decodeGroupMemberPermissions,
|
||||
GROUP_MEMBER_PERMISSION_MASKS,
|
||||
mergeGroupMemberPermission,
|
||||
SetMemberPermission,
|
||||
type GroupMemberPermission,
|
||||
} from '../../../src/oidb-services/group-admin/set-member-permission';
|
||||
|
||||
function makeSender() {
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => result) };
|
||||
}
|
||||
|
||||
describe('SetMemberPermission namespace', () => {
|
||||
it('declares 0x89A_0', () => {
|
||||
expect(SetMemberPermission.command).toBe(0x89a);
|
||||
expect(SetMemberPermission.subCommand).toBe(0);
|
||||
});
|
||||
|
||||
it.each<[GroupMemberPermission, number]>([
|
||||
['upload_album', 0x1],
|
||||
['temporary_session', 0x10000],
|
||||
['create_group', 0x8000],
|
||||
])('uses the verified deny-bit mask for %s', async (permission, mask) => {
|
||||
const sender = makeSender();
|
||||
|
||||
await SetMemberPermission.invoke(sender, {
|
||||
groupId: 12345,
|
||||
currentPrivilegeFlag: 0x80018001,
|
||||
permission,
|
||||
allow: true,
|
||||
});
|
||||
|
||||
const [command, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(command).toBe('OidbSvcTrpcTcp.0x89a_0');
|
||||
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0MemberPermission>>(bytes);
|
||||
expect(envelope.command).toBe(0x89a);
|
||||
expect(envelope.subCommand ?? 0).toBe(0);
|
||||
expect(envelope.body).toMatchObject({
|
||||
groupUin: 12345n,
|
||||
settings: {
|
||||
appPrivilegeFlag: Number(BigInt(0x80018001) & ~BigInt(mask)),
|
||||
appPrivilegeMask: mask,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('sets a deny bit when a capability is disabled', async () => {
|
||||
const sender = makeSender();
|
||||
await SetMemberPermission.invoke(sender, {
|
||||
groupId: 12345,
|
||||
currentPrivilegeFlag: 0x80000000,
|
||||
permission: 'temporary_session',
|
||||
allow: false,
|
||||
});
|
||||
|
||||
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0MemberPermission>>(
|
||||
sender.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(envelope.body?.settings).toEqual({
|
||||
appPrivilegeFlag: 0x80010000,
|
||||
appPrivilegeMask: GROUP_MEMBER_PERMISSION_MASKS.temporary_session,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves unrelated bits while changing only the selected capability', () => {
|
||||
expect(mergeGroupMemberPermission(0xf0018001, 'upload_album', true)).toBe(0xf0018000);
|
||||
expect(mergeGroupMemberPermission(0xf0000000, 'create_group', false)).toBe(0xf0008000);
|
||||
});
|
||||
|
||||
it('treats permission bits as deny flags when decoding', () => {
|
||||
expect(decodeGroupMemberPermissions(0)).toEqual({
|
||||
allowMemberUploadAlbum: true,
|
||||
allowMemberTemporarySession: true,
|
||||
allowMemberCreateGroup: true,
|
||||
});
|
||||
expect(decodeGroupMemberPermissions(0x80018001)).toEqual({
|
||||
allowMemberUploadAlbum: false,
|
||||
allowMemberTemporarySession: false,
|
||||
allowMemberCreateGroup: false,
|
||||
});
|
||||
expect(decodeGroupMemberPermissions(0x80000000)).toEqual({
|
||||
allowMemberUploadAlbum: true,
|
||||
allowMemberTemporarySession: true,
|
||||
allowMemberCreateGroup: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips each permission through merge then decode', () => {
|
||||
let flag = 0x80000000;
|
||||
flag = mergeGroupMemberPermission(flag, 'upload_album', false);
|
||||
flag = mergeGroupMemberPermission(flag, 'temporary_session', true);
|
||||
flag = mergeGroupMemberPermission(flag, 'create_group', false);
|
||||
expect(decodeGroupMemberPermissions(flag)).toEqual({
|
||||
allowMemberUploadAlbum: false,
|
||||
allowMemberTemporarySession: true,
|
||||
allowMemberCreateGroup: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps explicit zero fields on the wire', async () => {
|
||||
const sender = makeSender();
|
||||
await SetMemberPermission.invoke(sender, {
|
||||
groupId: 1,
|
||||
currentPrivilegeFlag: 1,
|
||||
permission: 'upload_album',
|
||||
allow: true,
|
||||
});
|
||||
|
||||
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0MemberPermission>>(
|
||||
sender.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(envelope.body?.settings?.appPrivilegeFlag).toBe(0);
|
||||
expect(envelope.body?.settings?.appPrivilegeMask).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects an invalid current flag before sending', async () => {
|
||||
const sender = makeSender();
|
||||
await expect(
|
||||
SetMemberPermission.invoke(sender, {
|
||||
groupId: 1,
|
||||
currentPrivilegeFlag: 0x1_0000_0000,
|
||||
permission: 'upload_album',
|
||||
allow: true,
|
||||
}),
|
||||
).rejects.toThrow(/unsigned 32-bit/);
|
||||
expect(sender.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0x89a_0HistoryVisibility } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
import {
|
||||
decodeGroupHistoryVisibility,
|
||||
GROUP_HISTORY_VISIBILITY_MASK,
|
||||
mergeGroupHistoryVisibility,
|
||||
SetNewMemberHistoryVisibility,
|
||||
} from '../../../src/oidb-services/group-admin/set-new-member-history-visibility';
|
||||
|
||||
function makeSender() {
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => result) };
|
||||
}
|
||||
|
||||
describe('SetNewMemberHistoryVisibility namespace', () => {
|
||||
it.each([
|
||||
[true, GROUP_HISTORY_VISIBILITY_MASK],
|
||||
[false, 0],
|
||||
])('writes visible=%s as the mutation bit only', async (visible, expected) => {
|
||||
const sender = makeSender();
|
||||
|
||||
await SetNewMemberHistoryVisibility.invoke(sender, {
|
||||
groupId: 12345,
|
||||
visible,
|
||||
});
|
||||
|
||||
const [command, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(command).toBe('OidbSvcTrpcTcp.0x89a_0');
|
||||
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0HistoryVisibility>>(bytes);
|
||||
expect(envelope.command).toBe(0x89a);
|
||||
expect(envelope.body).toMatchObject({
|
||||
groupUin: 12345n,
|
||||
settings: {
|
||||
groupFlagExt4: expected,
|
||||
groupFlagExt4Mask: GROUP_HISTORY_VISIBILITY_MASK,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('merges only the verified history-visibility bit', () => {
|
||||
expect(mergeGroupHistoryVisibility(0xf0000001, true)).toBe(0xf0000005);
|
||||
expect(mergeGroupHistoryVisibility(0xf0000005, false)).toBe(0xf0000001);
|
||||
});
|
||||
|
||||
it('decodes the history-visibility switch from a 0/1 reply or the mutation bit', () => {
|
||||
expect(decodeGroupHistoryVisibility(0)).toBe(false);
|
||||
expect(decodeGroupHistoryVisibility(1)).toBe(true);
|
||||
expect(decodeGroupHistoryVisibility(GROUP_HISTORY_VISIBILITY_MASK)).toBe(true);
|
||||
expect(decodeGroupHistoryVisibility(0x80000001)).toBe(false);
|
||||
expect(decodeGroupHistoryVisibility(0x80000005)).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trips history visibility through merge then decode', () => {
|
||||
expect(decodeGroupHistoryVisibility(mergeGroupHistoryVisibility(0x80000001, true))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(decodeGroupHistoryVisibility(mergeGroupHistoryVisibility(0x80000005, false))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat a 0/1 detail reply as a missing mutation bit (#387)', () => {
|
||||
expect(decodeGroupHistoryVisibility(1)).toBe(true);
|
||||
expect(decodeGroupHistoryVisibility(mergeGroupHistoryVisibility(0, true))).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an explicit zero value on the wire', async () => {
|
||||
const sender = makeSender();
|
||||
await SetNewMemberHistoryVisibility.invoke(sender, {
|
||||
groupId: 1,
|
||||
visible: false,
|
||||
});
|
||||
|
||||
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0HistoryVisibility>>(
|
||||
sender.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(envelope.body?.settings?.groupFlagExt4).toBe(0);
|
||||
expect(envelope.body?.settings?.groupFlagExt4Mask).toBe(GROUP_HISTORY_VISIBILITY_MASK);
|
||||
});
|
||||
|
||||
it('rejects an invalid current flag when merging', () => {
|
||||
expect(() => mergeGroupHistoryVisibility(-1, true)).toThrow(/unsigned 32-bit/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0x89a_0Search } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetSearch } from '../../../src/oidb-services/group-admin/set-search';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('SetSearch namespace', () => {
|
||||
it('declares 0x89A_0 (shared cmd shape with other 0x89a_0 cmds)', () => {
|
||||
expect(SetSearch.command).toBe(0x89a);
|
||||
expect(SetSearch.subCommand).toBe(0);
|
||||
});
|
||||
|
||||
it('emits an empty settings byte string and routes to 0x89a_0', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetSearch.invoke(deps, { groupId: 12345 });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x89a_0');
|
||||
const env = protobuf_decode<OidbBase<Oidb0x89a_0Search>>(bytes);
|
||||
expect(env.body?.groupUin).toBe(12345n);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbSpecialTitle } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetSpecialTitle } from '../../../src/oidb-services/group-admin/set-special-title';
|
||||
import { env, v, s, m } from '../_pb-oracle';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
resolveUserUid: vi.fn(async () => 'resolved-uid'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SetSpecialTitle namespace', () => {
|
||||
it('declares 0x8FC_2', () => {
|
||||
expect(SetSpecialTitle.command).toBe(0x8fc);
|
||||
expect(SetSpecialTitle.subCommand).toBe(2);
|
||||
});
|
||||
|
||||
it('routes to 0x8fc_2 with -1 (permanent) expireTime sentinel', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetSpecialTitle.invoke(deps, { groupId: 12345, userId: 67890, title: 'crown' });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x8fc_2');
|
||||
const env = protobuf_decode<OidbBase<OidbSpecialTitle>>(bytes);
|
||||
expect(env.body?.groupUin).toBe(12345);
|
||||
expect(env.body?.body?.targetUid).toBe('resolved-uid');
|
||||
expect(env.body?.body?.specialTitle).toBe('crown');
|
||||
// uinName (tag 7) must mirror specialTitle or the server silently no-ops.
|
||||
expect(env.body?.body?.uinName).toBe('crown');
|
||||
// int_32 preserves the signed -1 sentinel after the wire round-trip.
|
||||
expect(env.body?.body?.expireTime).toBe(-1);
|
||||
});
|
||||
|
||||
it.each(['', ' ', '龙王👑'])('preserves title %j in both wire fields', async (title) => {
|
||||
const deps = makeDeps();
|
||||
await SetSpecialTitle.invoke(deps, { groupId: 12345, userId: 67890, title });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x8fc_2');
|
||||
|
||||
// Empty titles must emit tag 5 + length 0 and tag 7 + length 0:
|
||||
// omitting either field turns clearing the title into a silent no-op (#517).
|
||||
const member = [
|
||||
...s(1, 'resolved-uid'),
|
||||
...s(5, title),
|
||||
...v(6, 0xffffffff),
|
||||
...s(7, title),
|
||||
];
|
||||
const body = [...v(1, 12345), ...m(3, member)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0x8fc, 2, body, false));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
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 {
|
||||
OidbGroupFileFolderReq,
|
||||
OidbGroupFileFolderResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/group-file';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { CreateGroupFolder } from '../../../src/oidb-services/group-file/create-group-folder';
|
||||
|
||||
function makeDeps(body?: OidbGroupFileFolderResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileFolderResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('CreateGroupFolder namespace', () => {
|
||||
it('declares 0x6D7_0 with uinForm=true', () => {
|
||||
expect(CreateGroupFolder.command).toBe(0x6d7);
|
||||
expect(CreateGroupFolder.subCommand).toBe(0);
|
||||
expect(CreateGroupFolder.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('packages create.{groupUin, rootDirectory, folderName}', async () => {
|
||||
const deps = makeDeps({ create: {} as any });
|
||||
await CreateGroupFolder.invoke(deps, { groupId: 12345, parentId: '/', folderName: 'docs' });
|
||||
const env = protobuf_decode<OidbBase<OidbGroupFileFolderReq>>(
|
||||
deps.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(env.body?.create).toMatchObject({
|
||||
groupUin: 12345,
|
||||
rootDirectory: '/',
|
||||
folderName: 'docs',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on retcode != 0 (note: lower-case retcode in this proto)', async () => {
|
||||
const deps = makeDeps({ create: { retcode: 7, retMsg: 'denied' } as any });
|
||||
await expect(
|
||||
CreateGroupFolder.invoke(deps, { groupId: 1, parentId: '/', folderName: 'f' }),
|
||||
).rejects.toThrow(/code=7/);
|
||||
});
|
||||
|
||||
it('returns the new folder info from the response folderInfo (#195)', async () => {
|
||||
const deps = makeDeps({
|
||||
create: {
|
||||
retcode: 0,
|
||||
folderInfo: {
|
||||
folderId: '/0f1e2d',
|
||||
folderPath: '/docs',
|
||||
folderName: 'docs',
|
||||
createTime: 100,
|
||||
modifyTime: 200,
|
||||
createUin: 111,
|
||||
modifyUin: 222,
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const r = await CreateGroupFolder.invoke(deps, {
|
||||
groupId: 1,
|
||||
parentId: '/',
|
||||
folderName: 'docs',
|
||||
});
|
||||
expect(r).toEqual({
|
||||
folderId: '/0f1e2d',
|
||||
folderName: 'docs',
|
||||
folderPath: '/docs',
|
||||
createTime: 100,
|
||||
modifyTime: 200,
|
||||
createUin: 111,
|
||||
modifyUin: 222,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults every field when the response omits folderInfo (#195)', async () => {
|
||||
const deps = makeDeps({ create: { retcode: 0 } as any });
|
||||
const r = await CreateGroupFolder.invoke(deps, {
|
||||
groupId: 1,
|
||||
parentId: '/',
|
||||
folderName: 'f',
|
||||
});
|
||||
expect(r).toEqual({
|
||||
folderId: '',
|
||||
folderName: '',
|
||||
folderPath: '',
|
||||
createTime: 0,
|
||||
modifyTime: 0,
|
||||
createUin: 0,
|
||||
modifyUin: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
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 {
|
||||
OidbGroupFileReq,
|
||||
OidbGroupFileResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/group-file';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { DeleteGroupFile } from '../../../src/oidb-services/group-file/delete-group-file';
|
||||
|
||||
function makeDeps(body?: OidbGroupFileResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('DeleteGroupFile namespace', () => {
|
||||
it('declares 0x6D6_3 with uinForm=true', () => {
|
||||
expect(DeleteGroupFile.command).toBe(0x6d6);
|
||||
expect(DeleteGroupFile.subCommand).toBe(3);
|
||||
expect(DeleteGroupFile.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('packages busId=102 and routes to 0x6d6_3', async () => {
|
||||
const deps = makeDeps({ delete: {} as any });
|
||||
await DeleteGroupFile.invoke(deps, { groupId: 12345, fileId: 'fid' });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x6d6_3');
|
||||
const env = protobuf_decode<OidbBase<OidbGroupFileReq>>(bytes);
|
||||
expect(env.body?.delete).toMatchObject({ groupUin: 12345, busId: 102, fileId: 'fid' });
|
||||
});
|
||||
|
||||
it('throws on missing delete sub-message', async () => {
|
||||
const deps = makeDeps({});
|
||||
await expect(DeleteGroupFile.invoke(deps, { groupId: 1, fileId: 'f' })).rejects.toThrow(
|
||||
/delete response missing/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on non-zero retCode', async () => {
|
||||
const deps = makeDeps({ delete: { retCode: 5, retMsg: 'no perms' } as any });
|
||||
await expect(DeleteGroupFile.invoke(deps, { groupId: 1, fileId: 'f' })).rejects.toThrow(
|
||||
/code=5/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
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 {
|
||||
OidbGroupFileFolderReq,
|
||||
OidbGroupFileFolderResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/group-file';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { DeleteGroupFolder } from '../../../src/oidb-services/group-file/delete-group-folder';
|
||||
|
||||
function makeDeps(body?: OidbGroupFileFolderResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileFolderResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('DeleteGroupFolder namespace', () => {
|
||||
it('declares 0x6D7_1 with uinForm=true', () => {
|
||||
expect(DeleteGroupFolder.command).toBe(0x6d7);
|
||||
expect(DeleteGroupFolder.subCommand).toBe(1);
|
||||
expect(DeleteGroupFolder.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('packages delete.{groupUin, folderId}', async () => {
|
||||
const deps = makeDeps({ delete: {} as any });
|
||||
await DeleteGroupFolder.invoke(deps, { groupId: 12345, folderId: 'fid' });
|
||||
const env = protobuf_decode<OidbBase<OidbGroupFileFolderReq>>(
|
||||
deps.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(env.body?.delete).toMatchObject({ groupUin: 12345, folderId: 'fid' });
|
||||
});
|
||||
|
||||
it('throws on missing delete sub-message', async () => {
|
||||
const deps = makeDeps({});
|
||||
await expect(DeleteGroupFolder.invoke(deps, { groupId: 1, folderId: 'f' })).rejects.toThrow(
|
||||
/folder delete response missing/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
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 { OidbOfflineFileFinalizeResp } from '@snowluma/proto-defs/oidb-actions/media';
|
||||
import type { FileExtra } from '@snowluma/proto-defs/message';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FinalizeOfflineFile } from '../../../src/oidb-services/group-file/finalize-offline-file';
|
||||
import { env, m, s, v } from '../_pb-oracle';
|
||||
|
||||
// `bytes` fields are wire type 2 — identical framing to a sub-message, so the
|
||||
// oracle's `m()` (tag + length + payload) doubles as the bytes emitter.
|
||||
const b = m;
|
||||
|
||||
function makeDeps(resp?: OidbOfflineFileFinalizeResp) {
|
||||
const responseData =
|
||||
resp !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbOfflineFileFinalizeResp>>({ body: resp }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FinalizeOfflineFile (0xE37_800)', () => {
|
||||
it('declares 0xE37_800', () => {
|
||||
expect(FinalizeOfflineFile.command).toBe(0xe37);
|
||||
expect(FinalizeOfflineFile.subCommand).toBe(800);
|
||||
});
|
||||
|
||||
it('serializes the request body byte-for-byte (oracle locks every tag)', async () => {
|
||||
const deps = makeDeps({});
|
||||
await FinalizeOfflineFile.invoke(deps, {
|
||||
senderUid: 'su',
|
||||
receiverUid: 'ru',
|
||||
fileUuid: 'uuid',
|
||||
fileHash: 'hh',
|
||||
});
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0xe37_800');
|
||||
|
||||
// body(10) { senderUid(10) receiverUid(20) fileUuid(30) fileHash(40) }
|
||||
const innerBody = [...s(10, 'su'), ...s(20, 'ru'), ...s(30, 'uuid'), ...s(40, 'hh')];
|
||||
// req: subCommand(1)=800, field2(2)=0 omitted, body(10), field101(101)=3,
|
||||
// field102(102)=1, field200(200)=1
|
||||
const req = [...v(1, 800), ...m(10, innerBody), ...v(101, 3), ...v(102, 1), ...v(200, 1)];
|
||||
const expected = env(0xe37, 800, req, false);
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(expected);
|
||||
});
|
||||
|
||||
it('extracts the download-routing metadata from field30 of the response', async () => {
|
||||
const deps = makeDeps({
|
||||
body: {
|
||||
metadata: {
|
||||
field3: 5,
|
||||
field100: new Uint8Array([9, 9]),
|
||||
field101: new Uint8Array([8]),
|
||||
field110: 7,
|
||||
timestamp1: 42,
|
||||
},
|
||||
},
|
||||
});
|
||||
const meta = await FinalizeOfflineFile.invoke(deps, {
|
||||
senderUid: 'su',
|
||||
receiverUid: 'ru',
|
||||
fileUuid: 'uuid',
|
||||
fileHash: 'hh',
|
||||
});
|
||||
expect(meta.field3).toBe(5);
|
||||
expect(meta.field110).toBe(7);
|
||||
expect(meta.timestamp1).toBe(42);
|
||||
expect(Array.from(meta.field100 ?? [])).toEqual([9, 9]);
|
||||
expect(Array.from(meta.field101 ?? [])).toEqual([8]);
|
||||
});
|
||||
|
||||
it('returns {} when the response carries no metadata', async () => {
|
||||
const deps = makeDeps({});
|
||||
const meta = await FinalizeOfflineFile.invoke(deps, {
|
||||
senderUid: 'su',
|
||||
receiverUid: 'ru',
|
||||
fileUuid: 'uuid',
|
||||
fileHash: 'hh',
|
||||
});
|
||||
expect(meta).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileExtra.field6 (c2c download routing — issue #157)', () => {
|
||||
it('encodes field6.field2 with the exact NapCat tag layout', () => {
|
||||
const extra: FileExtra = {
|
||||
field6: {
|
||||
field2: {
|
||||
field1: 7,
|
||||
fileUuid: 'uu',
|
||||
fileName: 'n.bin',
|
||||
field6: 3,
|
||||
field7: new Uint8Array([1, 2]),
|
||||
field8: new Uint8Array([3]),
|
||||
timestamp1: 99,
|
||||
fileHash: 'hh',
|
||||
selfUid: 'su',
|
||||
destUid: 'du',
|
||||
},
|
||||
},
|
||||
};
|
||||
const hex = Buffer.from(protobuf_encode<FileExtra>(extra)).toString('hex');
|
||||
|
||||
// PrivateFileExtraField2 in ascending tag order:
|
||||
// field1(1) fileUuid(4) fileName(5) field6(6) field7(7,bytes)
|
||||
// field8(8,bytes) timestamp1(9) fileHash(14) selfUid(15) destUid(16)
|
||||
const f2 = [
|
||||
...v(1, 7),
|
||||
...s(4, 'uu'),
|
||||
...s(5, 'n.bin'),
|
||||
...v(6, 3),
|
||||
...b(7, [1, 2]),
|
||||
...b(8, [3]),
|
||||
...v(9, 99),
|
||||
...s(14, 'hh'),
|
||||
...s(15, 'su'),
|
||||
...s(16, 'du'),
|
||||
];
|
||||
// PrivateFileExtra { field2(2) } → FileExtra { field6(6) }
|
||||
const expected = Buffer.from(m(6, m(2, f2))).toString('hex');
|
||||
expect(hex).toBe(expected);
|
||||
});
|
||||
|
||||
it('round-trips field6 through encode/decode', () => {
|
||||
const extra: FileExtra = {
|
||||
file: { fileUuid: 'u', fileName: 'f', subcmd: 1 },
|
||||
field6: {
|
||||
field2: {
|
||||
field1: 1,
|
||||
fileUuid: 'u',
|
||||
fileName: 'f',
|
||||
timestamp1: 5,
|
||||
selfUid: 's',
|
||||
destUid: 'd',
|
||||
},
|
||||
},
|
||||
};
|
||||
const back = protobuf_decode<FileExtra>(protobuf_encode<FileExtra>(extra));
|
||||
expect(back.field6?.field2).toMatchObject({
|
||||
field1: 1,
|
||||
fileUuid: 'u',
|
||||
fileName: 'f',
|
||||
timestamp1: 5,
|
||||
selfUid: 's',
|
||||
destUid: 'd',
|
||||
});
|
||||
expect(back.file?.subcmd).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
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 {
|
||||
OidbGroupFileCountViewReq,
|
||||
OidbGroupFileCountViewResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/group-file';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { GetGroupFileCount } from '../../../src/oidb-services/group-file/get-group-file-count';
|
||||
|
||||
function makeDeps(body?: OidbGroupFileCountViewResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileCountViewResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('GetGroupFileCount namespace', () => {
|
||||
it('declares 0x6D8_2 (Count subcommand)', () => {
|
||||
expect(GetGroupFileCount.command).toBe(0x6d8);
|
||||
expect(GetGroupFileCount.subCommand).toBe(2);
|
||||
});
|
||||
|
||||
it('routes to 0x6d8_2 with groupUin + appId=7 + busId=6 (#196)', async () => {
|
||||
const deps = makeDeps({ count: { fileCount: 1, maxCount: 100 } });
|
||||
await GetGroupFileCount.invoke(deps, { groupId: 12345 });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x6d8_2');
|
||||
const env = protobuf_decode<OidbBase<OidbGroupFileCountViewReq>>(bytes);
|
||||
expect(env.body?.count).toMatchObject({ groupUin: 12345, appId: 7, busId: 6 });
|
||||
});
|
||||
|
||||
it('decodes fileCount (field 4) + maxCount (field 6) as plain numbers', async () => {
|
||||
const deps = makeDeps({ count: { fileCount: 42, maxCount: 1000 } });
|
||||
const out = await GetGroupFileCount.invoke(deps, { groupId: 1 });
|
||||
expect(out).toEqual({ fileCount: 42, maxCount: 1000 });
|
||||
});
|
||||
|
||||
it('falls back to maxCount=10000 when the server elides it', async () => {
|
||||
const deps = makeDeps({ count: {} });
|
||||
const out = await GetGroupFileCount.invoke(deps, { groupId: 1 });
|
||||
expect(out).toEqual({ fileCount: 0, maxCount: 10000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
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 {
|
||||
OidbGroupFileCountViewReq,
|
||||
OidbGroupFileCountViewResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/group-file';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { GetGroupFileSpace } from '../../../src/oidb-services/group-file/get-group-file-space';
|
||||
|
||||
function makeDeps(body?: OidbGroupFileCountViewResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileCountViewResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('GetGroupFileSpace namespace (#196)', () => {
|
||||
it('declares 0x6D8_3 (Space subcommand)', () => {
|
||||
expect(GetGroupFileSpace.command).toBe(0x6d8);
|
||||
expect(GetGroupFileSpace.subCommand).toBe(3);
|
||||
});
|
||||
|
||||
it('routes to 0x6d8_3 with a space request (groupUin + appId=7)', async () => {
|
||||
const deps = makeDeps({ space: {} });
|
||||
await GetGroupFileSpace.invoke(deps, { groupId: 12345 });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x6d8_3');
|
||||
const env = protobuf_decode<OidbBase<OidbGroupFileCountViewReq>>(bytes);
|
||||
expect(env.body?.space).toMatchObject({ groupUin: 12345, appId: 7 });
|
||||
expect(env.body?.count).toBeNull();
|
||||
});
|
||||
|
||||
it('decodes usedSpace (field 5) + totalSpace (field 4) as plain numbers', async () => {
|
||||
const deps = makeDeps({ space: { usedSpace: 20103729n, totalSpace: 10737418240n } });
|
||||
const out = await GetGroupFileSpace.invoke(deps, { groupId: 1 });
|
||||
expect(out).toEqual({ usedSpace: 20103729, totalSpace: 10737418240 });
|
||||
});
|
||||
|
||||
it('defaults to 0 when the server elides the space block', async () => {
|
||||
const deps = makeDeps({ space: {} });
|
||||
const out = await GetGroupFileSpace.invoke(deps, { groupId: 1 });
|
||||
expect(out).toEqual({ usedSpace: 0, totalSpace: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
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 {
|
||||
OidbGroupFileReq,
|
||||
OidbGroupFileResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/group-file';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { GetGroupFileUrl } from '../../../src/oidb-services/group-file/get-group-file-url';
|
||||
|
||||
function makeDeps(body?: OidbGroupFileResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('GetGroupFileUrl namespace', () => {
|
||||
it('declares 0x6D6_2 with uinForm=true', () => {
|
||||
expect(GetGroupFileUrl.command).toBe(0x6d6);
|
||||
expect(GetGroupFileUrl.subCommand).toBe(2);
|
||||
expect(GetGroupFileUrl.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('packages the download slot with the supplied busId', async () => {
|
||||
const deps = makeDeps({
|
||||
download: { downloadDns: 'cdn', downloadUrl: new Uint8Array([1, 2]) } as any,
|
||||
});
|
||||
await GetGroupFileUrl.invoke(deps, { groupId: 12345, fileId: 'fid', busId: 102 });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x6d6_2');
|
||||
const env = protobuf_decode<OidbBase<OidbGroupFileReq>>(bytes);
|
||||
expect(env.body?.download).toMatchObject({
|
||||
groupUin: 12345,
|
||||
appId: 7,
|
||||
busId: 102,
|
||||
fileId: 'fid',
|
||||
});
|
||||
});
|
||||
|
||||
it('composes the https ftn_handler URL and checks download.retCode', async () => {
|
||||
const deps = makeDeps({
|
||||
download: { downloadDns: 'cdn', downloadUrl: new Uint8Array([0x01, 0x02]) } as any,
|
||||
});
|
||||
await expect(
|
||||
GetGroupFileUrl.invoke(deps, { groupId: 1, fileId: 'fid-xyz', busId: 102 }),
|
||||
).resolves.toBe('https://cdn/ftn_handler/0102/?fname=fid-xyz');
|
||||
});
|
||||
|
||||
it('throws when download.retCode is non-zero', async () => {
|
||||
const deps = makeDeps({
|
||||
download: {
|
||||
retCode: 1,
|
||||
retMsg: 'denied',
|
||||
downloadDns: 'cdn',
|
||||
downloadUrl: new Uint8Array([1]),
|
||||
} as any,
|
||||
});
|
||||
await expect(
|
||||
GetGroupFileUrl.invoke(deps, { groupId: 1, fileId: 'f', busId: 102 }),
|
||||
).rejects.toThrow(/group file url failed: code=1/);
|
||||
});
|
||||
|
||||
it('throws when the download sub-message is missing', async () => {
|
||||
const deps = makeDeps({});
|
||||
await expect(
|
||||
GetGroupFileUrl.invoke(deps, { groupId: 1, fileId: 'f', busId: 102 }),
|
||||
).rejects.toThrow(/url response missing/);
|
||||
});
|
||||
|
||||
it('falls back to downloadIp when downloadDns is empty', async () => {
|
||||
const deps = makeDeps({
|
||||
download: { downloadIp: '1.2.3.4', downloadUrl: new Uint8Array([0xab]) } as any,
|
||||
});
|
||||
await expect(
|
||||
GetGroupFileUrl.invoke(deps, { groupId: 1, fileId: 'fid', busId: 102 }),
|
||||
).resolves.toBe('https://1.2.3.4/ftn_handler/AB/?fname=fid');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
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 { GetGroupPttUrl } from '../../../src/oidb-services/group-file/get-group-ptt-url';
|
||||
|
||||
function makeDeps(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('GetGroupPttUrl namespace', () => {
|
||||
it('declares 0x126E_200 with uinForm=true', () => {
|
||||
expect(GetGroupPttUrl.command).toBe(0x126e);
|
||||
expect(GetGroupPttUrl.subCommand).toBe(200);
|
||||
expect(GetGroupPttUrl.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('emits scene{requestType=1, businessType=3, sceneType=2, group.groupUin}', async () => {
|
||||
const deps = makeDeps({
|
||||
respHead: { retCode: 0 },
|
||||
download: { info: { domain: 'd', urlPath: '/p' }, rKeyParam: '?rk' },
|
||||
} as any);
|
||||
await GetGroupPttUrl.invoke(deps, { groupId: 12345, node: { fileUuid: 'uuid' } });
|
||||
const env = protobuf_decode<OidbBase<NTV2RichMediaReq>>(
|
||||
deps.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(env.body?.reqHead?.scene).toMatchObject({
|
||||
requestType: 1,
|
||||
businessType: 3,
|
||||
sceneType: 2,
|
||||
group: { groupUin: 12345 },
|
||||
});
|
||||
});
|
||||
|
||||
it('composes the https://domain<path><rkey> URL', async () => {
|
||||
const deps = makeDeps({
|
||||
respHead: { retCode: 0 },
|
||||
download: { info: { domain: 'cdn.qq', urlPath: '/x' }, rKeyParam: '?r=1' },
|
||||
} as any);
|
||||
const out = await GetGroupPttUrl.invoke(deps, { groupId: 1, node: { fileUuid: 'uuid' } });
|
||||
expect(out).toBe('https://cdn.qq/x?r=1');
|
||||
});
|
||||
|
||||
it('throws when fileUuid is missing on the node', async () => {
|
||||
const deps = makeDeps({} as any);
|
||||
await expect(GetGroupPttUrl.invoke(deps, { groupId: 1, node: {} })).rejects.toThrow(
|
||||
/fileUuid is required/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
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 { GetGroupVideoUrl } from '../../../src/oidb-services/group-file/get-group-video-url';
|
||||
|
||||
function makeDeps(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('GetGroupVideoUrl namespace', () => {
|
||||
it('declares 0x11EA_200 with uinForm=true', () => {
|
||||
expect(GetGroupVideoUrl.command).toBe(0x11ea);
|
||||
expect(GetGroupVideoUrl.subCommand).toBe(200);
|
||||
expect(GetGroupVideoUrl.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('emits scene{requestType=2, businessType=2, sceneType=2, group}', async () => {
|
||||
const deps = makeDeps({
|
||||
respHead: { retCode: 0 },
|
||||
download: { info: { domain: 'd', urlPath: '/v' }, rKeyParam: '' },
|
||||
} as any);
|
||||
await GetGroupVideoUrl.invoke(deps, { groupId: 12345, node: { fileUuid: 'uuid' } });
|
||||
const env = protobuf_decode<OidbBase<NTV2RichMediaReq>>(
|
||||
deps.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(env.body?.reqHead?.scene).toMatchObject({
|
||||
requestType: 2,
|
||||
businessType: 2,
|
||||
sceneType: 2,
|
||||
group: { groupUin: 12345 },
|
||||
});
|
||||
});
|
||||
|
||||
it('bubbles up retCode != 0 via the shared ntv2 deserializer', async () => {
|
||||
const deps = makeDeps({
|
||||
respHead: { retCode: 99, message: 'gone' },
|
||||
download: { info: { domain: 'd', urlPath: '/p' }, rKeyParam: '' },
|
||||
} as any);
|
||||
await expect(
|
||||
GetGroupVideoUrl.invoke(deps, { groupId: 1, node: { fileUuid: 'u' } }),
|
||||
).rejects.toThrow(/code=99/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
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 {
|
||||
OidbPrivateFileDownloadReq,
|
||||
OidbPrivateFileDownloadResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/media';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { GetPrivateFileUrl } from '../../../src/oidb-services/group-file/get-private-file-url';
|
||||
|
||||
function makeDeps(body?: OidbPrivateFileDownloadResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbPrivateFileDownloadResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('GetPrivateFileUrl namespace', () => {
|
||||
it('declares 0xE37_1200', () => {
|
||||
expect(GetPrivateFileUrl.command).toBe(0xe37);
|
||||
expect(GetPrivateFileUrl.subCommand).toBe(1200);
|
||||
});
|
||||
|
||||
it('packages the magic field99999 cookie + body fields', async () => {
|
||||
const deps = makeDeps({
|
||||
body: { result: { server: 'host', port: 80, url: '/u' } } as any,
|
||||
});
|
||||
await GetPrivateFileUrl.invoke(deps, { selfUid: 'self', fileId: 'fid', fileHash: 'h' });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0xe37_1200');
|
||||
const env = protobuf_decode<OidbBase<OidbPrivateFileDownloadReq>>(bytes);
|
||||
expect(env.body).toMatchObject({
|
||||
subCommand: 1200,
|
||||
field2: 1,
|
||||
field101: 3,
|
||||
field102: 103,
|
||||
field200: 1,
|
||||
});
|
||||
expect(env.body?.body).toMatchObject({
|
||||
receiverUid: 'self',
|
||||
fileUuid: 'fid',
|
||||
type: 2,
|
||||
fileHash: 'h',
|
||||
});
|
||||
expect(env.body?.field99999).toEqual(new Uint8Array([0xc0, 0x85, 0x2c, 0x01]));
|
||||
});
|
||||
|
||||
it('composes the http download URL', async () => {
|
||||
const deps = makeDeps({
|
||||
body: { result: { server: 'srv', port: 8080, url: '/path' } } as any,
|
||||
});
|
||||
await expect(
|
||||
GetPrivateFileUrl.invoke(deps, { selfUid: 's', fileId: 'f', fileHash: 'h' }),
|
||||
).resolves.toBe('http://srv:8080/path&isthumb=0');
|
||||
});
|
||||
|
||||
it('throws when result is missing', async () => {
|
||||
const deps = makeDeps({ body: {} });
|
||||
await expect(
|
||||
GetPrivateFileUrl.invoke(deps, { selfUid: 's', fileId: 'f', fileHash: 'h' }),
|
||||
).rejects.toThrow(/private file url response invalid/);
|
||||
});
|
||||
|
||||
it('throws when server/port/url is incomplete', async () => {
|
||||
const deps = makeDeps({
|
||||
body: { result: { server: 'srv', port: 0, url: '/path' } } as any,
|
||||
});
|
||||
await expect(
|
||||
GetPrivateFileUrl.invoke(deps, { selfUid: 's', fileId: 'f', fileHash: 'h' }),
|
||||
).rejects.toThrow(/private file url response invalid/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
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 { GetPrivatePttUrl } from '../../../src/oidb-services/group-file/get-private-ptt-url';
|
||||
|
||||
function makeDeps(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('GetPrivatePttUrl namespace', () => {
|
||||
it('declares 0x126D_200 with uinForm=true', () => {
|
||||
expect(GetPrivatePttUrl.command).toBe(0x126d);
|
||||
expect(GetPrivatePttUrl.subCommand).toBe(200);
|
||||
expect(GetPrivatePttUrl.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('emits c2c scene with self uid as targetUid', async () => {
|
||||
const deps = makeDeps({
|
||||
respHead: { retCode: 0 },
|
||||
download: { info: { domain: 'd', urlPath: '/p' }, rKeyParam: '' },
|
||||
} as any);
|
||||
await GetPrivatePttUrl.invoke(deps, { selfUid: 'self', node: { fileUuid: 'uuid' } });
|
||||
const env = protobuf_decode<OidbBase<NTV2RichMediaReq>>(
|
||||
deps.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(env.body?.reqHead?.scene).toMatchObject({
|
||||
requestType: 1,
|
||||
businessType: 3,
|
||||
sceneType: 1,
|
||||
c2c: { accountType: 2, targetUid: 'self' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
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 { GetPrivateVideoUrl } from '../../../src/oidb-services/group-file/get-private-video-url';
|
||||
|
||||
function makeDeps(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('GetPrivateVideoUrl namespace', () => {
|
||||
it('declares 0x11E9_200 with uinForm=true', () => {
|
||||
expect(GetPrivateVideoUrl.command).toBe(0x11e9);
|
||||
expect(GetPrivateVideoUrl.subCommand).toBe(200);
|
||||
expect(GetPrivateVideoUrl.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('emits c2c scene with self uid as targetUid', async () => {
|
||||
const deps = makeDeps({
|
||||
respHead: { retCode: 0 },
|
||||
download: { info: { domain: 'd', urlPath: '/v' }, rKeyParam: '' },
|
||||
} as any);
|
||||
await GetPrivateVideoUrl.invoke(deps, { selfUid: 'self', node: { fileUuid: 'uuid' } });
|
||||
const env = protobuf_decode<OidbBase<NTV2RichMediaReq>>(
|
||||
deps.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(env.body?.reqHead?.scene).toMatchObject({
|
||||
requestType: 2,
|
||||
businessType: 2,
|
||||
sceneType: 1,
|
||||
c2c: { accountType: 2, targetUid: 'self' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
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 {
|
||||
OidbGroupFileViewReq,
|
||||
OidbGroupFileViewResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/group-file';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { ListGroupFilesPage } from '../../../src/oidb-services/group-file/list-group-files-page';
|
||||
|
||||
function wireVarint(value: bigint): number[] {
|
||||
const out: number[] = [];
|
||||
let remaining = value;
|
||||
|
||||
do {
|
||||
const byte = Number(remaining & 0x7fn);
|
||||
remaining >>= 7n;
|
||||
out.push(remaining === 0n ? byte : byte | 0x80);
|
||||
} while (remaining !== 0n);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function varintField(fieldNumber: number, value: bigint): number[] {
|
||||
return [...wireVarint(BigInt(fieldNumber << 3)), ...wireVarint(value)];
|
||||
}
|
||||
|
||||
function bytesField(fieldNumber: number, value: Uint8Array): number[] {
|
||||
return [
|
||||
...wireVarint(BigInt((fieldNumber << 3) | 2)),
|
||||
...wireVarint(BigInt(value.length)),
|
||||
...value,
|
||||
];
|
||||
}
|
||||
|
||||
function stringField(fieldNumber: number, value: string): number[] {
|
||||
return bytesField(fieldNumber, new TextEncoder().encode(value));
|
||||
}
|
||||
|
||||
function messageField(fieldNumber: number, value: number[]): number[] {
|
||||
return bytesField(fieldNumber, Uint8Array.from(value));
|
||||
}
|
||||
|
||||
function makeDeps(body?: OidbGroupFileViewResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileViewResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('ListGroupFilesPage namespace', () => {
|
||||
it('declares 0x6D8_1 with uinForm=true', () => {
|
||||
expect(ListGroupFilesPage.command).toBe(0x6d8);
|
||||
expect(ListGroupFilesPage.subCommand).toBe(1);
|
||||
expect(ListGroupFilesPage.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('packages the page parameters (sortBy=1, field17=2, field18=0)', async () => {
|
||||
const deps = makeDeps({ list: { isEnd: true, items: [] } as any });
|
||||
await ListGroupFilesPage.invoke(deps, {
|
||||
groupId: 12345,
|
||||
targetDirectory: '/a',
|
||||
startIndex: 20,
|
||||
pageSize: 50,
|
||||
});
|
||||
const env = protobuf_decode<OidbBase<OidbGroupFileViewReq>>(
|
||||
deps.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(env.body?.list).toMatchObject({
|
||||
groupUin: 12345,
|
||||
appId: 7,
|
||||
targetDirectory: '/a',
|
||||
fileCount: 50,
|
||||
sortBy: 1,
|
||||
startIndex: 20,
|
||||
field17: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns items and isEnd after checking list.retCode', async () => {
|
||||
const deps = makeDeps({
|
||||
list: { isEnd: false, items: [{ type: 1 }] } as any,
|
||||
});
|
||||
const out = await ListGroupFilesPage.invoke(deps, {
|
||||
groupId: 1,
|
||||
targetDirectory: '/',
|
||||
startIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
expect(out).toEqual({ items: [expect.objectContaining({ type: 1 })], isEnd: false });
|
||||
});
|
||||
|
||||
it('throws when list.retCode is non-zero', async () => {
|
||||
const deps = makeDeps({
|
||||
list: { retCode: 2, retMsg: 'busy', isEnd: true, items: [] } as any,
|
||||
});
|
||||
await expect(
|
||||
ListGroupFilesPage.invoke(deps, {
|
||||
groupId: 1,
|
||||
targetDirectory: '/',
|
||||
startIndex: 0,
|
||||
pageSize: 10,
|
||||
}),
|
||||
).rejects.toThrow(/group file list failed: code=2/);
|
||||
});
|
||||
|
||||
it('decodes the folder last-upload metadata from the QQ wire fields', async () => {
|
||||
// Independent wire fixture confirmed against QQ's group-file list decoder:
|
||||
// folder fields 5/9/10 are modify time, modifier UIN, and modifier name.
|
||||
const folder = [
|
||||
...stringField(1, 'd1'),
|
||||
...stringField(3, 'dir'),
|
||||
...varintField(4, 100n),
|
||||
...varintField(5, 200n),
|
||||
...varintField(6, 123n),
|
||||
...stringField(7, 'creator'),
|
||||
...varintField(8, 2n),
|
||||
...varintField(9, 5_000_000_001n),
|
||||
...stringField(10, 'uploader'),
|
||||
];
|
||||
const item = [...varintField(1, 2n), ...messageField(2, folder)];
|
||||
const list = [...varintField(4, 1n), ...messageField(5, item)];
|
||||
const body = messageField(2, list);
|
||||
const responseData = Buffer.from(messageField(4, body));
|
||||
const deps = {
|
||||
sendRawPacket: vi.fn(async (): Promise<SendPacketResult> => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
})),
|
||||
};
|
||||
|
||||
const out = await ListGroupFilesPage.invoke(deps, {
|
||||
groupId: 1,
|
||||
targetDirectory: '/',
|
||||
startIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const decoded = out?.items?.[0]?.folderInfo as Record<string, unknown> | undefined;
|
||||
|
||||
expect(decoded).toMatchObject({
|
||||
modifiedTime: 200,
|
||||
modifierUin: 5_000_000_001n,
|
||||
modifierName: 'uploader',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the server elides the list slot', async () => {
|
||||
const deps = makeDeps({});
|
||||
const out = await ListGroupFilesPage.invoke(deps, {
|
||||
groupId: 1,
|
||||
targetDirectory: '/',
|
||||
startIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
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 {
|
||||
OidbGroupFileReq,
|
||||
OidbGroupFileResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/group-file';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { MoveGroupFile } from '../../../src/oidb-services/group-file/move-group-file';
|
||||
|
||||
function makeDeps(body?: OidbGroupFileResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('MoveGroupFile namespace', () => {
|
||||
it('declares 0x6D6_5 with uinForm=true', () => {
|
||||
expect(MoveGroupFile.command).toBe(0x6d6);
|
||||
expect(MoveGroupFile.subCommand).toBe(5);
|
||||
expect(MoveGroupFile.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('packages parent + target directories under the move slot', async () => {
|
||||
const deps = makeDeps({ move: {} as any });
|
||||
await MoveGroupFile.invoke(deps, {
|
||||
groupId: 12345,
|
||||
fileId: 'fid',
|
||||
parentDirectory: '/a',
|
||||
targetDirectory: '/b',
|
||||
});
|
||||
const env = protobuf_decode<OidbBase<OidbGroupFileReq>>(
|
||||
deps.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(env.body?.move).toMatchObject({
|
||||
groupUin: 12345,
|
||||
appId: 7,
|
||||
busId: 102,
|
||||
fileId: 'fid',
|
||||
parentDirectory: '/a',
|
||||
targetDirectory: '/b',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on missing move sub-message', async () => {
|
||||
const deps = makeDeps({});
|
||||
await expect(
|
||||
MoveGroupFile.invoke(deps, {
|
||||
groupId: 1,
|
||||
fileId: 'f',
|
||||
parentDirectory: '/',
|
||||
targetDirectory: '/',
|
||||
}),
|
||||
).rejects.toThrow(/move response missing/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbGroupSendFileReq } from '@snowluma/proto-defs/oidb-actions/group-file';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { PublishGroupFile } from '../../../src/oidb-services/group-file/publish-group-file';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('PublishGroupFile namespace', () => {
|
||||
it('declares 0x6D9_4', () => {
|
||||
expect(PublishGroupFile.command).toBe(0x6d9);
|
||||
expect(PublishGroupFile.subCommand).toBe(4);
|
||||
});
|
||||
|
||||
it('routes to 0x6d9_4 with busiType=102, type=2, field5=true', async () => {
|
||||
const deps = makeDeps();
|
||||
await PublishGroupFile.invoke(deps, { groupId: 12345, fileId: 'fid' });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x6d9_4');
|
||||
const env = protobuf_decode<OidbBase<OidbGroupSendFileReq>>(bytes);
|
||||
expect(env.body?.body).toMatchObject({
|
||||
groupUin: 12345,
|
||||
type: 2,
|
||||
info: expect.objectContaining({ busiType: 102, fileId: 'fid', field5: true }),
|
||||
});
|
||||
});
|
||||
|
||||
it('emits a non-zero `info.field3` random discriminator', async () => {
|
||||
// Lagrange-V2 sets this to `Random.Shared.Next()`; our serialize uses
|
||||
// Math.random() — both produce a 31-bit unsigned int, never 0.
|
||||
const deps = makeDeps();
|
||||
await PublishGroupFile.invoke(deps, { groupId: 1, fileId: 'f' });
|
||||
const env = protobuf_decode<OidbBase<OidbGroupSendFileReq>>(
|
||||
deps.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
const field3 = env.body?.body?.info?.field3 ?? 0;
|
||||
expect(typeof field3).toBe('number');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
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 {
|
||||
OidbGroupFileReq,
|
||||
OidbGroupFileResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/group-file';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { RenameGroupFile } from '../../../src/oidb-services/group-file/rename-group-file';
|
||||
|
||||
// A success response the real server would send: command/sub echoed, rename
|
||||
// ret slot with retCode 0. Lets `invoke`'s deserialize pass so we can assert
|
||||
// the request wire (cmd name + body) without it throwing on an empty body.
|
||||
function okRenameResponse(): Buffer {
|
||||
const env: OidbBase<OidbGroupFileResp> = {
|
||||
command: 0x6d6,
|
||||
subCommand: 4,
|
||||
body: { rename: { retCode: 0 } },
|
||||
};
|
||||
return Buffer.from(protobuf_encode<OidbBase<OidbGroupFileResp>>(env));
|
||||
}
|
||||
|
||||
function makeSender() {
|
||||
const defaultResp: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: okRenameResponse(),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => defaultResp) };
|
||||
}
|
||||
|
||||
describe('RenameGroupFile namespace', () => {
|
||||
it('declares command 0x6D6 sub 4 (rename slot), uin form', () => {
|
||||
expect(RenameGroupFile.command).toBe(0x6d6);
|
||||
expect(RenameGroupFile.subCommand).toBe(4);
|
||||
expect(RenameGroupFile.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes into the rename slot with busId 102 and the current parent folder', () => {
|
||||
const out = RenameGroupFile.serialize({} as any, {
|
||||
groupId: 12345,
|
||||
fileId: '/abc',
|
||||
parentDirectory: '/sub',
|
||||
newFileName: 'new.txt',
|
||||
});
|
||||
expect(out).toEqual({
|
||||
rename: {
|
||||
groupUin: 12345,
|
||||
busId: 102,
|
||||
fileId: '/abc',
|
||||
parentFolder: '/sub',
|
||||
newFileName: 'new.txt',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips the rename body through the OIDB envelope (locks the pb field tags)', async () => {
|
||||
const sender = makeSender();
|
||||
await RenameGroupFile.invoke(sender, {
|
||||
groupId: 12345,
|
||||
fileId: '/abc',
|
||||
parentDirectory: '/',
|
||||
newFileName: 'new.txt',
|
||||
});
|
||||
expect(sender.sendRawPacket).toHaveBeenCalledOnce();
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x6d6_4');
|
||||
const env = protobuf_decode<OidbBase<OidbGroupFileReq>>(bytes);
|
||||
expect(env.command).toBe(0x6d6);
|
||||
expect(env.subCommand).toBe(4);
|
||||
expect(env.body.rename).toMatchObject({
|
||||
groupUin: 12345,
|
||||
busId: 102,
|
||||
fileId: '/abc',
|
||||
parentFolder: '/',
|
||||
newFileName: 'new.txt',
|
||||
});
|
||||
});
|
||||
|
||||
it('deserialize throws when the rename slot is missing from the response', () => {
|
||||
expect(() => RenameGroupFile.deserialize({} as any, {})).toThrow(/rename response missing/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
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 {
|
||||
OidbGroupFileFolderReq,
|
||||
OidbGroupFileFolderResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/group-file';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { RenameGroupFolder } from '../../../src/oidb-services/group-file/rename-group-folder';
|
||||
|
||||
function makeDeps(body?: OidbGroupFileFolderResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileFolderResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('RenameGroupFolder namespace', () => {
|
||||
it('declares 0x6D7_2 with uinForm=true', () => {
|
||||
expect(RenameGroupFolder.command).toBe(0x6d7);
|
||||
expect(RenameGroupFolder.subCommand).toBe(2);
|
||||
expect(RenameGroupFolder.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('packages rename.{groupUin, folderId, newFolderName}', async () => {
|
||||
const deps = makeDeps({ rename: {} as any });
|
||||
await RenameGroupFolder.invoke(deps, {
|
||||
groupId: 12345,
|
||||
folderId: 'fid',
|
||||
newFolderName: 'newname',
|
||||
});
|
||||
const env = protobuf_decode<OidbBase<OidbGroupFileFolderReq>>(
|
||||
deps.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(env.body?.rename).toMatchObject({
|
||||
groupUin: 12345,
|
||||
folderId: 'fid',
|
||||
newFolderName: 'newname',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on missing rename sub-message', async () => {
|
||||
const deps = makeDeps({});
|
||||
await expect(
|
||||
RenameGroupFolder.invoke(deps, {
|
||||
groupId: 1,
|
||||
folderId: 'f',
|
||||
newFolderName: 'n',
|
||||
}),
|
||||
).rejects.toThrow(/folder rename response missing/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
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 {
|
||||
OidbGroupSendFileReq,
|
||||
OidbGroupSendFileResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/group-file';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { TransGroupFile } from '../../../src/oidb-services/group-file/trans-group-file';
|
||||
|
||||
function makeDeps(body?: OidbGroupSendFileResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbGroupSendFileResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('TransGroupFile namespace', () => {
|
||||
it('declares 0x6D9_0', () => {
|
||||
expect(TransGroupFile.command).toBe(0x6d9);
|
||||
expect(TransGroupFile.subCommand).toBe(0);
|
||||
});
|
||||
|
||||
it('packages group + file id under the transFile slot', async () => {
|
||||
const deps = makeDeps({ transFile: { saveBusId: 102, saveFilePath: '/abc' } });
|
||||
const out = await TransGroupFile.invoke(deps, { groupId: 12345, fileId: 'fid' });
|
||||
expect(out).toEqual({ saveBusId: 102, saveFilePath: '/abc' });
|
||||
expect(deps.sendRawPacket).toHaveBeenCalledOnce();
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x6d9_0');
|
||||
const env = protobuf_decode<OidbBase<OidbGroupSendFileReq>>(bytes);
|
||||
expect(env.body?.transFile).toEqual({
|
||||
groupUin: 12345n,
|
||||
appId: 7,
|
||||
busId: 102,
|
||||
fileId: 'fid',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on missing transFile sub-message', async () => {
|
||||
const deps = makeDeps({});
|
||||
await expect(TransGroupFile.invoke(deps, { groupId: 1, fileId: 'f' })).rejects.toThrow(
|
||||
/transfer response missing/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on non-zero retCode', async () => {
|
||||
const deps = makeDeps({ transFile: { retCode: 7, retMsg: 'denied' } });
|
||||
await expect(TransGroupFile.invoke(deps, { groupId: 1, fileId: 'f' })).rejects.toThrow(
|
||||
/code=7/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
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 {
|
||||
OidbGroupFileReq,
|
||||
OidbGroupFileResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/group-file';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { UploadGroupFileRequest } from '../../../src/oidb-services/group-file/upload-group-file-request';
|
||||
|
||||
function makeDeps(body?: OidbGroupFileResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('UploadGroupFileRequest namespace', () => {
|
||||
it('declares 0x6D6_0 with uinForm=true', () => {
|
||||
expect(UploadGroupFileRequest.command).toBe(0x6d6);
|
||||
expect(UploadGroupFileRequest.subCommand).toBe(0);
|
||||
expect(UploadGroupFileRequest.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('packages the file pre-flight slot (entrance=6, appId=4, busId=102)', async () => {
|
||||
const deps = makeDeps({ upload: { fileId: 'fid', boolFileExist: true } as any });
|
||||
await UploadGroupFileRequest.invoke(deps, {
|
||||
groupId: 12345,
|
||||
fileName: 'doc.pdf',
|
||||
folderId: '/',
|
||||
fileSize: 1024,
|
||||
fileSha1: new Uint8Array(20),
|
||||
fileMd5: new Uint8Array(16),
|
||||
});
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x6d6_0');
|
||||
const env = protobuf_decode<OidbBase<OidbGroupFileReq>>(bytes);
|
||||
expect(env.reserved).toBe(1); // uinForm
|
||||
expect(env.body?.file).toMatchObject({
|
||||
groupUin: 12345,
|
||||
appId: 4,
|
||||
busId: 102,
|
||||
entrance: 6,
|
||||
targetDirectory: '/',
|
||||
fileName: 'doc.pdf',
|
||||
localDirectory: '/doc.pdf',
|
||||
fileSize: 1024n,
|
||||
field15: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the upload-info body for the facade orchestration', async () => {
|
||||
const deps = makeDeps({ upload: { fileId: 'fid-X', boolFileExist: true } as any });
|
||||
const out = await UploadGroupFileRequest.invoke(deps, {
|
||||
groupId: 1,
|
||||
fileName: 'f',
|
||||
folderId: '/',
|
||||
fileSize: 0,
|
||||
fileSha1: new Uint8Array(0),
|
||||
fileMd5: new Uint8Array(0),
|
||||
});
|
||||
expect(out.fileId).toBe('fid-X');
|
||||
expect(out.boolFileExist).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when the upload sub-message is missing', async () => {
|
||||
const deps = makeDeps({});
|
||||
await expect(
|
||||
UploadGroupFileRequest.invoke(deps, {
|
||||
groupId: 1,
|
||||
fileName: 'f',
|
||||
folderId: '/',
|
||||
fileSize: 0,
|
||||
fileSha1: new Uint8Array(0),
|
||||
fileMd5: new Uint8Array(0),
|
||||
}),
|
||||
).rejects.toThrow(/upload response missing/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
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 {
|
||||
OidbPrivateFileUploadReq,
|
||||
OidbPrivateFileUploadResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/media';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { UploadPrivateFileRequest } from '../../../src/oidb-services/group-file/upload-private-file-request';
|
||||
|
||||
function makeDeps(body?: OidbPrivateFileUploadResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbPrivateFileUploadResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('UploadPrivateFileRequest namespace', () => {
|
||||
it('declares 0xE37_1700', () => {
|
||||
expect(UploadPrivateFileRequest.command).toBe(0xe37);
|
||||
expect(UploadPrivateFileRequest.subCommand).toBe(1700);
|
||||
});
|
||||
|
||||
it('packages businessId=3, clientType=1, flagSupportMediaPlatform=1 alongside the upload body', async () => {
|
||||
const deps = makeDeps({ upload: { uuid: 'fid', boolFileExist: true } as any });
|
||||
await UploadPrivateFileRequest.invoke(deps, {
|
||||
senderUid: 's',
|
||||
receiverUid: 'r',
|
||||
fileName: 'f.pdf',
|
||||
fileSize: 4096,
|
||||
fileSha1: new Uint8Array(20),
|
||||
fileMd5: new Uint8Array(16),
|
||||
md510MCheckSum: new Uint8Array(16),
|
||||
});
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0xe37_1700');
|
||||
const env = protobuf_decode<OidbBase<OidbPrivateFileUploadReq>>(bytes);
|
||||
expect(env.body).toMatchObject({
|
||||
command: 1700,
|
||||
businessId: 3,
|
||||
clientType: 1,
|
||||
flagSupportMediaPlatform: 1,
|
||||
});
|
||||
expect(env.body?.upload).toMatchObject({
|
||||
senderUid: 's',
|
||||
receiverUid: 'r',
|
||||
fileSize: 4096,
|
||||
fileName: 'f.pdf',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the upload body for the facade host-selection step', async () => {
|
||||
const deps = makeDeps({
|
||||
upload: { uuid: 'fid-Y', fileAddon: 'hash', boolFileExist: true } as any,
|
||||
});
|
||||
const out = await UploadPrivateFileRequest.invoke(deps, {
|
||||
senderUid: 's',
|
||||
receiverUid: 'r',
|
||||
fileName: 'f',
|
||||
fileSize: 0,
|
||||
fileSha1: new Uint8Array(0),
|
||||
fileMd5: new Uint8Array(0),
|
||||
md510MCheckSum: new Uint8Array(0),
|
||||
});
|
||||
expect(out.uuid).toBe('fid-Y');
|
||||
expect(out.fileAddon).toBe('hash');
|
||||
});
|
||||
|
||||
it('throws when the upload sub-message is missing', async () => {
|
||||
const deps = makeDeps({});
|
||||
await expect(
|
||||
UploadPrivateFileRequest.invoke(deps, {
|
||||
senderUid: 's',
|
||||
receiverUid: 'r',
|
||||
fileName: 'f',
|
||||
fileSize: 0,
|
||||
fileSha1: new Uint8Array(0),
|
||||
fileMd5: new Uint8Array(0),
|
||||
md510MCheckSum: new Uint8Array(0),
|
||||
}),
|
||||
).rejects.toThrow(/upload response missing/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { NTV2UploadRichMediaResp } from '@snowluma/proto-defs/highway';
|
||||
import { OidbError } from '../../../src/oidb-service';
|
||||
import { Ntv2UploadRequest } from '../../../src/oidb-services/highway/ntv2-upload-request';
|
||||
|
||||
function packet(body: NTV2UploadRichMediaResp, errorCode = 0): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<NTV2UploadRichMediaResp>>({
|
||||
command: 0x11c5,
|
||||
subCommand: 100,
|
||||
errorCode,
|
||||
errorMsg: errorCode ? 'denied' : '',
|
||||
body,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const params: Ntv2UploadRequest.Params = {
|
||||
oidbCmd: 0x11c5,
|
||||
isGroup: false,
|
||||
targetIdOrUid: 'u_peer',
|
||||
requestId: 1,
|
||||
businessType: 1,
|
||||
uploadInfo: [],
|
||||
compatQmsgSceneType: 1,
|
||||
extBizInfo: {},
|
||||
tryFast: true,
|
||||
clientRandomId: 1n,
|
||||
};
|
||||
|
||||
describe('Ntv2UploadRequest', () => {
|
||||
it.each([
|
||||
{ tempGroupId: 0 },
|
||||
{ tempGroupId: -1 },
|
||||
{ tempGroupId: 1.5 },
|
||||
{ tempGroupId: 700, isGroup: true },
|
||||
{ tempGroupId: 700, targetIdOrUid: '' },
|
||||
{ tempGroupId: 700, targetIdOrUid: 1001 },
|
||||
])('rejects invalid temp context before sending: %j', async (invalid) => {
|
||||
const sendRawPacket = vi.fn();
|
||||
await expect(
|
||||
Ntv2UploadRequest.invoke({ sendRawPacket }, { ...params, ...invalid }),
|
||||
).rejects.toThrow('invalid temp-session image upload context');
|
||||
expect(sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends the resolved command on the default OIDB wire name', async () => {
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
packet({
|
||||
upload: { msgInfo: { msgInfoBody: [] } },
|
||||
}),
|
||||
);
|
||||
await Ntv2UploadRequest.invoke({ sendRawPacket }, params);
|
||||
expect(sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x11c5_100');
|
||||
});
|
||||
|
||||
it('raises OidbError when the envelope is rejected', async () => {
|
||||
const sendRawPacket = vi.fn(async () => packet({ upload: { msgInfo: {} } }, 42));
|
||||
await expect(Ntv2UploadRequest.invoke({ sendRawPacket }, params)).rejects.toBeInstanceOf(
|
||||
OidbError,
|
||||
);
|
||||
});
|
||||
|
||||
it('fails in deserialize when msgInfo is missing', async () => {
|
||||
const sendRawPacket = vi.fn(async () => packet({ upload: { uKey: 'k' } }));
|
||||
await expect(Ntv2UploadRequest.invoke({ sendRawPacket }, params)).rejects.toThrow(
|
||||
'upload response missing msgInfo',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbLike } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SendLike } from '../../../src/oidb-services/interaction/send-like';
|
||||
|
||||
function makeDeps(uid = 'target-uid-xyz') {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
resolveUserUid: vi.fn(async () => uid),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SendLike namespace', () => {
|
||||
it('declares 0x7E5_104', () => {
|
||||
expect(SendLike.command).toBe(0x7e5);
|
||||
expect(SendLike.subCommand).toBe(104);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('resolves uid via ctx and emits {targetUid, sourceId=71, count}', async () => {
|
||||
const deps = makeDeps('UID-AAA');
|
||||
const body = await SendLike.serialize(deps, { userId: 10001, count: 3 });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
expect(body).toEqual({ targetUid: 'UID-AAA', sourceId: 71, count: 3 });
|
||||
});
|
||||
|
||||
it('throws when ctx returns an empty uid', async () => {
|
||||
const deps = makeDeps('');
|
||||
await expect(SendLike.serialize(deps, { userId: 10001, count: 1 })).rejects.toThrow(
|
||||
/failed to resolve uid/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0x7e5_104', async () => {
|
||||
const deps = makeDeps();
|
||||
await SendLike.invoke(deps, { userId: 10001, count: 3 });
|
||||
expect(deps.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x7e5_104');
|
||||
});
|
||||
|
||||
it('encodes targetUid (field 11) + sourceId=71 (field 12) + count (field 13)', async () => {
|
||||
const deps = makeDeps('UID-BBB');
|
||||
await SendLike.invoke(deps, { userId: 10001, count: 5 });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbLike>>(bytes);
|
||||
expect(env.command).toBe(0x7e5);
|
||||
expect(env.subCommand).toBe(104);
|
||||
expect(env.body).toMatchObject({ targetUid: 'UID-BBB', sourceId: 71, count: 5 });
|
||||
// Defensive: the obsolete uin-form field must not appear (server
|
||||
// rejects uin-form payloads with "被点赞 QQ 号非法").
|
||||
expect((env.body as any).targetUin).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbPoke } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SendPoke } from '../../../src/oidb-services/interaction/send-poke';
|
||||
|
||||
function makeSender() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('SendPoke namespace', () => {
|
||||
it('declares 0xED3_1', () => {
|
||||
expect(SendPoke.command).toBe(0xed3);
|
||||
expect(SendPoke.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('group poke: groupUin = peer, friendUin = 0, uin = targetUin', () => {
|
||||
expect(
|
||||
SendPoke.serialize({} as any, { isGroup: true, peerUin: 12345, targetUin: 67890 }),
|
||||
).toEqual({
|
||||
uin: 67890,
|
||||
groupUin: 12345,
|
||||
friendUin: 0,
|
||||
ext: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('group poke without targetUin: uin defaults to peer', () => {
|
||||
expect(SendPoke.serialize({} as any, { isGroup: true, peerUin: 12345 })).toEqual({
|
||||
uin: 12345,
|
||||
groupUin: 12345,
|
||||
friendUin: 0,
|
||||
ext: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('friend poke: friendUin = peer, groupUin = 0, uin = targetUin', () => {
|
||||
expect(
|
||||
SendPoke.serialize({} as any, { isGroup: false, peerUin: 67890, targetUin: 11111 }),
|
||||
).toEqual({
|
||||
uin: 11111,
|
||||
groupUin: 0,
|
||||
friendUin: 67890,
|
||||
ext: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('friend poke without targetUin: uin defaults to peer', () => {
|
||||
expect(SendPoke.serialize({} as any, { isGroup: false, peerUin: 67890 })).toEqual({
|
||||
uin: 67890,
|
||||
groupUin: 0,
|
||||
friendUin: 67890,
|
||||
ext: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('always sets ext = 0', () => {
|
||||
expect(SendPoke.serialize({} as any, { isGroup: true, peerUin: 1 }).ext).toBe(0);
|
||||
expect(SendPoke.serialize({} as any, { isGroup: false, peerUin: 1 }).ext).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0xed3_1', async () => {
|
||||
const sender = makeSender();
|
||||
await SendPoke.invoke(sender, { isGroup: true, peerUin: 1 });
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xed3_1');
|
||||
});
|
||||
|
||||
it('encodes serialize() output into envelope body', async () => {
|
||||
const sender = makeSender();
|
||||
await SendPoke.invoke(sender, { isGroup: true, peerUin: 12345, targetUin: 67890 });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbPoke>>(bytes);
|
||||
expect(env.command).toBe(0xed3);
|
||||
expect(env.subCommand).toBe(1);
|
||||
expect(env.body).toMatchObject({ uin: 67890, groupUin: 12345 });
|
||||
// proto3 default 0 for friendUin/ext is omitted on the wire.
|
||||
expect(env.body?.friendUin ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('resolves to undefined', async () => {
|
||||
const sender = makeSender();
|
||||
const result = await SendPoke.invoke(sender, { isGroup: false, peerUin: 1 });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbEssence } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetEssence } from '../../../src/oidb-services/interaction/set-essence';
|
||||
|
||||
function makeSender() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('SetEssence namespace', () => {
|
||||
it('declares 0xEAC', () => {
|
||||
expect(SetEssence.command).toBe(0xeac);
|
||||
});
|
||||
|
||||
describe('resolveSubCommand', () => {
|
||||
it('returns 1 when enable=true', () => {
|
||||
expect(
|
||||
SetEssence.resolveSubCommand({ groupId: 1, sequence: 1, random: 1, enable: true }),
|
||||
).toBe(1);
|
||||
});
|
||||
it('returns 2 when enable=false', () => {
|
||||
expect(
|
||||
SetEssence.resolveSubCommand({ groupId: 1, sequence: 1, random: 1, enable: false }),
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('passes groupId / sequence / random through verbatim', () => {
|
||||
expect(
|
||||
SetEssence.serialize({} as any, {
|
||||
groupId: 12345,
|
||||
sequence: 99,
|
||||
random: 0xcafebabe,
|
||||
enable: true,
|
||||
}),
|
||||
).toEqual({
|
||||
groupUin: 12345,
|
||||
sequence: 99,
|
||||
random: 0xcafebabe,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0xeac_1 when enabling', async () => {
|
||||
const sender = makeSender();
|
||||
await SetEssence.invoke(sender, {
|
||||
groupId: 12345,
|
||||
sequence: 99,
|
||||
random: 0,
|
||||
enable: true,
|
||||
});
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xeac_1');
|
||||
});
|
||||
|
||||
it('routes to OidbSvcTrpcTcp.0xeac_2 when disabling', async () => {
|
||||
const sender = makeSender();
|
||||
await SetEssence.invoke(sender, {
|
||||
groupId: 12345,
|
||||
sequence: 99,
|
||||
random: 0,
|
||||
enable: false,
|
||||
});
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xeac_2');
|
||||
});
|
||||
|
||||
it('encodes envelope body with the right shape', async () => {
|
||||
const sender = makeSender();
|
||||
await SetEssence.invoke(sender, {
|
||||
groupId: 12345,
|
||||
sequence: 99,
|
||||
random: 7,
|
||||
enable: true,
|
||||
});
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbEssence>>(bytes);
|
||||
expect(env.command).toBe(0xeac);
|
||||
expect(env.subCommand).toBe(1);
|
||||
expect(env.body).toMatchObject({ groupUin: 12345, sequence: 99, random: 7 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
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 { Oidb0x112eReq, Oidb0x112eResp } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { ClickInlineKeyboardButton } from '../../../src/oidb-services/misc/click-inline-keyboard-button';
|
||||
|
||||
function makeSender(resp?: Buffer) {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: resp ?? Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('ClickInlineKeyboardButton namespace', () => {
|
||||
it('declares 0x112E_1', () => {
|
||||
expect(ClickInlineKeyboardButton.command).toBe(0x112e);
|
||||
expect(ClickInlineKeyboardButton.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('widens numeric ids to BigInt for uint_64 fields', () => {
|
||||
const out = ClickInlineKeyboardButton.serialize({} as any, {
|
||||
groupId: 12345,
|
||||
botAppid: 67890,
|
||||
buttonId: 'btn',
|
||||
callbackData: 'data',
|
||||
msgSeq: 100,
|
||||
});
|
||||
expect(out.groupId).toBe(12345n);
|
||||
expect(out.botAppid).toBe(67890n);
|
||||
expect(out.msgSeq).toBe(100n);
|
||||
});
|
||||
|
||||
it('always sends unknown7=0 / unknown9=1 magic values', () => {
|
||||
const out = ClickInlineKeyboardButton.serialize({} as any, {
|
||||
groupId: 1,
|
||||
botAppid: 1,
|
||||
buttonId: 'x',
|
||||
callbackData: 'y',
|
||||
msgSeq: 1,
|
||||
});
|
||||
expect(out.unknown7).toBe(0);
|
||||
expect(out.unknown9).toBe(1);
|
||||
});
|
||||
|
||||
it('coerces buttonId / callbackData to strings (defensive)', () => {
|
||||
const out = ClickInlineKeyboardButton.serialize({} as any, {
|
||||
groupId: 1,
|
||||
botAppid: 1,
|
||||
buttonId: 123 as any,
|
||||
callbackData: null as any,
|
||||
msgSeq: 1,
|
||||
});
|
||||
expect(out.buttonId).toBe('123');
|
||||
expect(out.callbackData).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('shapes the response with status/promptType/promptIcon = 0', () => {
|
||||
expect(
|
||||
ClickInlineKeyboardButton.deserialize(
|
||||
{} as any,
|
||||
{
|
||||
result: 1,
|
||||
errMsg: 'ok',
|
||||
promptText: 'hi',
|
||||
} as Oidb0x112eResp,
|
||||
),
|
||||
).toEqual({
|
||||
result: 1,
|
||||
errMsg: 'ok',
|
||||
status: 0,
|
||||
promptText: 'hi',
|
||||
promptType: 0,
|
||||
promptIcon: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults result/errMsg/promptText when omitted', () => {
|
||||
expect(ClickInlineKeyboardButton.deserialize({} as any, {} as Oidb0x112eResp)).toEqual({
|
||||
result: 0,
|
||||
errMsg: '',
|
||||
status: 0,
|
||||
promptText: '',
|
||||
promptType: 0,
|
||||
promptIcon: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0x112e_1', async () => {
|
||||
const sender = makeSender();
|
||||
await ClickInlineKeyboardButton.invoke(sender, {
|
||||
groupId: 1,
|
||||
botAppid: 1,
|
||||
buttonId: 'x',
|
||||
callbackData: 'y',
|
||||
msgSeq: 1,
|
||||
});
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x112e_1');
|
||||
});
|
||||
|
||||
it('round-trips a real envelope', async () => {
|
||||
const sender = makeSender(
|
||||
Buffer.from(
|
||||
protobuf_encode<OidbBase<Oidb0x112eResp>>({
|
||||
body: { result: 7, errMsg: 'denied', promptText: 'try again' } as any,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const out = await ClickInlineKeyboardButton.invoke(sender, {
|
||||
groupId: 1,
|
||||
botAppid: 1,
|
||||
buttonId: 'x',
|
||||
callbackData: 'y',
|
||||
msgSeq: 1,
|
||||
});
|
||||
expect(out).toMatchObject({ result: 7, errMsg: 'denied', promptText: 'try again' });
|
||||
});
|
||||
|
||||
it('encodes envelope body with all fields', async () => {
|
||||
const sender = makeSender();
|
||||
await ClickInlineKeyboardButton.invoke(sender, {
|
||||
groupId: 12345,
|
||||
botAppid: 67890,
|
||||
buttonId: 'b',
|
||||
callbackData: 'c',
|
||||
msgSeq: 999,
|
||||
});
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<Oidb0x112eReq>>(bytes);
|
||||
expect(env.body).toMatchObject({
|
||||
botAppid: 67890n,
|
||||
msgSeq: 999n,
|
||||
buttonId: 'b',
|
||||
callbackData: 'c',
|
||||
groupId: 12345n,
|
||||
unknown9: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
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 { ImageOcrReq, ImageOcrResp } from '@snowluma/proto-defs/oidb-actions/ocr';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { ImageOcr } from '../../../src/oidb-services/misc/image-ocr';
|
||||
|
||||
// A server OCR response: retCode 0, one text detection with a 2-vertex box.
|
||||
function ocrResponse(): Buffer {
|
||||
const env: OidbBase<ImageOcrResp> = {
|
||||
command: 0xe07,
|
||||
subCommand: 0,
|
||||
body: {
|
||||
retCode: 0,
|
||||
ocrRspBody: {
|
||||
language: 'zh',
|
||||
textDetections: [
|
||||
{
|
||||
detectedText: '你好',
|
||||
confidence: 95,
|
||||
polygon: {
|
||||
coordinates: [
|
||||
{ x: 1, y: 2 },
|
||||
{ x: 3, y: 4 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
return Buffer.from(protobuf_encode<OidbBase<ImageOcrResp>>(env));
|
||||
}
|
||||
|
||||
function makeSender(resp = ocrResponse()) {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: resp,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('ImageOcr namespace', () => {
|
||||
it('declares command 0xE07 sub 0 (no uin form)', () => {
|
||||
expect(ImageOcr.command).toBe(0xe07);
|
||||
expect(ImageOcr.subCommand).toBe(0);
|
||||
expect(ImageOcr.uinForm).toBeUndefined(); // defaults to false in invokeOidb
|
||||
});
|
||||
|
||||
it('serializes the image url into ocrReqBody with the fixed version/client/entrance header', () => {
|
||||
const out = ImageOcr.serialize({} as any, { imageUrl: 'https://x/a.jpg' });
|
||||
expect(out).toMatchObject({ version: 1, client: 0, entrance: 1 });
|
||||
expect(out.ocrReqBody).toMatchObject({ imageUrl: 'https://x/a.jpg', isCut: false });
|
||||
});
|
||||
|
||||
it('routes to OidbSvcTrpcTcp.0xe07_0 and encodes the exact wire bytes (fixed byte-oracle locks pb tags)', async () => {
|
||||
const sender = makeSender();
|
||||
await ImageOcr.invoke(sender, { imageUrl: 'https://x/a.jpg' });
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xe07_0');
|
||||
// Independent byte-oracle: a fixed hex constant, NOT re-derived from the
|
||||
// proto, so a tag change actually goes red (a symmetric encode→decode
|
||||
// round-trip would not). Hand-decoded: 08 871c = field1 command varint
|
||||
// 0xE07; 22 17 = field4 body (23B); body: 08 01 version=1, 18 01
|
||||
// entrance=1 (client=0 omitted), 52 11 = field10 ocrReqBody (17B):
|
||||
// 0a 0f = field1 imageUrl + "https://x/a.jpg".
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(
|
||||
'08871c22170801180152110a0f68747470733a2f2f782f612e6a7067',
|
||||
);
|
||||
// And the structure decodes back as expected.
|
||||
const env = protobuf_decode<OidbBase<ImageOcrReq>>(bytes);
|
||||
expect(env.command).toBe(0xe07);
|
||||
expect(env.body.ocrReqBody?.imageUrl).toBe('https://x/a.jpg');
|
||||
});
|
||||
|
||||
it('deserializes an empty ocr body to {texts:[], language:""}', () => {
|
||||
expect(ImageOcr.deserialize({} as any, { retCode: 0 })).toEqual({
|
||||
texts: [],
|
||||
language: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('deserializes text detections + coordinates + language', async () => {
|
||||
const result = await ImageOcr.invoke(makeSender(), { imageUrl: 'https://x/a.jpg' });
|
||||
expect(result.language).toBe('zh');
|
||||
expect(result.texts).toEqual([
|
||||
{
|
||||
text: '你好',
|
||||
confidence: 95,
|
||||
coordinates: [
|
||||
{ x: 1, y: 2 },
|
||||
{ x: 3, y: 4 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws on a non-zero retCode', () => {
|
||||
expect(() =>
|
||||
ImageOcr.deserialize({} as any, { retCode: 5, errMsg: 'bad', wording: 'nope' }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0xeb7Req } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SendGroupSign } from '../../../src/oidb-services/misc/send-group-sign';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
identity: { uin: '10001' } as any,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SendGroupSign namespace', () => {
|
||||
it('declares 0xEB7_1', () => {
|
||||
expect(SendGroupSign.command).toBe(0xeb7);
|
||||
expect(SendGroupSign.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
it('overrides wire name to preserve the historic UPPERCASE EB7 byte-equality', async () => {
|
||||
const deps = makeDeps();
|
||||
await SendGroupSign.invoke(deps, { groupId: 12345 });
|
||||
expect(deps.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xEB7_1');
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('packages uin / groupId as strings + hardcodes version "9.0.90"', async () => {
|
||||
const deps = makeDeps();
|
||||
await SendGroupSign.invoke(deps, { groupId: 12345 });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<Oidb0xeb7Req>>(bytes);
|
||||
expect(env.body?.signInInfo).toEqual({
|
||||
uin: '10001',
|
||||
groupId: '12345',
|
||||
version: '9.0.90',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
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 { Oidb0x990Req, Oidb0x990Resp } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { TranslateEnToZh } from '../../../src/oidb-services/misc/translate-en-to-zh';
|
||||
|
||||
function makeSender(resp?: Buffer) {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: resp ?? Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('TranslateEnToZh namespace', () => {
|
||||
it('declares 0x990_2', () => {
|
||||
expect(TranslateEnToZh.command).toBe(0x990);
|
||||
expect(TranslateEnToZh.subCommand).toBe(2);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('hardcodes en → zh and tag10/12 = 1', () => {
|
||||
const out = TranslateEnToZh.serialize({} as any, { words: ['hello'] });
|
||||
expect(out).toEqual({
|
||||
translateReq: { srcLang: 'en', dstLang: 'zh', words: ['hello'] },
|
||||
tag10: 1,
|
||||
tag12: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the words array verbatim', () => {
|
||||
const out = TranslateEnToZh.serialize({} as any, { words: ['a', 'b', 'c'] });
|
||||
expect(out.translateReq?.words).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('returns dstWords when translateResp is present', () => {
|
||||
expect(
|
||||
TranslateEnToZh.deserialize(
|
||||
{} as any,
|
||||
{
|
||||
translateResp: { dstWords: ['你好', '世界'] },
|
||||
} as Oidb0x990Resp,
|
||||
),
|
||||
).toEqual(['你好', '世界']);
|
||||
});
|
||||
|
||||
it('returns [] when dstWords is omitted but translateResp exists', () => {
|
||||
expect(
|
||||
TranslateEnToZh.deserialize({} as any, { translateResp: {} } as Oidb0x990Resp),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws when translateResp is missing', () => {
|
||||
expect(() => TranslateEnToZh.deserialize({} as any, {} as Oidb0x990Resp)).toThrow(
|
||||
'translate response empty',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0x990_2', async () => {
|
||||
const sender = makeSender(
|
||||
Buffer.from(
|
||||
protobuf_encode<OidbBase<Oidb0x990Resp>>({
|
||||
body: { translateResp: { dstWords: ['x'] } } as any,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await TranslateEnToZh.invoke(sender, { words: ['hello'] });
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x990_2');
|
||||
});
|
||||
|
||||
it('encodes envelope body with the request payload', async () => {
|
||||
const sender = makeSender(
|
||||
Buffer.from(
|
||||
protobuf_encode<OidbBase<Oidb0x990Resp>>({
|
||||
body: { translateResp: { dstWords: ['你好'] } } as any,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const out = await TranslateEnToZh.invoke(sender, { words: ['hello'] });
|
||||
expect(out).toEqual(['你好']);
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<Oidb0x990Req>>(bytes);
|
||||
expect(env.body?.translateReq?.words).toEqual(['hello']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
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 { Oidb0x7edReq, Oidb0x7edResp } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { GetLike } from '../../../src/oidb-services/profile/get-like';
|
||||
|
||||
function makeDeps(
|
||||
opts: {
|
||||
cachedSelfUid?: string | null;
|
||||
resolveUserUid?: (uin: number) => Promise<string>;
|
||||
findUinByUid?: (uid: string) => number | null;
|
||||
resolveUin?: (uid: string) => Promise<number | null>;
|
||||
responseBody?: Oidb0x7edResp;
|
||||
} = {},
|
||||
) {
|
||||
const responseData =
|
||||
opts.responseBody !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<Oidb0x7edResp>>({ body: opts.responseBody }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
identity: {
|
||||
uin: '10001',
|
||||
selfUid: opts.cachedSelfUid ?? null,
|
||||
findUinByUid: vi.fn(opts.findUinByUid ?? (() => null)),
|
||||
resolveUin: vi.fn(opts.resolveUin ?? (async () => null)),
|
||||
} as any,
|
||||
resolveUserUid: vi.fn(opts.resolveUserUid ?? (async (uin: number) => `uid-of-${uin}`)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('GetLike namespace', () => {
|
||||
it('uses the self and other-user subcommands selected by the native client', () => {
|
||||
const deps = makeDeps({ cachedSelfUid: 'self-uid' });
|
||||
expect(GetLike.command).toBe(0x7ed);
|
||||
expect(GetLike.resolveSubCommand({}, deps)).toBe(13);
|
||||
expect(GetLike.resolveSubCommand({ userId: 0 }, deps)).toBe(13);
|
||||
expect(GetLike.resolveSubCommand({ userId: 10001 }, deps)).toBe(13);
|
||||
expect(GetLike.resolveSubCommand({ userId: 20002 }, deps)).toBe(12);
|
||||
});
|
||||
|
||||
describe('invoke (target uid resolution)', () => {
|
||||
it('uses cached self uid when userId is omitted', async () => {
|
||||
const deps = makeDeps({
|
||||
cachedSelfUid: 'cached-self',
|
||||
responseBody: {
|
||||
userLikeInfos: [
|
||||
{ uid: 'cached-self', time: 0, favoriteInfo: {}, voteInfo: {} },
|
||||
],
|
||||
},
|
||||
});
|
||||
await GetLike.invoke(deps, {});
|
||||
expect(deps.resolveUserUid).not.toHaveBeenCalled();
|
||||
const [serviceCmd, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(serviceCmd).toBe('OidbSvcTrpcTcp.0x7ed_13');
|
||||
const env = protobuf_decode<OidbBase<Oidb0x7edReq>>(bytes);
|
||||
expect(env.body?.targetUids).toEqual(['cached-self']);
|
||||
});
|
||||
|
||||
it('falls back to resolveUserUid for self when cache is empty', async () => {
|
||||
const deps = makeDeps({
|
||||
cachedSelfUid: null,
|
||||
resolveUserUid: vi.fn(async () => 'fresh-self') as any,
|
||||
responseBody: {
|
||||
userLikeInfos: [{ uid: 'fresh-self', time: 0, favoriteInfo: {}, voteInfo: {} }],
|
||||
},
|
||||
});
|
||||
await GetLike.invoke(deps, {});
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
});
|
||||
|
||||
it('resolves other users via resolveUserUid', async () => {
|
||||
const deps = makeDeps({
|
||||
responseBody: {
|
||||
userLikeInfos: [
|
||||
{ uid: 'uid-of-99999', time: 0, favoriteInfo: {}, voteInfo: {} },
|
||||
],
|
||||
},
|
||||
});
|
||||
await GetLike.invoke(deps, { userId: 99999 });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(99999);
|
||||
expect(deps.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x7ed_12');
|
||||
});
|
||||
|
||||
it('treats an explicit self uin as a self query', async () => {
|
||||
const deps = makeDeps({
|
||||
cachedSelfUid: 'cached-self',
|
||||
responseBody: { userLikeInfos: [{ uid: 'cached-self', time: 0 }] },
|
||||
});
|
||||
await GetLike.invoke(deps, { userId: 10001 });
|
||||
expect(deps.resolveUserUid).not.toHaveBeenCalled();
|
||||
expect(deps.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x7ed_13');
|
||||
});
|
||||
|
||||
it('throws when self uin is invalid and cache is empty', async () => {
|
||||
const deps = makeDeps({ cachedSelfUid: null });
|
||||
deps.identity.uin = 'invalid';
|
||||
await expect(GetLike.invoke(deps, {})).rejects.toThrow('self uid is unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('requests the profile-backed vote list', async () => {
|
||||
const deps = makeDeps({
|
||||
responseBody: {
|
||||
userLikeInfos: [{ uid: 'u', time: 0, favoriteInfo: {}, voteInfo: {} }],
|
||||
},
|
||||
});
|
||||
await GetLike.invoke(deps, { userId: 1 });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<Oidb0x7edReq>>(bytes);
|
||||
expect(env.body?.basic).toBe(1);
|
||||
expect(env.body?.vote).toBe(1);
|
||||
expect(env.body?.favorite ?? 0).toBe(0);
|
||||
expect(env.body?.userProfile).toBe(1);
|
||||
});
|
||||
|
||||
it('threads start / limit through to the request', async () => {
|
||||
const deps = makeDeps({
|
||||
responseBody: {
|
||||
userLikeInfos: [{ uid: 'u', time: 0, favoriteInfo: {}, voteInfo: {} }],
|
||||
},
|
||||
});
|
||||
await GetLike.invoke(deps, { userId: 1, start: 5, limit: 50 });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<Oidb0x7edReq>>(bytes);
|
||||
expect(env.body?.start).toBe(5);
|
||||
expect(env.body?.limit).toBe(50);
|
||||
});
|
||||
|
||||
it('defaults start=0 / limit=10', async () => {
|
||||
const deps = makeDeps({
|
||||
responseBody: {
|
||||
userLikeInfos: [{ uid: 'u', time: 0, favoriteInfo: {}, voteInfo: {} }],
|
||||
},
|
||||
});
|
||||
await GetLike.invoke(deps, { userId: 1 });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<Oidb0x7edReq>>(bytes);
|
||||
expect(env.body?.start ?? 0).toBe(0);
|
||||
expect(env.body?.limit).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('shapes favorite + vote info with the expected key names', () => {
|
||||
const deps = makeDeps({
|
||||
findUinByUid: (uid) => (uid === 'vote-user' ? 12345 : null),
|
||||
});
|
||||
const out = GetLike.deserialize(deps, {
|
||||
userLikeInfos: [
|
||||
{
|
||||
uid: 'u',
|
||||
time: 1700000000,
|
||||
favoriteInfo: {
|
||||
totalCount: 5,
|
||||
lastTime: 1,
|
||||
todayCount: 1,
|
||||
userInfos: [{ uid: 'favorite-user', nick: '收藏者', count: 3 }],
|
||||
},
|
||||
voteInfo: {
|
||||
totalCount: 7,
|
||||
newCount: 2,
|
||||
newNearbyCount: 1,
|
||||
lastVisitTime: 2,
|
||||
userInfos: [
|
||||
{
|
||||
uid: 'vote-user',
|
||||
src: 4,
|
||||
latestTime: 3,
|
||||
count: 2,
|
||||
giftCount: 1,
|
||||
customId: 5,
|
||||
lastCharged: 6,
|
||||
availableCount: 7,
|
||||
todayVotedCount: 8,
|
||||
nick: '点赞者',
|
||||
gender: 1,
|
||||
age: 20,
|
||||
isFriend: true,
|
||||
isVip: true,
|
||||
isSvip: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out.uid).toBe('u');
|
||||
expect(out.time).toBe(1700000000);
|
||||
expect(out.favoriteInfo).toEqual({
|
||||
total_count: 5,
|
||||
last_time: 1,
|
||||
today_count: 1,
|
||||
userInfos: [
|
||||
expect.objectContaining({ uid: 'favorite-user', nick: '收藏者', count: 3 }),
|
||||
],
|
||||
});
|
||||
expect(out.voteInfo).toEqual({
|
||||
total_count: 7,
|
||||
new_count: 2,
|
||||
new_nearby_count: 1,
|
||||
last_visit_time: 2,
|
||||
userInfos: [
|
||||
expect.objectContaining({
|
||||
uid: 'vote-user',
|
||||
uin: 12345,
|
||||
src: 4,
|
||||
latestTime: 3,
|
||||
count: 2,
|
||||
giftCount: 1,
|
||||
customId: 5,
|
||||
lastCharged: 6,
|
||||
bAvailableCnt: 7,
|
||||
bTodayVotedCnt: 8,
|
||||
nick: '点赞者',
|
||||
gender: 1,
|
||||
age: 20,
|
||||
isFriend: true,
|
||||
isvip: true,
|
||||
isSvip: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when there are no userLikeInfos', () => {
|
||||
expect(() => GetLike.deserialize({} as any, {})).toThrow('get profile like info empty');
|
||||
expect(() => GetLike.deserialize({} as any, { userLikeInfos: [] })).toThrow(
|
||||
'get profile like info empty',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when a returned user item has no uid', () => {
|
||||
expect(() =>
|
||||
GetLike.deserialize(makeDeps(), {
|
||||
userLikeInfos: [{ uid: 'u', voteInfo: { userInfos: [{ nick: 'broken' }] } }],
|
||||
}),
|
||||
).toThrow('get profile like user uid missing');
|
||||
});
|
||||
|
||||
it('defaults all count fields to 0 when omitted', () => {
|
||||
const out = GetLike.deserialize(makeDeps(), { userLikeInfos: [{ uid: 'u', time: 0 }] });
|
||||
expect(out.favoriteInfo.total_count).toBe(0);
|
||||
expect(out.voteInfo.total_count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (inbound UIN fill)', () => {
|
||||
it('fills leftover uin=0 rows through Identity.resolveUin once per uid', async () => {
|
||||
const deps = makeDeps({
|
||||
cachedSelfUid: 'self-uid',
|
||||
findUinByUid: () => null,
|
||||
resolveUin: async (uid) => (uid === 'shared-uid' ? 30003 : null),
|
||||
responseBody: {
|
||||
userLikeInfos: [
|
||||
{
|
||||
uid: 'self-uid',
|
||||
favoriteInfo: { userInfos: [{ uid: 'shared-uid', nick: '收藏' }] },
|
||||
voteInfo: {
|
||||
userInfos: [
|
||||
{ uid: 'shared-uid', nick: '点赞' },
|
||||
{ uid: 'miss-uid' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const out = await GetLike.invoke(deps, {});
|
||||
|
||||
expect(deps.identity.resolveUin).toHaveBeenCalledTimes(2);
|
||||
expect(deps.identity.resolveUin).toHaveBeenCalledWith('shared-uid');
|
||||
expect(deps.identity.resolveUin).toHaveBeenCalledWith('miss-uid');
|
||||
expect(out.favoriteInfo.userInfos[0]?.uin).toBe(30003);
|
||||
expect(out.voteInfo.userInfos[0]?.uin).toBe(30003);
|
||||
expect(out.voteInfo.userInfos[1]?.uin).toBe(0);
|
||||
});
|
||||
|
||||
it('does not call resolveUin when deserialize already filled the cache hit', async () => {
|
||||
const deps = makeDeps({
|
||||
cachedSelfUid: 'self-uid',
|
||||
findUinByUid: (uid) => (uid === 'cached-uid' ? 20002 : null),
|
||||
responseBody: {
|
||||
userLikeInfos: [
|
||||
{
|
||||
uid: 'self-uid',
|
||||
voteInfo: { userInfos: [{ uid: 'cached-uid', nick: '点赞者' }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const out = await GetLike.invoke(deps, {});
|
||||
|
||||
expect(deps.identity.resolveUin).not.toHaveBeenCalled();
|
||||
expect(out.voteInfo.userInfos[0]?.uin).toBe(20002);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
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(json: unknown = { ActionStatus: 'OK', ErrorCode: 0, rpt_block_list: [] }) {
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from([
|
||||
...v(1, 0xe17),
|
||||
...v(2, 0),
|
||||
...v(3, 0),
|
||||
...s(4, JSON.stringify(json)),
|
||||
]),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async (_cmd: string, _body: Uint8Array) => result),
|
||||
identity: { uin: '10001' } as IdentityService,
|
||||
};
|
||||
}
|
||||
|
||||
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: '',
|
||||
}),
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a verified empty list', async () => {
|
||||
await expect(GetUnidirectionalFriendList.invoke(makeDeps())).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0xcd4Req } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetInputStatus } from '../../../src/oidb-services/profile/set-input-status';
|
||||
|
||||
function makeDeps(uid: string | null = 'resolved-uid') {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
resolveUserUid: vi.fn(async () => uid as string),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SetInputStatus namespace', () => {
|
||||
it('declares 0xCD4_1', () => {
|
||||
expect(SetInputStatus.command).toBe(0xcd4);
|
||||
expect(SetInputStatus.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
describe('invoke', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0xcd4_1', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetInputStatus.invoke(deps, { userId: 10001, eventType: 1 });
|
||||
expect(deps.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xcd4_1');
|
||||
});
|
||||
|
||||
it('resolves the target uid before encoding', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetInputStatus.invoke(deps, { userId: 10001, eventType: 1 });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
});
|
||||
|
||||
it('packages uid + chatType=0 + eventType into reqBody', async () => {
|
||||
const deps = makeDeps('alice-uid');
|
||||
await SetInputStatus.invoke(deps, { userId: 10001, eventType: 7 });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<Oidb0xcd4Req>>(bytes);
|
||||
expect(env.body?.reqBody?.uid).toBe('alice-uid');
|
||||
// chatType=0 is proto3 default — omitted on the wire, comes back as null/undefined.
|
||||
expect(env.body?.reqBody?.chatType ?? 0).toBe(0);
|
||||
expect(env.body?.reqBody?.eventType).toBe(7);
|
||||
});
|
||||
|
||||
it('throws when the target uid cannot be resolved', async () => {
|
||||
const deps = makeDeps(null);
|
||||
await expect(
|
||||
SetInputStatus.invoke(deps, { userId: 10001, eventType: 1 }),
|
||||
).rejects.toThrow('target uid not found');
|
||||
expect(deps.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbSetProfile } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetProfile } from '../../../src/oidb-services/profile/set-profile';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
identity: { uin: '10001' } as any,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SetProfile namespace', () => {
|
||||
it('declares 0x112A_2', () => {
|
||||
expect(SetProfile.command).toBe(0x112a);
|
||||
expect(SetProfile.subCommand).toBe(2);
|
||||
});
|
||||
|
||||
describe('invoke', () => {
|
||||
it('packages sex into integer field 20009', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetProfile.invoke(deps, { sex: 1 });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbSetProfile>>(bytes);
|
||||
expect(env.body?.intProfiles).toEqual([{ fieldId: 20009, value: 1n }]);
|
||||
});
|
||||
|
||||
it('is a no-op when both nickname and personalNote are undefined', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetProfile.invoke(deps, {});
|
||||
expect(deps.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('packages nickname into fieldId 20002', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetProfile.invoke(deps, { nickname: 'New Nick' });
|
||||
expect(deps.sendRawPacket).toHaveBeenCalledOnce();
|
||||
const [wireName, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0x112a_2');
|
||||
const env = protobuf_decode<OidbBase<OidbSetProfile>>(bytes);
|
||||
expect(env.body?.stringProfiles).toEqual([{ fieldId: 20002, value: 'New Nick' }]);
|
||||
});
|
||||
|
||||
it('packages personalNote into fieldId 102', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetProfile.invoke(deps, { personalNote: 'on vacation' });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbSetProfile>>(bytes);
|
||||
expect(env.body?.stringProfiles).toEqual([{ fieldId: 102, value: 'on vacation' }]);
|
||||
});
|
||||
|
||||
it('packages both fields together', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetProfile.invoke(deps, { nickname: 'Foo', personalNote: 'Bar' });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbSetProfile>>(bytes);
|
||||
expect(env.body?.stringProfiles).toEqual([
|
||||
{ fieldId: 20002, value: 'Foo' },
|
||||
{ fieldId: 102, value: 'Bar' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes the bot uin in the request body', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetProfile.invoke(deps, { nickname: 'x' });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbSetProfile>>(bytes);
|
||||
expect(env.body?.uin).toBe(10001n);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0x112aReq } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetSelfLongNick } from '../../../src/oidb-services/profile/set-self-long-nick';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
identity: { uin: '10001' } as any,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SetSelfLongNick namespace', () => {
|
||||
it('declares 0x112A_2', () => {
|
||||
expect(SetSelfLongNick.command).toBe(0x112a);
|
||||
expect(SetSelfLongNick.subCommand).toBe(2);
|
||||
});
|
||||
|
||||
describe('invoke', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0x112a_2', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetSelfLongNick.invoke(deps, { longNick: 'hello world' });
|
||||
expect(deps.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x112a_2');
|
||||
});
|
||||
|
||||
it('wraps the long nick in profile { tag: 102, value }', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetSelfLongNick.invoke(deps, { longNick: 'hello world' });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<Oidb0x112aReq>>(bytes);
|
||||
expect(env.body?.uin).toBe(10001n);
|
||||
expect(env.body?.profile).toEqual({ tag: 102, value: 'hello world' });
|
||||
});
|
||||
|
||||
it('omits the value field when clearing the signature', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetSelfLongNick.invoke(deps, { longNick: '' });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe('08aa221002220708914e12020866');
|
||||
});
|
||||
|
||||
it('coerces non-string longNick into a string (defensive)', async () => {
|
||||
const deps = makeDeps();
|
||||
// simulate a misbehaving caller passing a number
|
||||
await SetSelfLongNick.invoke(deps, { longNick: 12345 as any });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<Oidb0x112aReq>>(bytes);
|
||||
expect(env.body?.profile?.value).toBe('12345');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
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 { Oidb0x9084Req, Oidb0x9084Resp } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchReactionSummary } from '../../../src/oidb-services/reaction/fetch-reaction-summary';
|
||||
|
||||
function makeSender(resp?: Buffer) {
|
||||
const defaultResp: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: resp ?? Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => defaultResp) };
|
||||
}
|
||||
|
||||
describe('FetchReactionSummary namespace', () => {
|
||||
it('declares command = 0x9084 and subCommand = 1', () => {
|
||||
expect(FetchReactionSummary.command).toBe(0x9084);
|
||||
expect(FetchReactionSummary.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('encodes groupId/sequence as BigInt for uint_64 fields', () => {
|
||||
const out = FetchReactionSummary.serialize({} as any, {
|
||||
groupId: 1022489779,
|
||||
sequence: 1730183,
|
||||
});
|
||||
expect(out.groupId).toBe(1022489779n);
|
||||
expect(out.sequence).toBe(1730183n);
|
||||
});
|
||||
|
||||
it('passes empty / default values for the unused filter fields', () => {
|
||||
const out = FetchReactionSummary.serialize({} as any, { groupId: 1, sequence: 1 });
|
||||
expect(out.emojiId).toBe('');
|
||||
expect(out.emojiType).toBe(0);
|
||||
expect(out.count).toBe(0);
|
||||
expect(out.field12).toBe(1);
|
||||
});
|
||||
|
||||
it('emits a 0-length cookie buffer (no continuation)', () => {
|
||||
const out = FetchReactionSummary.serialize({} as any, { groupId: 1, sequence: 1 });
|
||||
expect(out.cookie).toBeInstanceOf(Uint8Array);
|
||||
expect(out.cookie!.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('keeps entries with count > 0 (used emojis)', () => {
|
||||
const body: Oidb0x9084Resp = {
|
||||
entries: [
|
||||
{ lastReactionTime: 1779456439n, count: 3, emojiType: 1, emojiId: '76' },
|
||||
{ lastReactionTime: 1779456962n, count: 1, emojiType: 1, emojiId: '124' },
|
||||
],
|
||||
};
|
||||
expect(FetchReactionSummary.deserialize({} as any, body)).toEqual([
|
||||
{ emojiId: '76', emojiType: 1, count: 3, lastReactionTime: 1779456439 },
|
||||
{ emojiId: '124', emojiType: 1, count: 1, lastReactionTime: 1779456962 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters out catalog-tail entries (count omitted ≡ 0)', () => {
|
||||
const body: Oidb0x9084Resp = {
|
||||
entries: [
|
||||
{ lastReactionTime: 1n, count: 1, emojiType: 1, emojiId: '76' },
|
||||
{ emojiType: 1, emojiId: '124' }, // catalog entry
|
||||
{ emojiType: 1, emojiId: '66' }, // catalog entry
|
||||
],
|
||||
};
|
||||
const out = FetchReactionSummary.deserialize({} as any, body);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]!.emojiId).toBe('76');
|
||||
});
|
||||
|
||||
it('returns an empty array when the response has no entries', () => {
|
||||
expect(FetchReactionSummary.deserialize({} as any, {})).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults emojiId / emojiType to safe values when omitted', () => {
|
||||
const body: Oidb0x9084Resp = {
|
||||
entries: [{ count: 5, lastReactionTime: 1700000000n }],
|
||||
};
|
||||
const [entry] = FetchReactionSummary.deserialize({} as any, body);
|
||||
expect(entry).toEqual({
|
||||
emojiId: '',
|
||||
emojiType: 1,
|
||||
count: 5,
|
||||
lastReactionTime: 1700000000,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports lastReactionTime=0 when omitted on a used entry (degenerate but defensive)', () => {
|
||||
const body: Oidb0x9084Resp = { entries: [{ count: 2, emojiId: 'x', emojiType: 1 }] };
|
||||
expect(FetchReactionSummary.deserialize({} as any, body)[0]!.lastReactionTime).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e via mock sender)', () => {
|
||||
it('sends to OidbSvcTrpcTcp.0x9084_1', async () => {
|
||||
const sender = makeSender();
|
||||
await FetchReactionSummary.invoke(sender, { groupId: 1, sequence: 1 });
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x9084_1');
|
||||
});
|
||||
|
||||
it('end-to-end: encode → decode pipeline yields the parsed summary entries', async () => {
|
||||
const respEnvelope = Buffer.from(
|
||||
protobuf_encode<OidbBase<Oidb0x9084Resp>>({
|
||||
command: 0x9084,
|
||||
subCommand: 1,
|
||||
body: {
|
||||
entries: [
|
||||
{
|
||||
lastReactionTime: 1700000000n,
|
||||
count: 5,
|
||||
emojiType: 1,
|
||||
emojiId: '76',
|
||||
},
|
||||
{ emojiType: 1, emojiId: '124' }, // catalog noise
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sender = makeSender(respEnvelope);
|
||||
const result = await FetchReactionSummary.invoke(sender, { groupId: 1, sequence: 1 });
|
||||
expect(result).toEqual([
|
||||
{ emojiId: '76', emojiType: 1, count: 5, lastReactionTime: 1700000000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('encodes the request envelope with groupId/sequence as uint_64', async () => {
|
||||
const sender = makeSender();
|
||||
await FetchReactionSummary.invoke(sender, { groupId: 1022489779, sequence: 1730183 });
|
||||
const [, reqBytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<Oidb0x9084Req>>(reqBytes);
|
||||
expect(env.command).toBe(0x9084);
|
||||
expect(env.subCommand).toBe(1);
|
||||
expect(env.body?.groupId).toBe(1022489779n);
|
||||
expect(env.body?.sequence).toBe(1730183n);
|
||||
});
|
||||
|
||||
it('returns an empty array when server responds with empty envelope', async () => {
|
||||
const sender = makeSender();
|
||||
const result = await FetchReactionSummary.invoke(sender, { groupId: 1, sequence: 1 });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
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 { Oidb0x9083Req, Oidb0x9083Resp } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { GetEmojiLikes } from '../../../src/oidb-services/reaction/get-emoji-likes';
|
||||
import { env, s, v } from '../_pb-oracle';
|
||||
|
||||
function makeSender(resp?: Buffer) {
|
||||
const defaultResp: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: resp ?? Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => defaultResp) };
|
||||
}
|
||||
|
||||
describe('GetEmojiLikes namespace', () => {
|
||||
it('declares command = 0x9083 and subCommand = 1', () => {
|
||||
expect(GetEmojiLikes.command).toBe(0x9083);
|
||||
expect(GetEmojiLikes.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('encodes groupId/sequence as BigInt for uint_64 fields', () => {
|
||||
const out = GetEmojiLikes.serialize({} as any, {
|
||||
groupId: 12345,
|
||||
sequence: 99,
|
||||
emojiId: '76',
|
||||
});
|
||||
expect(out.groupId).toBe(12345n);
|
||||
expect(out.sequence).toBe(99n);
|
||||
});
|
||||
|
||||
it('passes emojiId through unchanged', () => {
|
||||
const out = GetEmojiLikes.serialize({} as any, {
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
emojiId: '128516',
|
||||
});
|
||||
expect(out.emojiId).toBe('128516');
|
||||
});
|
||||
|
||||
it('defaults emojiType to 1 when omitted', () => {
|
||||
const out = GetEmojiLikes.serialize({} as any, {
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
emojiId: 'x',
|
||||
});
|
||||
expect(out.emojiType).toBe(1);
|
||||
});
|
||||
|
||||
it('uses caller-supplied emojiType when present', () => {
|
||||
const out = GetEmojiLikes.serialize({} as any, {
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
emojiId: 'x',
|
||||
emojiType: 2,
|
||||
});
|
||||
expect(out.emojiType).toBe(2);
|
||||
});
|
||||
|
||||
it('defaults count to 10 when omitted', () => {
|
||||
const out = GetEmojiLikes.serialize({} as any, {
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
emojiId: 'x',
|
||||
});
|
||||
expect(out.count).toBe(10);
|
||||
});
|
||||
|
||||
it('uses caller-supplied count when present', () => {
|
||||
const out = GetEmojiLikes.serialize({} as any, {
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
emojiId: 'x',
|
||||
count: 50,
|
||||
});
|
||||
expect(out.count).toBe(50);
|
||||
});
|
||||
|
||||
it('passes the continuation cookie through as a string', () => {
|
||||
const out = GetEmojiLikes.serialize({} as any, {
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
emojiId: 'x',
|
||||
cookie: 'next',
|
||||
});
|
||||
expect(out.cookie).toBe('next');
|
||||
});
|
||||
|
||||
it('emits an empty cookie for the first page', () => {
|
||||
const out = GetEmojiLikes.serialize({} as any, {
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
emojiId: 'x',
|
||||
});
|
||||
expect(out.cookie).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('extracts users from the repeated list and returns the cookie', () => {
|
||||
const result = GetEmojiLikes.deserialize({} as any, {
|
||||
users: [{ uin: 10001n }, { uin: 20002n }],
|
||||
cookie: 'next',
|
||||
isLast: false,
|
||||
});
|
||||
expect(result.users).toEqual([{ uin: 10001 }, { uin: 20002 }]);
|
||||
expect(result.cookie).toBe('next');
|
||||
expect(result.isLast).toBe(false);
|
||||
});
|
||||
|
||||
it('filters out users with uin=0 (placeholder / zero-value)', () => {
|
||||
const result = GetEmojiLikes.deserialize({} as any, {
|
||||
users: [{ uin: 10001n }, { uin: 0n }, { uin: 20002n }],
|
||||
});
|
||||
expect(result.users).toEqual([{ uin: 10001 }, { uin: 20002 }]);
|
||||
});
|
||||
|
||||
it('returns empty users + empty cookie + isLast=true when body has nothing', () => {
|
||||
const result = GetEmojiLikes.deserialize({} as any, {});
|
||||
expect(result.users).toEqual([]);
|
||||
expect(result.cookie).toBe('');
|
||||
expect(result.isLast).toBe(true);
|
||||
});
|
||||
|
||||
it('returns isLast=true when cookie is absent (final page)', () => {
|
||||
const result = GetEmojiLikes.deserialize({} as any, {
|
||||
users: [{ uin: 10001n }],
|
||||
});
|
||||
expect(result.isLast).toBe(true);
|
||||
});
|
||||
|
||||
it('handles a missing users field', () => {
|
||||
const result = GetEmojiLikes.deserialize({} as any, {});
|
||||
expect(result.users).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e via mock sender)', () => {
|
||||
it('sends to OidbSvcTrpcTcp.0x9083_1', async () => {
|
||||
const sender = makeSender();
|
||||
await GetEmojiLikes.invoke(sender, { groupId: 1, sequence: 1, emojiId: '76' });
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x9083_1');
|
||||
});
|
||||
|
||||
it('end-to-end: encode → decode yields parsed user list + cookie', async () => {
|
||||
const respEnvelope = Buffer.from(
|
||||
protobuf_encode<OidbBase<Oidb0x9083Resp>>({
|
||||
command: 0x9083,
|
||||
subCommand: 1,
|
||||
body: {
|
||||
users: [{ uin: 10001n }],
|
||||
cookie: 'next',
|
||||
isLast: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sender = makeSender(respEnvelope);
|
||||
const result = await GetEmojiLikes.invoke(sender, {
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
emojiId: '76',
|
||||
});
|
||||
expect(result.users).toEqual([{ uin: 10001 }]);
|
||||
expect(result.cookie).toBe('next');
|
||||
expect(result.isLast).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the legacy-stub empty result when server returns ack-only', async () => {
|
||||
const sender = makeSender();
|
||||
const result = await GetEmojiLikes.invoke(sender, {
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
emojiId: '76',
|
||||
});
|
||||
expect(result).toEqual({ users: [], cookie: '', isLast: true });
|
||||
});
|
||||
|
||||
it('encodes the request envelope with proper field types', async () => {
|
||||
const sender = makeSender();
|
||||
await GetEmojiLikes.invoke(sender, {
|
||||
groupId: 12345,
|
||||
sequence: 99,
|
||||
emojiId: '128516',
|
||||
emojiType: 2,
|
||||
count: 20,
|
||||
});
|
||||
const [, reqBytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<Oidb0x9083Req>>(reqBytes);
|
||||
expect(env.command).toBe(0x9083);
|
||||
expect(env.subCommand).toBe(1);
|
||||
expect(env.body?.groupId).toBe(12345n);
|
||||
expect(env.body?.sequence).toBe(99n);
|
||||
expect(env.body?.emojiId).toBe('128516');
|
||||
expect(env.body?.emojiType).toBe(2);
|
||||
expect(env.body?.count).toBe(20);
|
||||
});
|
||||
|
||||
it('byte-oracle: request body uses type then emoji id, not the set-reaction order', async () => {
|
||||
const sender = makeSender();
|
||||
await GetEmojiLikes.invoke(sender, {
|
||||
groupId: 12345,
|
||||
sequence: 99,
|
||||
emojiId: '76',
|
||||
emojiType: 1,
|
||||
count: 20,
|
||||
});
|
||||
const [, reqBytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const body = [...v(2, 12345), ...v(3, 99), ...v(4, 1), ...s(5, '76'), ...v(8, 20)];
|
||||
expect(Buffer.from(reqBytes).toString('hex')).toBe(env(0x9083, 1, body, false));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbGroupReaction } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetReaction } from '../../../src/oidb-services/reaction/set-reaction';
|
||||
|
||||
function makeSender() {
|
||||
const defaultResp: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => defaultResp) };
|
||||
}
|
||||
|
||||
describe('SetReaction namespace', () => {
|
||||
it('declares command = 0x9082', () => {
|
||||
expect(SetReaction.command).toBe(0x9082);
|
||||
});
|
||||
|
||||
it('resolveSubCommand: isSet=true → 1, isSet=false → 2', () => {
|
||||
expect(
|
||||
SetReaction.resolveSubCommand({ groupId: 1, sequence: 1, code: 'x', isSet: true }),
|
||||
).toBe(1);
|
||||
expect(
|
||||
SetReaction.resolveSubCommand({ groupId: 1, sequence: 1, code: 'x', isSet: false }),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('produces type=1 for short QQ-face codes (≤3 chars)', () => {
|
||||
const out = SetReaction.serialize({} as any, {
|
||||
groupId: 12345,
|
||||
sequence: 99,
|
||||
code: '76',
|
||||
isSet: true,
|
||||
});
|
||||
expect(out).toEqual({
|
||||
groupUin: 12345,
|
||||
sequence: 99,
|
||||
code: '76',
|
||||
type: 1,
|
||||
field6: false,
|
||||
field7: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('produces type=1 for exactly 3-character codes (boundary)', () => {
|
||||
const out = SetReaction.serialize({} as any, {
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
code: '999',
|
||||
isSet: true,
|
||||
});
|
||||
expect(out.type).toBe(1);
|
||||
});
|
||||
|
||||
it('produces type=2 for codes longer than 3 chars (unicode codepoint)', () => {
|
||||
const out = SetReaction.serialize({} as any, {
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
code: '128516',
|
||||
isSet: false,
|
||||
});
|
||||
expect(out.type).toBe(2);
|
||||
});
|
||||
|
||||
it('passes through groupId/sequence/code verbatim', () => {
|
||||
const out = SetReaction.serialize({} as any, {
|
||||
groupId: 999,
|
||||
sequence: 42,
|
||||
code: 'abc',
|
||||
isSet: true,
|
||||
});
|
||||
expect(out.groupUin).toBe(999);
|
||||
expect(out.sequence).toBe(42);
|
||||
expect(out.code).toBe('abc');
|
||||
});
|
||||
|
||||
it('always sets field6/field7 to false (deferred behaviour)', () => {
|
||||
const out = SetReaction.serialize({} as any, {
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
code: 'x',
|
||||
isSet: true,
|
||||
});
|
||||
expect(out.field6).toBe(false);
|
||||
expect(out.field7).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('always returns void regardless of body content', () => {
|
||||
expect(SetReaction.deserialize({} as any, {})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('encode / decode round trip', () => {
|
||||
it('encode preserves groupUin/sequence/code/type in the envelope body', () => {
|
||||
const env: OidbBase<OidbGroupReaction> = {
|
||||
command: 0x9082,
|
||||
subCommand: 1,
|
||||
body: { groupUin: 12345, sequence: 99, code: '76', type: 1 } as OidbGroupReaction,
|
||||
};
|
||||
const bytes = SetReaction.encode(env);
|
||||
const decoded = SetReaction.decode(bytes);
|
||||
expect(decoded.command).toBe(0x9082);
|
||||
expect(decoded.subCommand).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e via mock sender)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0x9082_1 when isSet=true', async () => {
|
||||
const sender = makeSender();
|
||||
await SetReaction.invoke(sender, {
|
||||
groupId: 12345,
|
||||
sequence: 99,
|
||||
code: '76',
|
||||
isSet: true,
|
||||
});
|
||||
expect(sender.sendRawPacket).toHaveBeenCalledOnce();
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x9082_1');
|
||||
});
|
||||
|
||||
it('routes to OidbSvcTrpcTcp.0x9082_2 when isSet=false', async () => {
|
||||
const sender = makeSender();
|
||||
await SetReaction.invoke(sender, {
|
||||
groupId: 12345,
|
||||
sequence: 99,
|
||||
code: '76',
|
||||
isSet: false,
|
||||
});
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x9082_2');
|
||||
});
|
||||
|
||||
it('packages the serialized body into the OIDB envelope wire', async () => {
|
||||
const sender = makeSender();
|
||||
await SetReaction.invoke(sender, {
|
||||
groupId: 12345,
|
||||
sequence: 99,
|
||||
code: '128516',
|
||||
isSet: true,
|
||||
});
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupReaction>>(bytes);
|
||||
expect(env.command).toBe(0x9082);
|
||||
expect(env.subCommand).toBe(1);
|
||||
expect(env.body).toMatchObject({
|
||||
groupUin: 12345,
|
||||
sequence: 99,
|
||||
code: '128516',
|
||||
type: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves to undefined (void)', async () => {
|
||||
const sender = makeSender();
|
||||
const result = await SetReaction.invoke(sender, {
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
code: 'x',
|
||||
isSet: true,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { SetSearch } from '../../src/oidb-services/group-admin/set-search';
|
||||
|
||||
// Capture the exact OIDB request bytes SetSearch sends, via a fake sender.
|
||||
async function sentHex(params: SetSearch.Params): Promise<string> {
|
||||
let body: Uint8Array | undefined;
|
||||
const deps = {
|
||||
sendRawPacket: vi.fn(async (_cmd: string, b: Uint8Array): Promise<SendPacketResult> => {
|
||||
body = b;
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
}),
|
||||
};
|
||||
await SetSearch.invoke(deps as never, params);
|
||||
return Buffer.from(body!).toString('hex');
|
||||
}
|
||||
|
||||
// settings (field2) sub-message: noFingerOpen = tag 35 (35<<3 = 280 → varint
|
||||
// "98 02"), noCodeFingerOpen = tag 36 (36<<3 = 288 → "a0 02"). Tags RE'd
|
||||
// empirically on a live group (#191, see Oidb0x89a_0SearchSettings).
|
||||
describe('SetSearch (0x89a_0) settings encoding (#191)', () => {
|
||||
it('emits BOTH flags even at value 0 — toggle-off must not be dropped', async () => {
|
||||
const h = await sentHex({ groupId: 941657197, noFingerOpen: 0, noCodeFingerOpen: 0 });
|
||||
// field2 = { 35: 0, 36: 0 } → 12 06 980200 a00200
|
||||
expect(h).toContain('1206980200a00200');
|
||||
});
|
||||
|
||||
it('emits value 1 correctly', async () => {
|
||||
const h = await sentHex({ groupId: 941657197, noFingerOpen: 1, noCodeFingerOpen: 1 });
|
||||
expect(h).toContain('1206980201a00201');
|
||||
});
|
||||
|
||||
it('elides an unprovided flag (undefined → not sent)', async () => {
|
||||
const h = await sentHex({ groupId: 941657197, noCodeFingerOpen: 0 });
|
||||
expect(h).toContain('a00200'); // tag 36 (noCodeFingerOpen) present, value 0
|
||||
expect(h).not.toContain('980200'); // tag 35 (noFingerOpen) absent
|
||||
});
|
||||
|
||||
it('carries the group uin + command 0x89a_0', async () => {
|
||||
const deps = {
|
||||
sendRawPacket: vi.fn(async (): Promise<SendPacketResult> => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
})),
|
||||
};
|
||||
await SetSearch.invoke(deps as never, { groupId: 941657197, noFingerOpen: 1 });
|
||||
expect(deps.sendRawPacket).toHaveBeenCalledWith(
|
||||
'OidbSvcTrpcTcp.0x89a_0',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbClientKeyResp } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { ForceFetchClientKey } from '../../../src/oidb-services/web/force-fetch-client-key';
|
||||
|
||||
function makeSender(body?: OidbClientKeyResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbClientKeyResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('ForceFetchClientKey namespace', () => {
|
||||
it('declares 0x102A_1', () => {
|
||||
expect(ForceFetchClientKey.command).toBe(0x102a);
|
||||
expect(ForceFetchClientKey.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0x102a_1', async () => {
|
||||
const sender = makeSender({ clientKey: 'ck', keyIndex: 19, expireTime: 1800 });
|
||||
await ForceFetchClientKey.invoke(sender);
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x102a_1');
|
||||
});
|
||||
|
||||
it('returns the parsed clientKey / keyIndex / expireTime', async () => {
|
||||
const sender = makeSender({ clientKey: 'KEY', keyIndex: 7, expireTime: 600 });
|
||||
const out = await ForceFetchClientKey.invoke(sender);
|
||||
expect(out).toEqual({ clientKey: 'KEY', keyIndex: '7', expireTime: '600' });
|
||||
});
|
||||
|
||||
it('falls back to keyIndex="19" and expireTime="1800" when server omits them', async () => {
|
||||
const sender = makeSender({ clientKey: 'KEY' });
|
||||
const out = await ForceFetchClientKey.invoke(sender);
|
||||
expect(out.keyIndex).toBe('19');
|
||||
expect(out.expireTime).toBe('1800');
|
||||
});
|
||||
|
||||
it('returns empty clientKey when server omits it', async () => {
|
||||
const sender = makeSender({});
|
||||
const out = await ForceFetchClientKey.invoke(sender);
|
||||
expect(out.clientKey).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
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 { OidbGetPskeyReq, OidbGetPskeyResp } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { GetPskey } from '../../../src/oidb-services/web/get-pskey';
|
||||
|
||||
function makeSender(body?: OidbGetPskeyResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbGetPskeyResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('GetPskey namespace', () => {
|
||||
it('declares 0x102A_0', () => {
|
||||
expect(GetPskey.command).toBe(0x102a);
|
||||
expect(GetPskey.subCommand).toBe(0);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('passes the domainList through verbatim', () => {
|
||||
const out = GetPskey.serialize({} as any, {
|
||||
domainList: ['qun.qq.com', 'qzone.qq.com'],
|
||||
});
|
||||
expect(out.domainList).toEqual(['qun.qq.com', 'qzone.qq.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('builds a Map from server pskeyItems', () => {
|
||||
const { domainPskeyMap } = GetPskey.deserialize({} as any, {
|
||||
pskeyItems: [
|
||||
{ domain: 'qun.qq.com', pskey: 'pskey-qun' },
|
||||
{ domain: 'qzone.qq.com', pskey: 'pskey-qzone' },
|
||||
],
|
||||
});
|
||||
expect(domainPskeyMap.get('qun.qq.com')).toBe('pskey-qun');
|
||||
expect(domainPskeyMap.get('qzone.qq.com')).toBe('pskey-qzone');
|
||||
});
|
||||
|
||||
it('drops entries with missing domain or pskey', () => {
|
||||
const { domainPskeyMap } = GetPskey.deserialize({} as any, {
|
||||
pskeyItems: [
|
||||
{ domain: 'qun.qq.com', pskey: 'ok' },
|
||||
{ domain: 'missing-pskey.com' },
|
||||
{ pskey: 'missing-domain' },
|
||||
] as any,
|
||||
});
|
||||
expect(domainPskeyMap.size).toBe(1);
|
||||
expect(domainPskeyMap.get('qun.qq.com')).toBe('ok');
|
||||
});
|
||||
|
||||
it('returns empty Map when pskeyItems is absent', () => {
|
||||
const { domainPskeyMap } = GetPskey.deserialize({} as any, {});
|
||||
expect(domainPskeyMap.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0x102a_0', async () => {
|
||||
const sender = makeSender({ pskeyItems: [] });
|
||||
await GetPskey.invoke(sender, { domainList: ['qq.com'] });
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x102a_0');
|
||||
});
|
||||
|
||||
it('encodes the domain list into the request body', async () => {
|
||||
const sender = makeSender({ pskeyItems: [] });
|
||||
await GetPskey.invoke(sender, { domainList: ['qun.qq.com'] });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGetPskeyReq>>(bytes);
|
||||
expect(env.body?.domainList).toEqual(['qun.qq.com']);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user