Include verification message reception in administrator permission results. Preserve unavailable states and surface failed reads. Update SDK contracts, action metadata, documentation and isolated behavior coverage.
1010 lines
40 KiB
TypeScript
1010 lines
40 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
|
import type { OidbBase, OidbSvcTrpcTcp0x88D_0Response } from '@snowluma/proto-defs/oidb';
|
|
import type { OidbGetGroupExtResp } from '@snowluma/proto-defs/oidb-actions/group-ext';
|
|
import type {
|
|
GetGroupAdminPermissionsResponse,
|
|
GetGroupAdminReceiveVerifyResponse,
|
|
SetGroupAdminPermissionsRequest,
|
|
SetGroupAdminReceiveVerifyRequest,
|
|
SetGroupAdminReceiveVerifyResponse,
|
|
} from '@snowluma/proto-defs/oidb-actions/group-admin-permissions';
|
|
import type { BridgeContext } from '../../src/bridge/bridge-context';
|
|
import type {
|
|
Oidb0x8a0Req,
|
|
Oidb0x8a7Resp,
|
|
Oidb0x89a_0AddOption,
|
|
Oidb0x89a_0Search,
|
|
Oidb0x89a_0InvitePolicy,
|
|
Oidb0x89a_0HistoryVisibility,
|
|
Oidb0x89a_0MemberPermission,
|
|
Oidb0xf16Req,
|
|
OidbGroupDetailRequest,
|
|
OidbGroupRequestAction,
|
|
OidbKickMember,
|
|
OidbKickMemberResponse,
|
|
OidbLeaveGroup,
|
|
OidbMuteAll,
|
|
OidbMuteMember,
|
|
OidbRenameGroup,
|
|
OidbRenameMember,
|
|
OidbSetAdmin,
|
|
OidbSpecialTitle,
|
|
} from '@snowluma/proto-defs/oidb-actions/base';
|
|
import type {
|
|
OidbChangeGroupCategoryTag,
|
|
OidbGetGroupCategoryTags,
|
|
OidbGetGroupCategoryTagsResp,
|
|
} from '@snowluma/proto-defs/oidb-actions/group-category-tag';
|
|
|
|
// Post-namespace migration: GroupAdminApi forwards through namespaces
|
|
// under @snowluma/protocol/oidb-services/group-admin. Tests assert
|
|
// against bridge.sendRawPacket directly — no module-level mocks.
|
|
import { GroupAdminApi, MEMBER_PERMISSIONS_OWNER_ONLY } from '../../src/bridge/apis/group-admin';
|
|
import { mockBridge } from './_helpers';
|
|
|
|
function packResponse(body: Uint8Array) {
|
|
return {
|
|
success: true,
|
|
gotResponse: true,
|
|
errorCode: 0,
|
|
errorMessage: '',
|
|
responseData: Buffer.from(body),
|
|
};
|
|
}
|
|
|
|
function packPrivilegeFlag(privilegeFlag?: number, ownerUid?: string) {
|
|
return packResponse(
|
|
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({
|
|
body: {
|
|
groupInfo: {
|
|
uin: 12345n,
|
|
results: {
|
|
...(privilegeFlag === undefined ? {} : { privilegeFlag }),
|
|
...(ownerUid === undefined ? {} : { ownerUid }),
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
function packOidbFail(code: number) {
|
|
return {
|
|
success: false,
|
|
gotResponse: true,
|
|
errorCode: code,
|
|
errorMessage: '',
|
|
responseData: Buffer.alloc(0),
|
|
};
|
|
}
|
|
|
|
function packGroupFlagExt4(groupFlagExt4?: number) {
|
|
return packResponse(
|
|
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({
|
|
body: {
|
|
groupInfo: {
|
|
uin: 12345n,
|
|
results: groupFlagExt4 === undefined ? {} : { groupFlagExt4 },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
function packAdminDetail(
|
|
results: NonNullable<NonNullable<OidbSvcTrpcTcp0x88D_0Response['groupInfo']>['results']>,
|
|
) {
|
|
return packResponse(
|
|
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({
|
|
body: { groupInfo: { uin: 12345n, results } },
|
|
}),
|
|
);
|
|
}
|
|
|
|
function packRobotExt(robotMemberSwitch?: number, robotMemberExamine?: number) {
|
|
return packResponse(
|
|
protobuf_encode<OidbBase<OidbGetGroupExtResp>>({
|
|
body: {
|
|
items: [
|
|
{
|
|
groupCode: 12345n,
|
|
ext: {
|
|
inviteRobotMemberSwitch: robotMemberSwitch,
|
|
inviteRobotMemberExamine: robotMemberExamine,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
describe('apis/group-admin', () => {
|
|
it.each([
|
|
[0, true],
|
|
[64, false],
|
|
[undefined, null],
|
|
] as const)(
|
|
'maps receive-verification state %s without inventing missing permissions',
|
|
async (flag, enabled) => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket.mockResolvedValueOnce(
|
|
packResponse(
|
|
protobuf_encode<OidbBase<GetGroupAdminPermissionsResponse>>({
|
|
body: {
|
|
groupCode: 12345n,
|
|
members: [
|
|
{
|
|
memberUin: 67890n,
|
|
ext: { permissions: { items: [{ item: 1, value: 2 }] } },
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
bridge.sendRawPacket.mockResolvedValueOnce(
|
|
packResponse(
|
|
protobuf_encode<OidbBase<GetGroupAdminReceiveVerifyResponse>>({
|
|
body: {
|
|
groupCode: 12345n,
|
|
members: [
|
|
{
|
|
identity: { uid: 'resolved-uid', uin: 67890n },
|
|
memberFlagExt: flag,
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
await expect(
|
|
new GroupAdminApi(bridge as unknown as BridgeContext).getAdminPermissions(
|
|
12345,
|
|
67890,
|
|
),
|
|
).resolves.toEqual({
|
|
group_id: 12345,
|
|
user_id: 67890,
|
|
recall_member_message: null,
|
|
kick_member: false,
|
|
kick_member_per_day: null,
|
|
receive_verify: enabled,
|
|
});
|
|
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2);
|
|
expect(bridge.resolveUserUid).toHaveBeenCalledExactlyOnceWith(67890, 12345);
|
|
},
|
|
);
|
|
|
|
it.each(['OidbSvcTrpcTcp.0x9164_2', 'OidbSvcTrpcTcp.0xfe7_4'])(
|
|
'rejects the combined snapshot when %s fails',
|
|
async (failedCommand) => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket.mockImplementation(async (command: string) => {
|
|
if (command === failedCommand) {
|
|
return packOidbFail(1287);
|
|
}
|
|
|
|
if (command === 'OidbSvcTrpcTcp.0x9164_2') {
|
|
return packResponse(
|
|
protobuf_encode<OidbBase<GetGroupAdminPermissionsResponse>>({
|
|
body: { groupCode: 12345n, members: [{ memberUin: 67890n }] },
|
|
}),
|
|
);
|
|
}
|
|
|
|
return packResponse(
|
|
protobuf_encode<OidbBase<GetGroupAdminReceiveVerifyResponse>>({
|
|
body: {
|
|
groupCode: 12345n,
|
|
members: [{ identity: { uid: 'resolved-uid' }, memberFlagExt: 0 }],
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
await expect(
|
|
new GroupAdminApi(bridge as unknown as BridgeContext).getAdminPermissions(
|
|
12345,
|
|
67890,
|
|
),
|
|
).rejects.toThrow('OIDB error 1287');
|
|
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2);
|
|
},
|
|
);
|
|
|
|
it('propagates identity resolution failures when reading administrator permissions', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.resolveUserUid.mockRejectedValueOnce(new Error('identity unavailable'));
|
|
bridge.sendRawPacket.mockResolvedValueOnce(
|
|
packResponse(
|
|
protobuf_encode<OidbBase<GetGroupAdminPermissionsResponse>>({
|
|
body: { groupCode: 12345n, members: [{ memberUin: 67890n }] },
|
|
}),
|
|
),
|
|
);
|
|
await expect(
|
|
new GroupAdminApi(bridge as unknown as BridgeContext).getAdminPermissions(12345, 67890),
|
|
).rejects.toThrow('identity unavailable');
|
|
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('writes administrator capabilities and receive-verification through separate services', async () => {
|
|
const bridge = mockBridge();
|
|
const api = new GroupAdminApi(bridge as unknown as BridgeContext);
|
|
await api.setAdminPermissions(12345, 67890, { recallMemberMessage: false });
|
|
const [permissionCmd, permissionBytes] = bridge.sendRawPacket.mock.calls[0]!;
|
|
expect(permissionCmd).toBe('OidbSvcTrpcTcp.0x9c1c_2');
|
|
expect(
|
|
protobuf_decode<OidbBase<SetGroupAdminPermissionsRequest>>(permissionBytes).body,
|
|
).toEqual({
|
|
groupCode: 12345n,
|
|
memberUin: 67890n,
|
|
permissions: [{ item: 2, value: 2, extra: null }],
|
|
});
|
|
|
|
bridge.sendRawPacket.mockResolvedValueOnce(
|
|
packResponse(
|
|
protobuf_encode<OidbBase<SetGroupAdminReceiveVerifyResponse>>({
|
|
body: { groupCode: 12345n, result: { successfulUins: [67890n] } },
|
|
}),
|
|
),
|
|
);
|
|
await api.setAdminReceiveVerify(12345, 67890, false);
|
|
const [verifyCmd, verifyBytes] = bridge.sendRawPacket.mock.calls[1]!;
|
|
expect(verifyCmd).toBe('OidbSvcTrpcTcp.0x904_1');
|
|
expect(
|
|
protobuf_decode<OidbBase<SetGroupAdminReceiveVerifyRequest>>(verifyBytes).body,
|
|
).toEqual({
|
|
groupCode: 12345n,
|
|
operation: { adminUins: [67890n], authority: 1, operate: 0 },
|
|
});
|
|
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2);
|
|
expect(bridge.resolveUserUid).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not swallow an unconfirmed receive-verification response', async () => {
|
|
const bridge = mockBridge();
|
|
await expect(
|
|
new GroupAdminApi(bridge as unknown as BridgeContext).setAdminReceiveVerify(
|
|
12345,
|
|
67890,
|
|
true,
|
|
),
|
|
).rejects.toThrow('missing or mismatched group');
|
|
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('muteMember resolves UID and dispatches 0x1253_1', async () => {
|
|
const bridge = mockBridge();
|
|
await new GroupAdminApi(bridge as any).muteMember(12345, 67890, 600);
|
|
expect(bridge.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
|
|
const [cmd, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
|
expect(cmd).toBe('OidbSvcTrpcTcp.0x1253_1');
|
|
const env = protobuf_decode<OidbBase<OidbMuteMember>>(bytes);
|
|
expect(env.command).toBe(0x1253);
|
|
expect(env.subCommand).toBe(1);
|
|
expect(env.body).toMatchObject({
|
|
groupUin: 12345,
|
|
type: 1,
|
|
body: { targetUid: 'resolved-uid', duration: 600 },
|
|
});
|
|
});
|
|
|
|
it('muteAll flips the muteState flag based on enable', async () => {
|
|
const bridge = mockBridge();
|
|
await new GroupAdminApi(bridge as any).muteAll(12345, true);
|
|
const env1 = protobuf_decode<OidbBase<OidbMuteAll>>(bridge.sendRawPacket.mock.calls[0]![1]);
|
|
expect(env1.body).toMatchObject({ groupUin: 12345, muteState: { state: 0xffffffff } });
|
|
|
|
await new GroupAdminApi(bridge as any).muteAll(12345, false);
|
|
const env2 = protobuf_decode<OidbBase<OidbMuteAll>>(bridge.sendRawPacket.mock.calls[1]![1]);
|
|
// proto3 default 0 fields are omitted on the wire — equivalent to "state=0".
|
|
expect(env2.body?.muteState?.state ?? 0).toBe(0);
|
|
});
|
|
|
|
it('kickMember resolves UID per-group and forwards reject + reason', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket.mockResolvedValueOnce(packResponse(Buffer.from('220308b960', 'hex')));
|
|
await new GroupAdminApi(bridge as any).kickMember(12345, 67890, true, 'bad behaviour');
|
|
expect(bridge.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
|
|
const [cmd, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
|
expect(cmd).toBe('OidbSvcTrpcTcp.0x8a0_1');
|
|
const env = protobuf_decode<OidbBase<OidbKickMember>>(bytes);
|
|
expect(env.body).toMatchObject({
|
|
groupUin: 12345,
|
|
targetUid: 'resolved-uid',
|
|
rejectAddRequest: true,
|
|
reason: 'bad behaviour',
|
|
});
|
|
});
|
|
|
|
it('kickMember treats a zero per-member result as success (#413)', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket.mockResolvedValueOnce(
|
|
packResponse(
|
|
protobuf_encode<OidbBase<OidbKickMemberResponse>>({
|
|
body: {
|
|
groupUin: 12345,
|
|
results: [{ result: 0, uid: 'u_abcdefghijklmnopqrstuv' }],
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
|
|
await expect(
|
|
new GroupAdminApi(bridge as any).kickMember(12345, 67890, false),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('kickMember rejects a server business error instead of reporting success (#298)', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket.mockResolvedValueOnce(
|
|
packResponse(
|
|
protobuf_encode<OidbBase<OidbKickMemberResponse>>({
|
|
body: {
|
|
groupUin: 12345,
|
|
results: [{ result: 1, uid: 'u_abcdefghijklmnopqrstuv' }],
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
|
|
await expect(
|
|
new GroupAdminApi(bridge as any).kickMember(12345, 67890, false),
|
|
).rejects.toThrow('kick member failed: result=1');
|
|
});
|
|
|
|
it('kickMembers resolves each UID in parallel', async () => {
|
|
const bridge = mockBridge();
|
|
vi.mocked(bridge.resolveUserUid)
|
|
.mockResolvedValueOnce('uid-a')
|
|
.mockResolvedValueOnce('uid-b');
|
|
|
|
await new GroupAdminApi(bridge as any).kickMembers(12345, [11, 22], false);
|
|
expect(bridge.resolveUserUid).toHaveBeenCalledTimes(2);
|
|
const env = protobuf_decode<OidbBase<Oidb0x8a0Req>>(bridge.sendRawPacket.mock.calls[0]![1]);
|
|
expect(env.body?.targetUids).toEqual(['uid-a', 'uid-b']);
|
|
expect(env.body?.rejectAddRequest ?? 0).toBe(0);
|
|
});
|
|
|
|
it('kickMembers rejects the same command-level business error', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket.mockResolvedValueOnce(
|
|
packResponse(
|
|
protobuf_encode<OidbBase<OidbKickMemberResponse>>({
|
|
body: {
|
|
groupUin: 12345,
|
|
results: [{ result: 1, uid: 'u_abcdefghijklmnopqrstuv' }],
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
|
|
await expect(
|
|
new GroupAdminApi(bridge as any).kickMembers(12345, [67890], false),
|
|
).rejects.toThrow('kick members failed: result=1');
|
|
});
|
|
|
|
it('leave sends 0x1097_1, emits a self group_member_leave, and forgets the group (#133)', async () => {
|
|
const bridge = mockBridge();
|
|
await new GroupAdminApi(bridge as any).leave(12345);
|
|
const [cmd, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
|
expect(cmd).toBe('OidbSvcTrpcTcp.0x1097_1');
|
|
const env = protobuf_decode<OidbBase<OidbLeaveGroup>>(bytes);
|
|
expect(env.body).toMatchObject({ groupUin: 12345 });
|
|
|
|
// The server never pushes a member-decrease for our own voluntary leave, so
|
|
// the bridge synthesizes it (self user_id/operator_id) and drops the group.
|
|
expect(bridge.events.emit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
kind: 'group_member_leave',
|
|
groupId: 12345,
|
|
userUin: 10001,
|
|
operatorUin: 10001,
|
|
leaveType: 'leave',
|
|
}),
|
|
);
|
|
expect(bridge.identity.forgetGroup).toHaveBeenCalledWith(12345);
|
|
});
|
|
|
|
it('setAdmin resolves UID and sends 0x1096_1', async () => {
|
|
const bridge = mockBridge();
|
|
await new GroupAdminApi(bridge as any).setAdmin(12345, 67890, true);
|
|
const [cmd, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
|
expect(cmd).toBe('OidbSvcTrpcTcp.0x1096_1');
|
|
const env = protobuf_decode<OidbBase<OidbSetAdmin>>(bytes);
|
|
expect(env.body).toMatchObject({ groupUin: 12345, uid: 'resolved-uid', isAdmin: true });
|
|
});
|
|
|
|
it('setCard / setName / setSpecialTitle / setRemark / setAddOption / setSearch dispatch the right command', async () => {
|
|
const bridge = mockBridge();
|
|
await new GroupAdminApi(bridge as any).setCard(1, 2, 'newCard');
|
|
await new GroupAdminApi(bridge as any).setName(1, 'newName');
|
|
await new GroupAdminApi(bridge as any).setSpecialTitle(1, 2, 'newTitle');
|
|
await new GroupAdminApi(bridge as any).setRemark(1, 'newRemark');
|
|
await new GroupAdminApi(bridge as any).setAddOption(1, 2);
|
|
await new GroupAdminApi(bridge as any).setSearch(1);
|
|
|
|
const cmds = bridge.sendRawPacket.mock.calls.map((call) => call[0]);
|
|
expect(cmds).toEqual([
|
|
'OidbSvcTrpcTcp.0x8fc_3',
|
|
'OidbSvcTrpcTcp.0x89a_15',
|
|
'OidbSvcTrpcTcp.0x8fc_2',
|
|
'OidbSvcTrpcTcp.0xf16_1',
|
|
'OidbSvcTrpcTcp.0x89a_0',
|
|
'OidbSvcTrpcTcp.0x89a_0',
|
|
]);
|
|
|
|
const cardEnv = protobuf_decode<OidbBase<OidbRenameMember>>(
|
|
bridge.sendRawPacket.mock.calls[0]![1],
|
|
);
|
|
expect(cardEnv.body).toMatchObject({
|
|
groupUin: 1,
|
|
body: { targetUid: 'resolved-uid', targetName: 'newCard' },
|
|
});
|
|
|
|
const nameEnv = protobuf_decode<OidbBase<OidbRenameGroup>>(
|
|
bridge.sendRawPacket.mock.calls[1]![1],
|
|
);
|
|
expect(nameEnv.body).toMatchObject({ groupUin: 1, body: { targetName: 'newName' } });
|
|
|
|
const titleEnv = protobuf_decode<OidbBase<OidbSpecialTitle>>(
|
|
bridge.sendRawPacket.mock.calls[2]![1],
|
|
);
|
|
// expireTime is proto int_32 and preserves the signed -1 sentinel.
|
|
expect(titleEnv.body?.groupUin).toBe(1);
|
|
expect(titleEnv.body?.body?.targetUid).toBe('resolved-uid');
|
|
expect(titleEnv.body?.body?.specialTitle).toBe('newTitle');
|
|
expect(titleEnv.body?.body?.expireTime).toBe(-1);
|
|
|
|
const remarkEnv = protobuf_decode<OidbBase<Oidb0xf16Req>>(
|
|
bridge.sendRawPacket.mock.calls[3]![1],
|
|
);
|
|
expect(remarkEnv.body?.inner).toMatchObject({ groupId: 1n, remark: 'newRemark' });
|
|
|
|
const addOptEnv = protobuf_decode<OidbBase<Oidb0x89a_0AddOption>>(
|
|
bridge.sendRawPacket.mock.calls[4]![1],
|
|
);
|
|
expect(addOptEnv.body).toMatchObject({ groupUin: 1n, settings: { addType: 2 } });
|
|
expect(addOptEnv.body?.settings?.groupQuestion ?? undefined).toBeUndefined();
|
|
|
|
const searchEnv = protobuf_decode<OidbBase<Oidb0x89a_0Search>>(
|
|
bridge.sendRawPacket.mock.calls[5]![1],
|
|
);
|
|
expect(searchEnv.body?.groupUin).toBe(1n);
|
|
});
|
|
|
|
it('setAddOption forwards question and answer for type 4', async () => {
|
|
const bridge = mockBridge();
|
|
await new GroupAdminApi(bridge as any).setAddOption(12345, 4, 'how', '42');
|
|
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0AddOption>>(
|
|
bridge.sendRawPacket.mock.calls[0]![1],
|
|
);
|
|
expect(envelope.body).toMatchObject({
|
|
groupUin: 12345n,
|
|
settings: { addType: 4, groupQuestion: 'how', groupAnswer: '42' },
|
|
});
|
|
});
|
|
|
|
it('setAddRequest picks _1 / _2 based on filtered flag', async () => {
|
|
const bridge = mockBridge();
|
|
await new GroupAdminApi(bridge as any).setAddRequest(12345, 5, 1, true, 'ok', false);
|
|
await new GroupAdminApi(bridge as any).setAddRequest(12345, 5, 1, false, 'no', true);
|
|
|
|
const cmds = bridge.sendRawPacket.mock.calls.map((call) => call[0]);
|
|
expect(cmds).toEqual(['OidbSvcTrpcTcp.0x10c8_1', 'OidbSvcTrpcTcp.0x10c8_2']);
|
|
|
|
const env1 = protobuf_decode<OidbBase<OidbGroupRequestAction>>(
|
|
bridge.sendRawPacket.mock.calls[0]![1],
|
|
);
|
|
expect(env1.reserved).toBe(1); // uinForm
|
|
expect(env1.body?.accept).toBe(1);
|
|
expect(env1.body?.body?.message).toBe('ok');
|
|
|
|
const env2 = protobuf_decode<OidbBase<OidbGroupRequestAction>>(
|
|
bridge.sendRawPacket.mock.calls[1]![1],
|
|
);
|
|
expect(env2.body?.accept).toBe(2);
|
|
});
|
|
|
|
it('setMemberInvitePolicy reads, merges, writes, and verifies the privilege flag (#334)', async () => {
|
|
const bridge = mockBridge();
|
|
const currentPrivilegeFlag = 0x86100001;
|
|
const expectedPrivilegeFlag = 0x84000001;
|
|
bridge.sendRawPacket
|
|
.mockResolvedValueOnce(packPrivilegeFlag(currentPrivilegeFlag))
|
|
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
|
|
.mockResolvedValueOnce(packPrivilegeFlag(expectedPrivilegeFlag));
|
|
|
|
await new GroupAdminApi(bridge as any).setMemberInvitePolicy(12345, 'disabled');
|
|
|
|
expect(bridge.sendRawPacket.mock.calls.map((call) => call[0])).toEqual([
|
|
'OidbSvcTrpcTcp.0x88d_0',
|
|
'OidbSvcTrpcTcp.0x89a_0',
|
|
'OidbSvcTrpcTcp.0x88d_0',
|
|
]);
|
|
const env = protobuf_decode<OidbBase<Oidb0x89a_0InvitePolicy>>(
|
|
bridge.sendRawPacket.mock.calls[1]![1],
|
|
);
|
|
expect(env.body).toMatchObject({
|
|
groupUin: 12345n,
|
|
settings: {
|
|
appPrivilegeFlag: expectedPrivilegeFlag,
|
|
appPrivilegeMask: 0x06100000,
|
|
allowMemberInvite: 0,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('setMemberInvitePolicy treats an omitted privilege flag as zero', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket
|
|
.mockResolvedValueOnce(packPrivilegeFlag())
|
|
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
|
|
.mockResolvedValueOnce(packPrivilegeFlag(0));
|
|
|
|
await new GroupAdminApi(bridge as any).setMemberInvitePolicy(12345, 'require_approval');
|
|
|
|
const env = protobuf_decode<OidbBase<Oidb0x89a_0InvitePolicy>>(
|
|
bridge.sendRawPacket.mock.calls[1]![1],
|
|
);
|
|
expect(env.body?.settings).toEqual({
|
|
appPrivilegeFlag: 0,
|
|
appPrivilegeMask: 0x06100000,
|
|
allowMemberInvite: 1,
|
|
});
|
|
});
|
|
|
|
it('setMemberInvitePolicy fails when group detail has no results block', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket.mockResolvedValueOnce(
|
|
packResponse(
|
|
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({
|
|
body: { groupInfo: { uin: 12345n } },
|
|
}),
|
|
),
|
|
);
|
|
|
|
await expect(
|
|
new GroupAdminApi(bridge as any).setMemberInvitePolicy(12345, 'require_approval'),
|
|
).rejects.toThrow(/unable to read group member invite policy before update/);
|
|
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('setMemberInvitePolicy rejects a successful ack when read-back does not match', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket
|
|
.mockResolvedValueOnce(packPrivilegeFlag(0x80100001))
|
|
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
|
|
.mockResolvedValueOnce(packPrivilegeFlag(0x80100001));
|
|
|
|
await expect(
|
|
new GroupAdminApi(bridge as any).setMemberInvitePolicy(12345, 'disabled'),
|
|
).rejects.toThrow(/was not applied/);
|
|
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('setNewMemberHistoryVisibility accepts a 0/1 history-visible read-back (#387)', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket
|
|
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
|
|
.mockResolvedValueOnce(packGroupFlagExt4(1));
|
|
|
|
await new GroupAdminApi(bridge as any).setNewMemberHistoryVisibility(12345, true);
|
|
|
|
expect(bridge.sendRawPacket.mock.calls.map((call) => call[0])).toEqual([
|
|
'OidbSvcTrpcTcp.0x89a_0',
|
|
'OidbSvcTrpcTcp.0x88d_0',
|
|
]);
|
|
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0HistoryVisibility>>(
|
|
bridge.sendRawPacket.mock.calls[0]![1],
|
|
);
|
|
expect(envelope.body).toMatchObject({
|
|
groupUin: 12345n,
|
|
settings: {
|
|
groupFlagExt4: 0x4,
|
|
groupFlagExt4Mask: 0x4,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('setNewMemberHistoryVisibility clears visibility when read-back is the off flag', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket
|
|
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
|
|
.mockResolvedValueOnce(packGroupFlagExt4(0));
|
|
|
|
await new GroupAdminApi(bridge as any).setNewMemberHistoryVisibility(12345, false);
|
|
|
|
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0HistoryVisibility>>(
|
|
bridge.sendRawPacket.mock.calls[0]![1],
|
|
);
|
|
expect(envelope.body?.settings).toEqual({
|
|
groupFlagExt4: 0,
|
|
groupFlagExt4Mask: 0x4,
|
|
});
|
|
});
|
|
|
|
it('setNewMemberHistoryVisibility treats an omitted history flag as hidden', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket
|
|
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
|
|
.mockResolvedValueOnce(packGroupFlagExt4());
|
|
|
|
await new GroupAdminApi(bridge as any).setNewMemberHistoryVisibility(12345, false);
|
|
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('setNewMemberHistoryVisibility fails when group detail has no results block', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket
|
|
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
|
|
.mockResolvedValueOnce(
|
|
packResponse(
|
|
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({
|
|
body: { groupInfo: { uin: 12345n } },
|
|
}),
|
|
),
|
|
);
|
|
|
|
await expect(
|
|
new GroupAdminApi(bridge as any).setNewMemberHistoryVisibility(12345, true),
|
|
).rejects.toThrow(/unable to read new-member history visibility after update/);
|
|
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('setNewMemberHistoryVisibility rejects an ack when read-back stays hidden', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket
|
|
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
|
|
.mockResolvedValueOnce(packGroupFlagExt4(0));
|
|
|
|
await expect(
|
|
new GroupAdminApi(bridge as any).setNewMemberHistoryVisibility(12345, true),
|
|
).rejects.toThrow(/was not applied/);
|
|
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('setMemberPermissions applies supplied switches separately and verifies the combined result (#335)', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket
|
|
.mockResolvedValueOnce(packPrivilegeFlag(0x80018001))
|
|
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
|
|
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
|
|
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
|
|
.mockResolvedValueOnce(packPrivilegeFlag(0x80010000));
|
|
|
|
await new GroupAdminApi(bridge as any).setMemberPermissions(12345, {
|
|
allowMemberUploadAlbum: true,
|
|
allowMemberTemporarySession: false,
|
|
allowMemberCreateGroup: true,
|
|
});
|
|
|
|
expect(bridge.sendRawPacket.mock.calls.map((call) => call[0])).toEqual([
|
|
'OidbSvcTrpcTcp.0x88d_0',
|
|
'OidbSvcTrpcTcp.0x89a_0',
|
|
'OidbSvcTrpcTcp.0x89a_0',
|
|
'OidbSvcTrpcTcp.0x89a_0',
|
|
'OidbSvcTrpcTcp.0x88d_0',
|
|
]);
|
|
|
|
const writes = bridge.sendRawPacket.mock.calls
|
|
.slice(1, 4)
|
|
.map(
|
|
(call) =>
|
|
protobuf_decode<OidbBase<Oidb0x89a_0MemberPermission>>(call[1]).body?.settings,
|
|
);
|
|
expect(writes).toEqual([
|
|
{ appPrivilegeFlag: 0x80018000, appPrivilegeMask: 0x1 },
|
|
{ appPrivilegeFlag: 0x80018000, appPrivilegeMask: 0x10000 },
|
|
{ appPrivilegeFlag: 0x80010000, appPrivilegeMask: 0x8000 },
|
|
]);
|
|
});
|
|
|
|
it('setMemberPermissions rejects an empty update before reading group state', async () => {
|
|
const bridge = mockBridge();
|
|
await expect(
|
|
new GroupAdminApi(bridge as any).setMemberPermissions(12345, {}),
|
|
).rejects.toThrow(/at least one member permission/);
|
|
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('setMemberPermissions treats an omitted privilege flag as all-allowed (#388)', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket
|
|
.mockResolvedValueOnce(packPrivilegeFlag())
|
|
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
|
|
.mockResolvedValueOnce(packPrivilegeFlag(0));
|
|
|
|
await new GroupAdminApi(bridge as any).setMemberPermissions(12345, {
|
|
allowMemberUploadAlbum: true,
|
|
});
|
|
|
|
expect(bridge.sendRawPacket.mock.calls[1]![0]).toBe('OidbSvcTrpcTcp.0x89a_0');
|
|
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0MemberPermission>>(
|
|
bridge.sendRawPacket.mock.calls[1]![1],
|
|
);
|
|
expect(envelope.subCommand ?? 0).toBe(0);
|
|
expect(envelope.body?.settings).toEqual({
|
|
appPrivilegeFlag: 0,
|
|
appPrivilegeMask: 1,
|
|
});
|
|
});
|
|
|
|
it('setMemberPermissions fails when group detail has no results block', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket.mockResolvedValueOnce(
|
|
packResponse(
|
|
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({
|
|
body: { groupInfo: { uin: 12345n } },
|
|
}),
|
|
),
|
|
);
|
|
|
|
await expect(
|
|
new GroupAdminApi(bridge as any).setMemberPermissions(12345, {
|
|
allowMemberUploadAlbum: true,
|
|
}),
|
|
).rejects.toThrow(/unable to read group member permissions before update/);
|
|
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('setMemberPermissions rejects a non-owner before writing (#411)', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket.mockResolvedValueOnce(packPrivilegeFlag(0x80000001, 'u_owner'));
|
|
|
|
await expect(
|
|
new GroupAdminApi(bridge as any).setMemberPermissions(12345, {
|
|
allowMemberUploadAlbum: true,
|
|
}),
|
|
).rejects.toThrow(MEMBER_PERMISSIONS_OWNER_ONLY);
|
|
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
|
|
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x88d_0');
|
|
});
|
|
|
|
it('setMemberPermissions maps a not-owner server refusal to a readable error (#411)', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket
|
|
.mockResolvedValueOnce(packPrivilegeFlag(0x80000001))
|
|
.mockResolvedValueOnce(packOidbFail(1287));
|
|
|
|
await expect(
|
|
new GroupAdminApi(bridge as any).setMemberPermissions(12345, {
|
|
allowMemberUploadAlbum: true,
|
|
}),
|
|
).rejects.toThrow(MEMBER_PERMISSIONS_OWNER_ONLY);
|
|
expect(bridge.sendRawPacket.mock.calls.map((call) => call[0])).toEqual([
|
|
'OidbSvcTrpcTcp.0x88d_0',
|
|
'OidbSvcTrpcTcp.0x89a_0',
|
|
]);
|
|
});
|
|
|
|
it('setMemberPermissions writes when the bot is the group owner', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket
|
|
.mockResolvedValueOnce(packPrivilegeFlag(0x80000001, 'self-uid'))
|
|
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
|
|
.mockResolvedValueOnce(packPrivilegeFlag(0, 'self-uid'));
|
|
|
|
await new GroupAdminApi(bridge as any).setMemberPermissions(12345, {
|
|
allowMemberUploadAlbum: true,
|
|
});
|
|
expect(bridge.sendRawPacket.mock.calls.map((call) => call[0])).toEqual([
|
|
'OidbSvcTrpcTcp.0x88d_0',
|
|
'OidbSvcTrpcTcp.0x89a_0',
|
|
'OidbSvcTrpcTcp.0x88d_0',
|
|
]);
|
|
});
|
|
|
|
it('setMemberPermissions rejects an ack when final read-back does not match', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket
|
|
.mockResolvedValueOnce(packPrivilegeFlag(0x80000001))
|
|
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
|
|
.mockResolvedValueOnce(packPrivilegeFlag(0x80000001));
|
|
|
|
await expect(
|
|
new GroupAdminApi(bridge as any).setMemberPermissions(12345, {
|
|
allowMemberUploadAlbum: true,
|
|
}),
|
|
).rejects.toThrow(/were not applied/);
|
|
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('getAtAllRemain decodes the response and converts BigInts', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket.mockResolvedValueOnce(
|
|
packResponse(
|
|
protobuf_encode<OidbBase<Oidb0x8a7Resp>>({
|
|
body: { canAtAll: true, groupRemain: 12, uinRemain: 5 } as any,
|
|
}),
|
|
),
|
|
);
|
|
const out = await new GroupAdminApi(bridge as any).getAtAllRemain(12345);
|
|
expect(out).toEqual({
|
|
can_at_all: true,
|
|
remain_at_all_count_for_group: 12,
|
|
remain_at_all_count_for_uin: 5,
|
|
});
|
|
});
|
|
|
|
it('getAdminSettings composes detail and ext-info into setter-aligned fields (#385)', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket.mockImplementation(async (cmd: string) => {
|
|
if (cmd === 'OidbSvcTrpcTcp.0x88d_0') {
|
|
return packAdminDetail({
|
|
addType: 2,
|
|
question: 'how',
|
|
answer: '42',
|
|
privilegeFlag: 0x84018001,
|
|
groupFlagExt4: 1,
|
|
noFingerOpen: 1,
|
|
});
|
|
}
|
|
|
|
if (cmd === 'OidbSvcTrpcTcp.0xef0_1') {
|
|
return packRobotExt(1, 2);
|
|
}
|
|
|
|
return packResponse(Buffer.alloc(0));
|
|
});
|
|
|
|
const out = await new GroupAdminApi(bridge as any).getAdminSettings(12345);
|
|
expect(out).toEqual({
|
|
add_type: 2,
|
|
group_question: 'how',
|
|
group_answer: '42',
|
|
robot_member_switch: 1,
|
|
robot_member_examine: 2,
|
|
member_invite_policy: 'disabled',
|
|
allow_member_upload_album: false,
|
|
allow_member_temporary_session: false,
|
|
allow_member_create_group: false,
|
|
new_member_history_visible: true,
|
|
no_finger_open: 1,
|
|
no_code_finger_open: 0,
|
|
});
|
|
expect(bridge.sendRawPacket.mock.calls.map((call) => call[0]).sort()).toEqual([
|
|
'OidbSvcTrpcTcp.0x88d_0',
|
|
'OidbSvcTrpcTcp.0xef0_1',
|
|
]);
|
|
const detailReq = protobuf_decode<OidbBase<OidbGroupDetailRequest>>(
|
|
bridge.sendRawPacket.mock.calls.find(
|
|
(call) => call[0] === 'OidbSvcTrpcTcp.0x88d_0',
|
|
)![1],
|
|
);
|
|
expect(detailReq.body?.config?.flags?.question).toBe('');
|
|
expect(detailReq.body?.config?.flags?.answer).toBe('');
|
|
});
|
|
|
|
it('getAdminSettings treats omitted detail/ext zeros as the default values', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket.mockImplementation(async (cmd: string) => {
|
|
if (cmd === 'OidbSvcTrpcTcp.0x88d_0') {
|
|
return packAdminDetail({});
|
|
}
|
|
|
|
if (cmd === 'OidbSvcTrpcTcp.0xef0_1') {
|
|
return packRobotExt();
|
|
}
|
|
|
|
return packResponse(Buffer.alloc(0));
|
|
});
|
|
|
|
await expect(new GroupAdminApi(bridge as any).getAdminSettings(12345)).resolves.toEqual({
|
|
add_type: 0,
|
|
group_question: '',
|
|
group_answer: '',
|
|
robot_member_switch: 0,
|
|
robot_member_examine: 0,
|
|
member_invite_policy: 'require_approval',
|
|
allow_member_upload_album: true,
|
|
allow_member_temporary_session: true,
|
|
allow_member_create_group: true,
|
|
new_member_history_visible: false,
|
|
no_finger_open: 0,
|
|
no_code_finger_open: 0,
|
|
});
|
|
});
|
|
|
|
it('getAdminSettings fails when group detail has no results block', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket.mockImplementation(async (cmd: string) => {
|
|
if (cmd === 'OidbSvcTrpcTcp.0x88d_0') {
|
|
return packResponse(
|
|
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({
|
|
body: { groupInfo: { uin: 12345n } },
|
|
}),
|
|
);
|
|
}
|
|
|
|
return packRobotExt(0, 0);
|
|
});
|
|
|
|
await expect(new GroupAdminApi(bridge as any).getAdminSettings(12345)).rejects.toThrow(
|
|
/unable to read group admin settings/,
|
|
);
|
|
});
|
|
|
|
it('getAtAllRemain falls back to zero / false when the response is empty', async () => {
|
|
const bridge = mockBridge();
|
|
// Empty OidbBase envelope (no body) — invokeOidb substitutes `{}`
|
|
// for the deserialize argument; canAtAll/groupRemain/uinRemain
|
|
// become undefined and get coerced to false/0.
|
|
bridge.sendRawPacket.mockResolvedValueOnce(
|
|
packResponse(protobuf_encode<OidbBase<Oidb0x8a7Resp>>({})),
|
|
);
|
|
const out = await new GroupAdminApi(bridge as any).getAtAllRemain(12345);
|
|
expect(out).toEqual({
|
|
can_at_all: false,
|
|
remain_at_all_count_for_group: 0,
|
|
remain_at_all_count_for_uin: 0,
|
|
});
|
|
});
|
|
|
|
it('lists category tags and keeps category 0 on the wire', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket.mockResolvedValueOnce(
|
|
packResponse(
|
|
protobuf_encode<OidbBase<OidbGetGroupCategoryTagsResp>>({
|
|
body: { tags: [{ id: 7, name: '学习', type: 1 }, { name: '未编号' }] },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const out = await new GroupAdminApi(bridge as any).listCategoryTags(0);
|
|
expect(out).toEqual({
|
|
tags: [
|
|
{ id: 7, name: '学习', type: 1 },
|
|
{ id: 0, name: '未编号', type: 0 },
|
|
],
|
|
});
|
|
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x967c_0');
|
|
const env = protobuf_decode<OidbBase<OidbGetGroupCategoryTags>>(
|
|
bridge.sendRawPacket.mock.calls[0]![1],
|
|
);
|
|
expect(env.command).toBe(0x967c);
|
|
expect(env.body?.groupClass).toBe(0);
|
|
});
|
|
|
|
it('returns an empty tag list when the category has none', async () => {
|
|
const bridge = mockBridge();
|
|
bridge.sendRawPacket.mockResolvedValueOnce(
|
|
packResponse(protobuf_encode<OidbBase<OidbGetGroupCategoryTagsResp>>({ body: {} })),
|
|
);
|
|
await expect(new GroupAdminApi(bridge as any).listCategoryTags(2)).resolves.toEqual({
|
|
tags: [],
|
|
});
|
|
});
|
|
|
|
it('adds and removes a category tag by its name', async () => {
|
|
const bridge = mockBridge();
|
|
await new GroupAdminApi(bridge as any).addCategoryTag(3, ' 户外 ');
|
|
await new GroupAdminApi(bridge as any).removeCategoryTag(3, '户外');
|
|
expect(bridge.sendRawPacket.mock.calls.map((call) => call[0])).toEqual([
|
|
'OidbSvcTrpcTcp.0x967d_0',
|
|
'OidbSvcTrpcTcp.0x967e_0',
|
|
]);
|
|
const added = protobuf_decode<OidbBase<OidbChangeGroupCategoryTag>>(
|
|
bridge.sendRawPacket.mock.calls[0]![1],
|
|
);
|
|
expect(added.body).toMatchObject({ groupClass: 3, name: ' 户外 ' });
|
|
const removed = protobuf_decode<OidbBase<OidbChangeGroupCategoryTag>>(
|
|
bridge.sendRawPacket.mock.calls[1]![1],
|
|
);
|
|
expect(removed.body).toMatchObject({ groupClass: 3, name: '户外' });
|
|
});
|
|
|
|
it('refuses an empty tag name instead of sending it', async () => {
|
|
const bridge = mockBridge();
|
|
await expect(new GroupAdminApi(bridge as any).addCategoryTag(1, '')).rejects.toThrow(
|
|
/must not be empty/,
|
|
);
|
|
await expect(new GroupAdminApi(bridge as any).removeCategoryTag(1, '')).rejects.toThrow(
|
|
/must not be empty/,
|
|
);
|
|
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
|
|
});
|
|
});
|