Files
SnowLuma/packages/core/tests/bridge-client-message.test.ts
T
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

235 lines
8.7 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { CLIENT_MESSAGE_RECEIPT_CMD, type PacketInfo } from '@snowluma/common/protocol-types';
import type { PacketSender, SendPacketResult } from '@snowluma/common/packet-sender';
import type { SendMessageResponse } from '@snowluma/proto-defs/action';
import type { SsoGetC2cMsgResponse } from '@snowluma/proto-defs/get-c2c-msg';
import { protobuf_encode } from '@snowluma/proton';
import { IdentityService } from '@snowluma/protocol/identity-service';
import { Bridge } from '../src/bridge/bridge';
const bridges: Bridge[] = [];
const timestamp = 1_700_000_000;
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((accept) => {
resolve = accept;
});
return { promise, resolve };
}
afterEach(async () => {
await Promise.all(bridges.splice(0).map((bridge) => bridge.dispose()));
vi.useRealTimers();
vi.restoreAllMocks();
});
function packet(): PacketInfo {
const uid = Buffer.from('u_test_peer');
const header = Buffer.alloc(8);
header[0] = 1;
header[1] = uid.length;
header.writeUInt32LE(9, 4);
return {
pid: 1234,
uin: '10001',
source: 'client-message',
serviceCmd: CLIENT_MESSAGE_RECEIPT_CMD,
seqId: 42,
retCode: 0,
fromClient: false,
body: Buffer.concat([
header,
uid,
protobuf_encode<SendMessageResponse>({ privateSequence: 87, timestamp1: timestamp }),
]),
};
}
function response(): SendPacketResult {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<SsoGetC2cMsgResponse>({
friendUid: 'u_test_peer',
messages: [
{
responseHead: {
fromUin: 10001,
fromUid: 'u_test_self',
toUin: 20002,
toUid: 'u_test_peer',
},
contentHead: {
msgType: 166,
sequence: 9,
ntMsgSeq: 87,
timestamp,
msgId: 123456,
},
body: { richText: { elems: [{ text: { str: 'hello' } }] } },
},
],
}),
),
};
}
async function setup() {
const bridge = new Bridge(IdentityService.memory('10001'));
bridges.push(bridge);
vi.spyOn(bridge.apis.contacts, 'fetchFriendList').mockResolvedValue([
{ uin: 10001, uid: 'u_test_self', nickname: 'self', remark: '' },
]);
vi.spyOn(bridge.apis.contacts, 'fetchGroupList').mockResolvedValue([]);
const sendPacket = vi.fn<PacketSender['sendPacket']>(async () => response());
const sender = { sendPacket };
bridge.bindPid(1234, sender);
await bridge.whenRosterWarmupSettled();
const received = vi.fn();
bridge.events.on('friend_message', received);
return { bridge, sendPacket, sender, received };
}
describe('Bridge client message recovery', () => {
it('does not retry a delayed lookup on a detached or replacement connection', async () => {
const ctx = await setup();
vi.useFakeTimers();
ctx.sendPacket.mockResolvedValue({
...response(),
responseData: Buffer.from(
protobuf_encode<SsoGetC2cMsgResponse>({
result: 100000301,
friendUid: 'u_test_peer',
}),
),
});
const work = ctx.bridge.onPacket(packet());
const rejected = expect(work).rejects.toThrow('inactive connection');
await vi.advanceTimersByTimeAsync(0);
expect(ctx.sendPacket).toHaveBeenCalledOnce();
ctx.bridge.detachPid(1234);
const replacement = vi.fn<PacketSender['sendPacket']>();
ctx.bridge.bindPid(1234, { sendPacket: replacement });
await vi.runAllTimersAsync();
await rejected;
expect(ctx.sendPacket).toHaveBeenCalledOnce();
expect(replacement).not.toHaveBeenCalled();
expect(ctx.received).not.toHaveBeenCalled();
});
it('dispatches confirmed content through the normal event pipeline', async () => {
const ctx = await setup();
await ctx.bridge.onPacket(packet());
expect(ctx.sendPacket).toHaveBeenCalledOnce();
expect(ctx.received).toHaveBeenCalledOnce();
expect(ctx.received).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'friend_message',
selfUin: 10001,
senderUin: 10001,
peerUin: 20002,
ntMsgSeq: 87,
clientSeq: 9,
elements: [{ type: 'text', text: 'hello' }],
}),
);
});
it('does not treat an ordinary packet with the same command as a receipt', async () => {
const ctx = await setup();
const ordinary = packet();
delete ordinary.source;
await ctx.bridge.onPacket(ordinary);
expect(ctx.sendPacket).not.toHaveBeenCalled();
expect(ctx.received).not.toHaveBeenCalled();
});
it('shares an in-flight lookup across repeated receipts and ordinary PID observations', async () => {
const ctx = await setup();
const result = deferred<SendPacketResult>();
const started = deferred<void>();
ctx.sendPacket.mockImplementation(() => {
started.resolve();
return result.promise;
});
const first = ctx.bridge.onPacket(packet());
await started.promise;
ctx.bridge.bindPid(1234, ctx.sender);
const duplicate = ctx.bridge.onPacket(packet());
await Promise.resolve();
result.resolve(response());
await Promise.all([first, duplicate]);
expect(ctx.sendPacket).toHaveBeenCalledOnce();
expect(ctx.received).toHaveBeenCalledOnce();
});
it.each(['detach', 'replace', 'reattach', 'dispose'] as const)(
'rejects a lookup completed after %s',
async (action) => {
const ctx = await setup();
const result = deferred<SendPacketResult>();
const started = deferred<void>();
ctx.sendPacket.mockImplementation(() => {
started.resolve();
return result.promise;
});
const work = ctx.bridge.onPacket(packet());
const rejected = expect(work).rejects.toThrow('inactive connection');
await started.promise;
let disposal: Promise<void> | undefined;
if (action === 'dispose') {
disposal = ctx.bridge.dispose();
} else if (action === 'replace') {
ctx.bridge.bindPid(1234, { sendPacket: vi.fn() });
} else {
ctx.bridge.detachPid(1234);
if (action === 'reattach') {
ctx.bridge.bindPid(1234, ctx.sender);
}
}
result.resolve(response());
await rejected;
await disposal;
expect(ctx.received).not.toHaveBeenCalled();
},
);
it('queries the originating connection even when another process is active', async () => {
const ctx = await setup();
const other = { sendPacket: vi.fn<PacketSender['sendPacket']>() };
ctx.bridge.bindPid(4321, other);
await ctx.bridge.onPacket(packet());
expect(ctx.sendPacket).toHaveBeenCalledOnce();
expect(other.sendPacket).not.toHaveBeenCalled();
});
it('rejects unavailable, detached and wrong-account receipts before querying', async () => {
const ctx = await setup();
await expect(ctx.bridge.onPacket({ ...packet(), retCode: -1 })).rejects.toThrow(
'unavailable',
);
await expect(ctx.bridge.onPacket({ ...packet(), uin: '20002' })).rejects.toThrow('account');
ctx.bridge.detachPid(1234);
await expect(ctx.bridge.onPacket(packet())).rejects.toThrow('inactive connection');
expect(ctx.sendPacket).not.toHaveBeenCalled();
expect(ctx.received).not.toHaveBeenCalled();
});
it('surfaces lookup failure and allows a later receipt to retry', async () => {
const ctx = await setup();
ctx.sendPacket.mockRejectedValueOnce(new Error('lookup failed'));
await expect(ctx.bridge.onPacket(packet())).rejects.toThrow('lookup failed');
expect(ctx.received).not.toHaveBeenCalled();
await ctx.bridge.onPacket(packet());
expect(ctx.sendPacket).toHaveBeenCalledTimes(2);
expect(ctx.received).toHaveBeenCalledOnce();
});
});