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,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));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user