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
778 lines
27 KiB
TypeScript
778 lines
27 KiB
TypeScript
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 { SendPacketResult } from '@snowluma/common/packet-sender';
|
|
import type {
|
|
OidbBase,
|
|
OidbEmpty,
|
|
OidbFriend,
|
|
OidbFriendCategory,
|
|
OidbRobotUinRangeResponse,
|
|
OidbSvcTrpcTcp0x88D_0Response,
|
|
OidbSvcTrpcTcp0xFD4_1Response,
|
|
OidbSvcTrpcTcp0xFE5_2Response,
|
|
OidbSvcTrpcTcp0xFE7_3Response,
|
|
} from '@snowluma/proto-defs/oidb';
|
|
import type {
|
|
OidbFriendListRequest,
|
|
OidbRobotUinRangeRequest,
|
|
OidbSetFriendCategoryRequest,
|
|
} from '@snowluma/proto-defs/oidb-actions/base';
|
|
|
|
import { ContactsApi } from '../../src/bridge/apis/contacts';
|
|
|
|
interface ObservedGroupRequestUserByUin {
|
|
uin?: pb<1, uint_32>;
|
|
name?: pb<2, string>;
|
|
}
|
|
|
|
interface ObservedGroupRequestUserByUid {
|
|
uid?: pb<1, string>;
|
|
name?: pb<2, string>;
|
|
}
|
|
|
|
interface ObservedGroupRequestGroup {
|
|
groupUin?: pb<1, uint_32>;
|
|
groupName?: pb<2, string>;
|
|
}
|
|
|
|
interface ObservedGroupRequest<TUser> {
|
|
sequence?: pb<1, uint_64>;
|
|
eventType?: pb<2, uint_32>;
|
|
state?: pb<3, uint_32>;
|
|
group?: pb<4, ObservedGroupRequestGroup>;
|
|
target?: pb<5, TUser>;
|
|
invitor?: pb<6, TUser>;
|
|
operatorUser?: pb<7, TUser>;
|
|
comment?: pb<10, string>;
|
|
}
|
|
|
|
interface ObservedGroupRequestResponse<TUser> {
|
|
requests?: pb_repeated<1, ObservedGroupRequest<TUser>>;
|
|
}
|
|
|
|
function friend(
|
|
uin: number,
|
|
categoryId: number,
|
|
uid: string,
|
|
nickname: string,
|
|
remark = '',
|
|
): OidbFriend {
|
|
return {
|
|
uin,
|
|
uid,
|
|
customGroup: categoryId,
|
|
additional: [
|
|
{
|
|
type: 1,
|
|
layer1: {
|
|
properties: [
|
|
{ code: 20002, value: Buffer.from(nickname) },
|
|
{ code: 103, value: Buffer.from(remark) },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function category(
|
|
categoryId: number,
|
|
categoryName: string,
|
|
memberCount: number,
|
|
sortId: number,
|
|
): OidbFriendCategory {
|
|
return { categoryId, categoryName, memberCount, sortId };
|
|
}
|
|
|
|
function packet(body: OidbSvcTrpcTcp0xFD4_1Response): SendPacketResult {
|
|
return {
|
|
success: true,
|
|
gotResponse: true,
|
|
errorCode: 0,
|
|
errorMessage: '',
|
|
responseData: Buffer.from(
|
|
protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFD4_1Response>>({ body }),
|
|
),
|
|
};
|
|
}
|
|
|
|
function robotRangePacket(body: OidbRobotUinRangeResponse): SendPacketResult {
|
|
return {
|
|
success: true,
|
|
gotResponse: true,
|
|
errorCode: 0,
|
|
errorMessage: '',
|
|
responseData: Buffer.from(protobuf_encode<OidbBase<OidbRobotUinRangeResponse>>({ body })),
|
|
};
|
|
}
|
|
|
|
function memberListPacket(body: OidbSvcTrpcTcp0xFE7_3Response): SendPacketResult {
|
|
return {
|
|
success: true,
|
|
gotResponse: true,
|
|
errorCode: 0,
|
|
errorMessage: '',
|
|
responseData: Buffer.from(
|
|
protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFE7_3Response>>({ body }),
|
|
),
|
|
};
|
|
}
|
|
|
|
function groupListPacket(body: OidbSvcTrpcTcp0xFE5_2Response): SendPacketResult {
|
|
return {
|
|
success: true,
|
|
gotResponse: true,
|
|
errorCode: 0,
|
|
errorMessage: '',
|
|
responseData: Buffer.from(
|
|
protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFE5_2Response>>({ body }),
|
|
),
|
|
};
|
|
}
|
|
|
|
function groupDetailPacket(body: OidbSvcTrpcTcp0x88D_0Response): SendPacketResult {
|
|
return {
|
|
success: true,
|
|
gotResponse: true,
|
|
errorCode: 0,
|
|
errorMessage: '',
|
|
responseData: Buffer.from(
|
|
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({ body }),
|
|
),
|
|
};
|
|
}
|
|
|
|
function groupRequestPacketByUin(
|
|
body: ObservedGroupRequestResponse<ObservedGroupRequestUserByUin>,
|
|
): SendPacketResult {
|
|
return {
|
|
success: true,
|
|
gotResponse: true,
|
|
errorCode: 0,
|
|
errorMessage: '',
|
|
responseData: Buffer.from(
|
|
protobuf_encode<OidbBase<ObservedGroupRequestResponse<ObservedGroupRequestUserByUin>>>({
|
|
body,
|
|
}),
|
|
),
|
|
};
|
|
}
|
|
|
|
function groupRequestPacketByUid(
|
|
body: ObservedGroupRequestResponse<ObservedGroupRequestUserByUid>,
|
|
): SendPacketResult {
|
|
return {
|
|
success: true,
|
|
gotResponse: true,
|
|
errorCode: 0,
|
|
errorMessage: '',
|
|
responseData: Buffer.from(
|
|
protobuf_encode<OidbBase<ObservedGroupRequestResponse<ObservedGroupRequestUserByUid>>>({
|
|
body,
|
|
}),
|
|
),
|
|
};
|
|
}
|
|
|
|
function apiForPages(pages: OidbSvcTrpcTcp0xFD4_1Response[]) {
|
|
let index = 0;
|
|
const sendRawPacket = vi.fn(
|
|
async (_cmd: string, _body: Uint8Array): Promise<SendPacketResult> => {
|
|
const page = pages[index++];
|
|
|
|
if (!page) {
|
|
throw new Error(`unexpected friend-list page ${index}`);
|
|
}
|
|
|
|
return packet(page);
|
|
},
|
|
);
|
|
const rememberFriends = vi.fn();
|
|
const api = new ContactsApi({
|
|
sendRawPacket,
|
|
identity: { rememberFriends },
|
|
} as any);
|
|
return { api, sendRawPacket, rememberFriends };
|
|
}
|
|
|
|
function apiForCategoryChange(roster: OidbSvcTrpcTcp0xFD4_1Response) {
|
|
const sendRawPacket = vi.fn(
|
|
async (command: string, _body: Uint8Array): Promise<SendPacketResult> => {
|
|
if (command === 'OidbSvcTrpcTcp.0xfd4_1') {
|
|
return packet(roster);
|
|
}
|
|
|
|
if (command === 'OidbSvcTrpcTcp.0x1255_0') {
|
|
return {
|
|
success: true,
|
|
gotResponse: true,
|
|
errorCode: 0,
|
|
errorMessage: '',
|
|
responseData: Buffer.from(
|
|
protobuf_encode<OidbBase<OidbEmpty>>({
|
|
command: 0x1255,
|
|
subCommand: 0,
|
|
}),
|
|
),
|
|
};
|
|
}
|
|
|
|
throw new Error(`unexpected command: ${command}`);
|
|
},
|
|
);
|
|
const api = new ContactsApi({
|
|
sendRawPacket,
|
|
identity: { uin: '10001', rememberFriends: vi.fn() },
|
|
} as any);
|
|
return { api, sendRawPacket };
|
|
}
|
|
|
|
describe('apis/contacts / group roster', () => {
|
|
it('maps QQ group remarks into the returned and remembered roster', async () => {
|
|
const rememberGroups = vi.fn();
|
|
const sendRawPacket = vi.fn(async () =>
|
|
groupListPacket({
|
|
groups: [
|
|
{
|
|
groupUin: 123456789,
|
|
info: {
|
|
groupName: 'Project',
|
|
memberCount: 42,
|
|
memberMax: 500,
|
|
shutUpAllTimestamp: 4_294_967_295,
|
|
},
|
|
customInfo: { remark: ' My Project ' },
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const api = new ContactsApi({
|
|
sendRawPacket,
|
|
identity: { rememberGroups },
|
|
} as any);
|
|
|
|
const groups = await api.fetchGroupList();
|
|
|
|
expect(groups).toEqual([
|
|
expect.objectContaining({
|
|
groupId: 123456789,
|
|
groupName: 'Project',
|
|
remark: ' My Project ',
|
|
memberCount: 42,
|
|
memberMax: 500,
|
|
allMuted: true,
|
|
}),
|
|
]);
|
|
expect(rememberGroups).toHaveBeenCalledWith(groups);
|
|
});
|
|
|
|
// #490: `memo` prefers the announcement, so a group with both fields set used
|
|
// to lose its description entirely. The description must stay readable.
|
|
it('keeps the group description readable when an announcement is also set (#490)', async () => {
|
|
const sendRawPacket = vi.fn(async () =>
|
|
groupListPacket({
|
|
groups: [
|
|
{
|
|
groupUin: 123456789,
|
|
info: {
|
|
groupName: 'Both Set',
|
|
description: '简介正文',
|
|
announcement: '公告正文',
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const api = new ContactsApi({
|
|
sendRawPacket,
|
|
identity: { rememberGroups: vi.fn() },
|
|
} as any);
|
|
|
|
const [group] = await api.fetchGroupList();
|
|
|
|
expect(group?.memo).toBe('公告正文');
|
|
expect(group?.description).toBe('简介正文');
|
|
});
|
|
|
|
it('reports the description for a group that has no announcement (#490)', async () => {
|
|
const sendRawPacket = vi.fn(async () =>
|
|
groupListPacket({
|
|
groups: [
|
|
{
|
|
groupUin: 123456789,
|
|
info: { groupName: 'Description Only', description: '只有简介' },
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const api = new ContactsApi({
|
|
sendRawPacket,
|
|
identity: { rememberGroups: vi.fn() },
|
|
} as any);
|
|
|
|
const [group] = await api.fetchGroupList();
|
|
|
|
// memo still falls back to the description (unchanged pre-#490 behaviour).
|
|
expect(group?.memo).toBe('只有简介');
|
|
expect(group?.description).toBe('只有简介');
|
|
});
|
|
|
|
it('leaves the description unset on a non-member detail lookup (#490)', async () => {
|
|
const sendRawPacket = vi.fn(async () =>
|
|
groupDetailPacket({
|
|
groupInfo: {
|
|
uin: 123456789n,
|
|
results: { name: 'Not Joined', noticePreview: '公告预览' },
|
|
},
|
|
}),
|
|
);
|
|
const api = new ContactsApi({ sendRawPacket, identity: {} } as any);
|
|
|
|
const detail = await api.fetchGroupDetail(123456789);
|
|
|
|
// 0x88D_0 has no description tag, so there is nothing to expose — the field
|
|
// must stay absent rather than echo the announcement preview.
|
|
expect(detail?.memo).toBe('公告预览');
|
|
expect(detail?.description).toBeUndefined();
|
|
});
|
|
|
|
it('maps the group-wide mute timestamp from a single-group detail', async () => {
|
|
const sendRawPacket = vi.fn(async () =>
|
|
groupDetailPacket({
|
|
groupInfo: {
|
|
uin: 123456789n,
|
|
results: {
|
|
name: 'Muted Group',
|
|
shutUpAllTimestamp: 4_294_967_295,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const api = new ContactsApi({ sendRawPacket, identity: {} } as any);
|
|
|
|
const detail = await api.fetchGroupDetail(123456789);
|
|
|
|
expect(detail).toEqual(
|
|
expect.objectContaining({
|
|
groupId: 123456789,
|
|
groupName: 'Muted Group',
|
|
allMuted: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('treats a past group-mute expire as unmuted (#356)', async () => {
|
|
const sendRawPacket = vi.fn(async () =>
|
|
groupListPacket({
|
|
groups: [
|
|
{
|
|
groupUin: 123456789,
|
|
info: {
|
|
groupName: 'Was Muted',
|
|
shutUpAllTimestamp: 1_700_000_000,
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const api = new ContactsApi({
|
|
sendRawPacket,
|
|
identity: { rememberGroups: vi.fn() },
|
|
} as any);
|
|
|
|
const groups = await api.fetchGroupList();
|
|
expect(groups[0]?.allMuted).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('apis/contacts / group requests', () => {
|
|
it('maps the native numeric-account response into directly actionable requests', async () => {
|
|
const rememberGroupRequests = vi.fn();
|
|
const findUidByUin = vi.fn((uin: number) =>
|
|
uin === 1_234_567_890 ? 'cached_target_uid' : null,
|
|
);
|
|
const sendRawPacket = vi.fn(async () =>
|
|
groupRequestPacketByUin({
|
|
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',
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const api = new ContactsApi({
|
|
sendRawPacket,
|
|
identity: { findUidByUin, rememberGroupRequests },
|
|
} as any);
|
|
|
|
const requests = await api.fetchGroupRequests(false);
|
|
|
|
expect(requests).toEqual([
|
|
expect.objectContaining({
|
|
groupId: 1_095_186_374,
|
|
targetUid: 'cached_target_uid',
|
|
targetUin: 1_234_567_890,
|
|
invitorUin: 2_345_678_901,
|
|
operatorUin: 3_456_789_012,
|
|
sequence: 1_785_525_232_784_291,
|
|
notifyType: 7,
|
|
eventType: 1,
|
|
filtered: false,
|
|
}),
|
|
]);
|
|
expect(rememberGroupRequests).toHaveBeenCalledWith(requests);
|
|
});
|
|
|
|
it('retains the UID-form response for correlating real-time request pushes', async () => {
|
|
const rememberGroupRequests = vi.fn();
|
|
const findUinByUid = vi.fn((uid: string) => (uid === 'target_uid' ? 1_234_567_890 : null));
|
|
const sendRawPacket = vi.fn(async () =>
|
|
groupRequestPacketByUid({
|
|
requests: [
|
|
{
|
|
sequence: 42n,
|
|
eventType: 1,
|
|
state: 1,
|
|
group: { groupUin: 999, groupName: 'group' },
|
|
target: { uid: 'target_uid', name: 'requester' },
|
|
invitor: { uid: 'inviter_uid', name: 'inviter' },
|
|
operatorUser: { uid: 'operator_uid', name: 'operator' },
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const api = new ContactsApi({
|
|
sendRawPacket,
|
|
identity: { findUinByUid, rememberGroupRequests },
|
|
} as any);
|
|
|
|
const requests = await api.fetchGroupRequestsByUid(true);
|
|
|
|
expect(requests).toEqual([
|
|
expect.objectContaining({
|
|
targetUid: 'target_uid',
|
|
targetUin: 1_234_567_890,
|
|
invitorUid: 'inviter_uid',
|
|
operatorUid: 'operator_uid',
|
|
notifyType: 7,
|
|
eventType: 1,
|
|
filtered: true,
|
|
}),
|
|
]);
|
|
expect(rememberGroupRequests).toHaveBeenCalledWith(requests);
|
|
});
|
|
});
|
|
|
|
describe('apis/contacts / categorized friend roster', () => {
|
|
it('keeps fetchFriendList flat while traversing cookie pages', async () => {
|
|
const cookie = Uint8Array.from([0x01]);
|
|
const { api, sendRawPacket } = apiForPages([
|
|
{ cookie, friends: [friend(10001, 0, 'u1', 'Default')] },
|
|
{ friends: [friend(10002, 7, 'u2', 'Alice', 'A')] },
|
|
]);
|
|
|
|
await expect(api.fetchFriendList()).resolves.toEqual([
|
|
{ uin: 10001, uid: 'u1', nickname: 'Default', remark: '', categoryId: 0 },
|
|
{ uin: 10002, uid: 'u2', nickname: 'Alice', remark: 'A', categoryId: 7 },
|
|
]);
|
|
expect(sendRawPacket).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('groups a complete multi-page roster and preserves empty categories', async () => {
|
|
const cookie = Uint8Array.from([0xaa, 0xbb]);
|
|
const { api, sendRawPacket, rememberFriends } = apiForPages([
|
|
{
|
|
cookie,
|
|
friends: [friend(10001, 0, 'u1', 'Default')],
|
|
categories: [
|
|
category(0, '我的好友', 1, 0),
|
|
category(7, 'Work', 1, 1),
|
|
category(9, 'Empty', 0, 2),
|
|
],
|
|
},
|
|
{
|
|
friends: [friend(10002, 7, 'u2', 'Alice', 'A')],
|
|
categories: [category(7, 'Work', 1, 1)],
|
|
},
|
|
]);
|
|
|
|
await expect(api.fetchFriendCategories()).resolves.toEqual([
|
|
{
|
|
categoryId: 0,
|
|
categoryName: '我的好友',
|
|
memberCount: 1,
|
|
sortId: 0,
|
|
friends: [
|
|
{ uin: 10001, uid: 'u1', nickname: 'Default', remark: '', categoryId: 0 },
|
|
],
|
|
},
|
|
{
|
|
categoryId: 7,
|
|
categoryName: 'Work',
|
|
memberCount: 1,
|
|
sortId: 1,
|
|
friends: [{ uin: 10002, uid: 'u2', nickname: 'Alice', remark: 'A', categoryId: 7 }],
|
|
},
|
|
{
|
|
categoryId: 9,
|
|
categoryName: 'Empty',
|
|
memberCount: 0,
|
|
sortId: 2,
|
|
friends: [],
|
|
},
|
|
]);
|
|
|
|
expect(sendRawPacket).toHaveBeenCalledTimes(2);
|
|
const secondRequest = protobuf_decode<OidbBase<OidbFriendListRequest>>(
|
|
sendRawPacket.mock.calls[1]![1],
|
|
);
|
|
expect(secondRequest.body?.cookie).toEqual(cookie);
|
|
expect(rememberFriends).toHaveBeenCalledOnce();
|
|
expect(rememberFriends).toHaveBeenCalledWith([
|
|
{ uin: 10001, uid: 'u1', nickname: 'Default', remark: '', categoryId: 0 },
|
|
{ uin: 10002, uid: 'u2', nickname: 'Alice', remark: 'A', categoryId: 7 },
|
|
]);
|
|
});
|
|
|
|
it('rejects a friend whose category metadata never arrives', async () => {
|
|
const { api } = apiForPages([
|
|
{
|
|
friends: [friend(10001, 99, 'u1', 'Orphan')],
|
|
categories: [],
|
|
},
|
|
]);
|
|
|
|
await expect(api.fetchFriendCategories()).rejects.toThrow('missing category 99');
|
|
});
|
|
|
|
it('rejects a repeated non-empty pagination cookie', async () => {
|
|
const cookie = Uint8Array.from([0xaa]);
|
|
const { api } = apiForPages([{ cookie }, { cookie }]);
|
|
|
|
await expect(api.fetchFriendCategories()).rejects.toThrow('repeated friend-list cookie aa');
|
|
});
|
|
|
|
it('moves a live friend to a category selected by ID', async () => {
|
|
const { api, sendRawPacket } = apiForCategoryChange({
|
|
friends: [friend(10001, 0, 'u_friend', 'Alice')],
|
|
categories: [category(0, '我的好友', 1, 0), category(7, 'Work', 0, 1)],
|
|
});
|
|
|
|
await api.setFriendCategory({ uin: 10001, categoryId: 7 });
|
|
|
|
expect(sendRawPacket).toHaveBeenCalledTimes(2);
|
|
const [command, bytes] = sendRawPacket.mock.calls[1]!;
|
|
expect(command).toBe('OidbSvcTrpcTcp.0x1255_0');
|
|
const envelope = protobuf_decode<OidbBase<OidbSetFriendCategoryRequest>>(bytes);
|
|
expect(envelope.body).toEqual({ uid: 'u_friend', categoryId: 7 });
|
|
});
|
|
|
|
it('resolves an exact unique category name before moving the friend', async () => {
|
|
const { api, sendRawPacket } = apiForCategoryChange({
|
|
friends: [friend(10001, 0, 'u_friend', 'Alice')],
|
|
categories: [category(0, '我的好友', 1, 0), category(7, 'Work', 0, 1)],
|
|
});
|
|
|
|
await api.setFriendCategory({ uin: 10001, categoryName: 'Work' });
|
|
|
|
const envelope = protobuf_decode<OidbBase<OidbSetFriendCategoryRequest>>(
|
|
sendRawPacket.mock.calls[1]![1],
|
|
);
|
|
expect(envelope.body).toEqual({ uid: 'u_friend', categoryId: 7 });
|
|
});
|
|
|
|
it('rejects unknown friends and categories without sending a mutation', async () => {
|
|
const unknownFriend = apiForCategoryChange({
|
|
friends: [friend(10001, 0, 'u_friend', 'Alice')],
|
|
categories: [category(0, '我的好友', 1, 0)],
|
|
});
|
|
await expect(
|
|
unknownFriend.api.setFriendCategory({
|
|
uin: 10002,
|
|
categoryId: 0,
|
|
}),
|
|
).rejects.toThrow('friend 10002 is not in the live roster');
|
|
expect(unknownFriend.sendRawPacket).toHaveBeenCalledTimes(1);
|
|
|
|
const unknownCategory = apiForCategoryChange({
|
|
friends: [friend(10001, 0, 'u_friend', 'Alice')],
|
|
categories: [category(0, '我的好友', 1, 0)],
|
|
});
|
|
await expect(
|
|
unknownCategory.api.setFriendCategory({
|
|
uin: 10001,
|
|
categoryId: 7,
|
|
}),
|
|
).rejects.toThrow('friend category 7 does not exist');
|
|
expect(unknownCategory.sendRawPacket).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('rejects duplicate category names instead of selecting one arbitrarily', async () => {
|
|
const { api, sendRawPacket } = apiForCategoryChange({
|
|
friends: [friend(10001, 0, 'u_friend', 'Alice')],
|
|
categories: [category(0, 'Work', 1, 0), category(7, 'Work', 0, 1)],
|
|
});
|
|
|
|
await expect(
|
|
api.setFriendCategory({
|
|
uin: 10001,
|
|
categoryName: 'Work',
|
|
}),
|
|
).rejects.toThrow('friend category name "Work" is ambiguous');
|
|
expect(sendRawPacket).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('rejects missing or conflicting selectors before reading the roster', async () => {
|
|
const { api, sendRawPacket } = apiForCategoryChange({
|
|
friends: [],
|
|
categories: [],
|
|
});
|
|
|
|
await expect(api.setFriendCategory({ uin: 10001 })).rejects.toThrow(
|
|
'exactly one of categoryId or categoryName is required',
|
|
);
|
|
await expect(
|
|
api.setFriendCategory({
|
|
uin: 10001,
|
|
categoryId: 7,
|
|
categoryName: 'Work',
|
|
}),
|
|
).rejects.toThrow('exactly one of categoryId or categoryName is required');
|
|
expect(sendRawPacket).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('apis/contacts / robot group-member classification', () => {
|
|
it('loads QQ robot ranges once and marks every fetched member', async () => {
|
|
const rememberGroupMembers = vi.fn();
|
|
const sendRawPacket = vi.fn(
|
|
async (cmd: string, _data: Uint8Array): Promise<SendPacketResult> => {
|
|
if (cmd === 'OidbSvcTrpcTcp.0x496_0') {
|
|
return robotRangePacket({
|
|
robotConfig: {
|
|
version: 206,
|
|
ranges: [{ minUin: 3_889_000_000n, maxUin: 3_889_999_999n }],
|
|
},
|
|
});
|
|
}
|
|
|
|
if (cmd === 'OidbSvcTrpcTcp.0xfe7_3') {
|
|
return memberListPacket({
|
|
groupUin: 42,
|
|
members: [
|
|
{ uin: { uid: 'u_robot', uin: 3_889_054_356 }, memberName: 'robot' },
|
|
{ uin: { uid: 'u_person', uin: 1_234_567_890 }, memberName: 'person' },
|
|
],
|
|
});
|
|
}
|
|
|
|
throw new Error(`unexpected command: ${cmd}`);
|
|
},
|
|
);
|
|
const api = new ContactsApi({
|
|
sendRawPacket,
|
|
identity: { uin: '10001', rememberGroupMembers },
|
|
} as any);
|
|
|
|
const first = await api.fetchGroupMemberList(42);
|
|
const second = await api.fetchGroupMemberList(42, { force: true });
|
|
|
|
expect(first).toEqual([
|
|
expect.objectContaining({ uin: 3_889_054_356, isRobot: true }),
|
|
expect.objectContaining({ uin: 1_234_567_890, isRobot: false }),
|
|
]);
|
|
expect(second).toEqual(first);
|
|
expect(rememberGroupMembers).toHaveBeenCalledWith(42, first);
|
|
expect(sendRawPacket.mock.calls.map(([cmd]) => cmd).sort()).toEqual([
|
|
'OidbSvcTrpcTcp.0x496_0',
|
|
'OidbSvcTrpcTcp.0xfe7_3',
|
|
'OidbSvcTrpcTcp.0xfe7_3',
|
|
]);
|
|
expect(
|
|
sendRawPacket.mock.calls.filter(([cmd]) => cmd === 'OidbSvcTrpcTcp.0x496_0'),
|
|
).toHaveLength(1);
|
|
|
|
const rangeCall = sendRawPacket.mock.calls.find(
|
|
([cmd]) => cmd === 'OidbSvcTrpcTcp.0x496_0',
|
|
)!;
|
|
const request = protobuf_decode<OidbBase<OidbRobotUinRangeRequest>>(rangeCall[1]);
|
|
expect(request.body).toMatchObject({ justFetchMsgConfig: 1, type: 1 });
|
|
});
|
|
|
|
it('propagates an invalid range snapshot and retries it on the next fetch', async () => {
|
|
let rangeAttempts = 0;
|
|
const sendRawPacket = vi.fn(
|
|
async (cmd: string, _data: Uint8Array): Promise<SendPacketResult> => {
|
|
if (cmd === 'OidbSvcTrpcTcp.0x496_0') {
|
|
rangeAttempts += 1;
|
|
return rangeAttempts === 1
|
|
? robotRangePacket({})
|
|
: robotRangePacket({
|
|
robotConfig: {
|
|
version: 206,
|
|
ranges: [{ minUin: 3_889_000_000n, maxUin: 3_889_999_999n }],
|
|
},
|
|
});
|
|
}
|
|
|
|
if (cmd === 'OidbSvcTrpcTcp.0xfe7_3') {
|
|
return memberListPacket({
|
|
groupUin: 42,
|
|
members: [
|
|
{ uin: { uid: 'u_robot', uin: 3_889_054_356 }, memberName: 'robot' },
|
|
],
|
|
});
|
|
}
|
|
|
|
throw new Error(`unexpected command: ${cmd}`);
|
|
},
|
|
);
|
|
const rememberGroupMembers = vi.fn();
|
|
const api = new ContactsApi({
|
|
sendRawPacket,
|
|
identity: { uin: '10001', rememberGroupMembers },
|
|
} as any);
|
|
|
|
await expect(api.fetchGroupMemberList(42)).rejects.toThrow(
|
|
'0x496_0 response missing robot range config',
|
|
);
|
|
await expect(api.fetchGroupMemberList(42)).resolves.toEqual([
|
|
expect.objectContaining({ isRobot: true }),
|
|
]);
|
|
|
|
expect(rangeAttempts).toBe(2);
|
|
expect(rememberGroupMembers).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe('apis/contacts / invite-card pending application', () => {
|
|
it('remembers, reads, and reverse-looks-up a card sequence', () => {
|
|
const api = new ContactsApi({ identity: { uin: '10001' } } as any);
|
|
expect(api.getGroupInviteCardSequence(12345)).toBeUndefined();
|
|
|
|
expect(api.rememberGroupInviteCardSequence(12345, 778899)).toBe(true);
|
|
expect(api.rememberGroupInviteCardSequence(12345, 778899)).toBe(false);
|
|
|
|
expect(api.getGroupInviteCardSequence(12345)).toBe(778899);
|
|
expect(api.findGroupInviteCardGroupBySequence(778899)).toBe(12345);
|
|
expect(api.findGroupInviteCardGroupBySequence(1)).toBeUndefined();
|
|
});
|
|
|
|
it('requires a group when different groups have the same card sequence', () => {
|
|
const api = new ContactsApi({ identity: { uin: '10001' } } as any);
|
|
api.rememberGroupInviteCardSequence(12345, 778899);
|
|
api.rememberGroupInviteCardSequence(67890, 778899);
|
|
|
|
expect(() => api.findGroupInviteCardGroupBySequence(778899)).toThrow('multiple groups');
|
|
expect(api.findGroupInviteCardGroupBySequence(778899, 67890)).toBe(67890);
|
|
});
|
|
});
|