Files
SnowLuma/packages/protocol/tests/self-invite-dispatch.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

189 lines
6.6 KiB
TypeScript

import type { PacketInfo } from '@snowluma/common/protocol-types';
import { BridgeEventBus } from '@snowluma/protocol/event-bus';
import type { GroupInviteEvent, QQEventVariant } from '@snowluma/protocol/events';
import { IdentityService } from '@snowluma/protocol/identity-service';
import { GroupInviteTracker } from '@snowluma/protocol/group-invite-tracker';
import { IncomingPacketPipeline } from '@snowluma/protocol/packet-pipeline';
import { afterEach, describe, expect, it, vi } from 'vitest';
const SELF_UIN = 10001;
const GROUP_ID = 12345;
const INVITOR_UID = 'u_inviter';
const INVITOR_UIN = 20002;
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
function packet(): PacketInfo {
return {
pid: 42,
uin: String(SELF_UIN),
serviceCmd: 'test.self-invite',
seqId: 1,
retCode: 0,
fromClient: false,
body: Buffer.from([0x00]),
};
}
/** The MSF notice: no approval msgseq, only the legacy flag from the decoder. */
function selfInviteNotice(): GroupInviteEvent {
return {
kind: 'group_invite',
time: 1,
selfUin: SELF_UIN,
groupId: GROUP_ID,
fromUin: INVITOR_UIN,
fromUid: INVITOR_UID,
invitedUin: SELF_UIN,
invitedUid: undefined,
subType: 'invite',
message: '',
flag: `invite:${GROUP_ID}:${INVITOR_UID}`,
};
}
/** The private "qun.invite" card, which carries the real approval msgseq. */
function inviteCard(sequence = 777): QQEventVariant {
return {
kind: 'friend_message',
time: 1,
selfUin: SELF_UIN,
senderUin: INVITOR_UIN,
senderUid: INVITOR_UID,
senderNick: 'inviter',
msgSeq: 1,
msgId: 2,
elements: [],
inviteCardGroupUin: GROUP_ID,
inviteCardSequence: sequence,
} as QQEventVariant;
}
function makePipeline(options: { request?: 'found' | 'missing' } = {}) {
const identity = IdentityService.memory(String(SELF_UIN));
const events = new BridgeEventBus();
const received: QQEventVariant[] = [];
events.onAny((event) => {
// The card arrives as a friend_message and the pipeline derives a
// group_invite from it; only the latter is under test here.
if ((event as QQEventVariant).kind === 'group_invite') {
received.push(event as QQEventVariant);
}
});
const resolveGroupJoinRequest = vi.fn(async () =>
options.request === 'found'
? {
groupId: GROUP_ID,
groupName: '',
targetUid: 'u_target',
targetUin: 0,
targetName: '',
invitorUid: INVITOR_UID,
invitorUin: INVITOR_UIN,
invitorName: '',
operatorUid: '',
operatorUin: 0,
operatorName: '',
sequence: 42,
state: 1,
notifyType: 1,
eventType: 1,
comment: '',
filtered: false,
}
: null,
);
const pipeline = new IncomingPacketPipeline({
identity,
events,
fetchGroupList: vi.fn(async () => {}),
fetchGroupMemberList: vi.fn(async () => {}),
resolveGroupJoinRequest,
groupInvites: new GroupInviteTracker(),
});
return { pipeline, received, resolveGroupJoinRequest };
}
describe('self-invite delivery', () => {
it('dispatches a self-invite that the request inbox cannot resolve', async () => {
const { pipeline, received } = makePipeline({ request: 'missing' });
pipeline.registerCmd('test.self-invite', () => [selfInviteNotice()]);
await pipeline.process(packet());
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({
kind: 'group_invite',
groupId: GROUP_ID,
invitedUin: SELF_UIN,
subType: 'invite',
});
});
it('keeps the decoder-provided legacy flag so the request stays actionable', async () => {
const { pipeline, received } = makePipeline({ request: 'missing' });
pipeline.registerCmd('test.self-invite', () => [selfInviteNotice()]);
await pipeline.process(packet());
expect((received[0] as GroupInviteEvent).flag).toBe(
`invite:${GROUP_ID}:${INVITOR_UID}`,
);
});
it('keeps the legacy flag even when the inbox has a matching request', async () => {
// Self-invite notices are absent from the UIN-based inbox, so the pipeline
// does not consult it at all -- the legacy flag must therefore survive.
const { pipeline, received, resolveGroupJoinRequest } = makePipeline({ request: 'found' });
pipeline.registerCmd('test.self-invite', () => [selfInviteNotice()]);
await pipeline.process(packet());
expect(resolveGroupJoinRequest).not.toHaveBeenCalled();
expect((received[0] as GroupInviteEvent).flag).toBe(
`invite:${GROUP_ID}:${INVITOR_UID}`,
);
});
it('does not query the inbox and still dispatches a self-invite', async () => {
const { pipeline, received, resolveGroupJoinRequest } = makePipeline({ request: 'missing' });
pipeline.registerCmd('test.self-invite', () => [selfInviteNotice()]);
await pipeline.process(packet());
expect(received).toHaveLength(1);
// A self-invite never shows up in the UIN-based inbox, so the lookup is skipped.
expect(resolveGroupJoinRequest).not.toHaveBeenCalled();
});
it('lets the card win when it arrives first', async () => {
const { pipeline, received } = makePipeline({ request: 'missing' });
pipeline.registerCmd('test.self-invite', () => [selfInviteNotice()]);
pipeline.registerCmd('test.card', () => [inviteCard()]);
await pipeline.process({ ...packet(), serviceCmd: 'test.card' });
await pipeline.process(packet());
expect(received).toHaveLength(1);
expect((received[0] as GroupInviteEvent).flag).toBe(`slreq:1:777:${GROUP_ID}:2:0`);
});
it('suppresses the notice when the card already delivered the invitation', async () => {
const { pipeline, received } = makePipeline({ request: 'missing' });
pipeline.registerCmd('test.self-invite', () => [selfInviteNotice()]);
pipeline.registerCmd('test.card', () => [inviteCard()]);
await pipeline.process(packet());
await pipeline.process({ ...packet(), serviceCmd: 'test.card' });
expect(received).toHaveLength(1);
expect((received[0] as GroupInviteEvent).flag).toBe(`invite:${GROUP_ID}:${INVITOR_UID}`);
});
});