Files
TanTan fa9c73363d
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
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

312 lines
12 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { subscribeLogs, type LogEntry } from '@snowluma/common/logger';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { pb, pb_repeated, uint_32, uint_64 } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbDeleteFriend,
OidbFriendRequestAction,
OidbSetFriendRemarkResponse,
} from '@snowluma/proto-defs/oidb-actions/base';
import type {
OidbDoubtApprovalReq,
OidbDoubtDelReq,
OidbDoubtGetResp,
} from '@snowluma/proto-defs/oidb-actions/doubt-buddy';
// Post-namespace migration: FriendApi is a thin facade over the
// namespaces under @snowluma/protocol/oidb-services/friend. Tests assert
// against the bridge mock's sendRawPacket directly — no need for
// module-level bridge-oidb mocks anymore.
import { DATALINE_UIN_PAD } from '@snowluma/protocol/dataline/device-contacts';
import { IdentityService } from '@snowluma/protocol/identity-service';
import { FriendApi } from '../../src/bridge/apis/friend';
import { mockBridge } from './_helpers';
describe('apis/friend', () => {
it('handleRequest: numeric input is treated as UIN and resolved', async () => {
const bridge = mockBridge();
await new FriendApi(bridge as any).handleRequest('10001', true);
expect(bridge.resolveUserUid).toHaveBeenCalledWith(10001);
const [cmd, bytes] = bridge.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0xb5d_44');
const env = protobuf_decode<OidbBase<OidbFriendRequestAction>>(bytes);
expect(env.body).toMatchObject({ accept: 3, targetUid: 'resolved-uid' });
});
it('handleRequest: non-numeric flag is forwarded as-is', async () => {
const bridge = mockBridge();
await new FriendApi(bridge as any).handleRequest('flag-abc', false);
expect(bridge.resolveUserUid).not.toHaveBeenCalled();
const [, bytes] = bridge.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbFriendRequestAction>>(bytes);
expect(env.body).toMatchObject({ accept: 5, targetUid: 'flag-abc' });
});
it('delete resolves UID, calls 0x126b_0, and triggers a friend-list refresh', async () => {
const bridge = mockBridge();
await new FriendApi(bridge as any).delete(10001, true);
expect(bridge.resolveUserUid).toHaveBeenCalledWith(10001);
const [cmd, bytes] = bridge.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0x126b_0');
const env = protobuf_decode<OidbBase<OidbDeleteFriend>>(bytes);
expect(env.body?.field1?.block).toBe(true);
expect(bridge.apis.contacts.fetchFriendList).toHaveBeenCalled();
});
it('reports a friend-list refresh failure without misreporting the completed delete', async () => {
const bridge = mockBridge();
bridge.apis.contacts.fetchFriendList = vi.fn(async () => {
throw new Error('cache miss');
});
const captured: LogEntry[] = [];
const unsubscribe = subscribeLogs((entry) => {
if (entry.scope === 'Bridge.Friend') {
captured.push(entry);
}
});
try {
await expect(new FriendApi(bridge as any).delete(10001)).resolves.toBeUndefined();
} finally {
unsubscribe();
}
expect(captured.map(({ level, message }) => ({ level, message }))).toEqual([
{
level: 'warn',
message: 'friend-list refresh failed after deleting user=10001: cache miss',
},
]);
});
it('setRemark uses the current set command for a non-empty remark', async () => {
const bridge = mockBridge({
sendRawPacket: vi.fn(async () => ({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<OidbBase<OidbSetFriendRemarkResponse>>({
body: {
result: {
target: { targetUid: 'resolved-uid', targetUin: 10001n },
remark: 'best-friend',
},
},
}),
),
})),
});
await new FriendApi(bridge as any).setRemark(10001, 'best-friend');
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x912e_0');
expect(bridge.identity.updateFriendRemark).toHaveBeenCalledWith(
'resolved-uid',
10001,
'best-friend',
);
});
it('refuses to delete or rename a my-device contact', async () => {
const bridge = mockBridge();
const api = new FriendApi(bridge as any);
await expect(api.delete(DATALINE_UIN_PAD)).rejects.toThrow('cannot be deleted');
await expect(api.setRemark(DATALINE_UIN_PAD, 'pad')).rejects.toThrow(
'does not support remarks',
);
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
});
it('setRemark uses the dedicated clear command for an empty remark', async () => {
const bridge = mockBridge();
await new FriendApi(bridge as any).setRemark(10001, '');
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x912f_0');
});
it('getDoubtRequests fills a missing uid via resolveUserUid', async () => {
const bridge = mockBridge({
sendRawPacket: vi.fn(async () => ({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: encodeNumericDoubtList([
{ nick: 'Alice', uin: 12345n, reqTime: 1700000000n },
]),
})),
});
const list = await new FriendApi(bridge as any).getDoubtRequests(10);
expect(bridge.resolveUserUid).toHaveBeenCalledWith(12345);
expect(list).toEqual([
{
uid: 'resolved-uid',
user_id: 12345,
nick: 'Alice',
source: '',
reason: '',
msg: '',
group_code: '',
reqTime: 1700000000,
},
]);
});
it('getDoubtRequests keeps a wire uid and does not resolve', async () => {
const bridge = mockBridge({
identity: { findUinByUid: vi.fn(() => 12345) } as any,
sendRawPacket: vi.fn(async () => ({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: encodeDoubtList([
{ uid: 'u_alice', nick: 'Alice', reqTime: 1700000000n },
]),
})),
});
const list = await new FriendApi(bridge as any).getDoubtRequests(10);
expect(bridge.resolveUserUid).not.toHaveBeenCalled();
expect(list[0]?.uid).toBe('u_alice');
expect(list[0]?.user_id).toBe(12345);
});
it('getDoubtRequests keeps an empty uid when resolveUserUid fails', async () => {
const bridge = mockBridge({
resolveUserUid: vi.fn(async () => {
throw new Error('no mapping');
}),
sendRawPacket: vi.fn(async () => ({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: encodeNumericDoubtList([{ nick: 'Alice', uin: 12345n }]),
})),
});
const captured: LogEntry[] = [];
const unsubscribe = subscribeLogs((entry) => {
if (entry.scope === 'Bridge.Friend') {
captured.push(entry);
}
});
try {
const list = await new FriendApi(bridge as any).getDoubtRequests(10);
expect(list[0]?.uid).toBe('');
expect(list[0]?.user_id).toBe(12345);
} finally {
unsubscribe();
}
expect(captured.map(({ level, message }) => ({ level, message }))).toEqual([
{
level: 'warn',
message: 'doubt-request uid resolve failed: user=12345 err=no mapping',
},
]);
});
it('approveDoubtRequest resolves a digit-only flag before sending', async () => {
const bridge = mockBridge();
await new FriendApi(bridge as any).approveDoubtRequest('12345');
expect(bridge.resolveUserUid).toHaveBeenCalledWith(12345);
const [cmd, bytes] = bridge.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0xd72_0');
const env = protobuf_decode<OidbBase<OidbDoubtApprovalReq>>(bytes);
expect(env.body).toMatchObject({
selfUid: 'self-uid',
targetUid: 'resolved-uid',
field3: 0,
field4: '',
});
});
it('approveDoubtRequest forwards a uid flag without resolving', async () => {
const bridge = mockBridge();
await new FriendApi(bridge as any).approveDoubtRequest('u_abc');
expect(bridge.resolveUserUid).not.toHaveBeenCalled();
const [, bytes] = bridge.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbDoubtApprovalReq>>(bytes);
expect(env.body).toMatchObject({ selfUid: 'self-uid', targetUid: 'u_abc' });
});
it('approves a filtered stranger through Identity without requiring a shared roster', async () => {
const identity = new IdentityService('10001', null);
await identity.initialization;
const bridge = mockBridge();
const fetchProfile = vi.fn(async (uin: number) => ({
uin,
uid: uin === 10001 ? 'u_self' : 'u_stranger',
}));
identity.setFetcher({ fetchProfile: fetchProfile as any });
bridge.identity = identity as any;
bridge.resolveUserUid = vi.fn((uin: number) => identity.resolveUid(uin));
try {
await new FriendApi(bridge as any).approveDoubtRequest('12345');
expect(fetchProfile.mock.calls.map(([uin]) => uin)).toEqual([12345, 10001]);
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
const [, bytes] = bridge.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbDoubtApprovalReq>>(bytes);
expect(env.body).toMatchObject({ selfUid: 'u_self', targetUid: 'u_stranger' });
} finally {
await identity.close();
}
});
it('does not send an approval when the applicant cannot be resolved', async () => {
const bridge = mockBridge({
resolveUserUid: vi.fn(async () => {
throw new Error('applicant lookup failed');
}),
});
await expect(new FriendApi(bridge as any).approveDoubtRequest('12345')).rejects.toThrow(
'applicant lookup failed',
);
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
});
it('rejectDoubtRequest resolves a digit-only flag before sending', async () => {
const bridge = mockBridge();
await new FriendApi(bridge as any).rejectDoubtRequest('12345');
expect(bridge.resolveUserUid).toHaveBeenCalledWith(12345);
const [, bytes] = bridge.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbDoubtDelReq>>(bytes);
expect(env.body).toMatchObject({ field1: 3, inner: { uid: 'resolved-uid' } });
});
});
function encodeDoubtList(list: NonNullable<NonNullable<OidbDoubtGetResp['body']>['list']>): Buffer {
return Buffer.from(
protobuf_encode<OidbBase<OidbDoubtGetResp>>({
command: 0xd69,
subCommand: 0,
body: { status: 1, body: { list } },
}),
);
}
interface NumericDoubtItem {
uin?: pb<1, uint_64>;
nick?: pb<2, string>;
reqTime?: pb<8, uint_64>;
}
interface NumericDoubtBody {
list?: pb_repeated<1, NumericDoubtItem>;
}
interface NumericDoubtResponse {
status?: pb<1, uint_32>;
body?: pb<2, NumericDoubtBody>;
}
function encodeNumericDoubtList(list: NumericDoubtItem[]): Buffer {
return Buffer.from(
protobuf_encode<OidbBase<NumericDoubtResponse>>({
command: 0xd69,
subCommand: 0,
body: { status: 1, body: { list } },
}),
);
}