Files
SnowLuma/packages/core/tests/group-history-walkback.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

362 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// MessageApi history pagination: bidirectional chunking, dedup, short-cap
// handling, floor termination, and chronological output. Range fetches are
// mocked so this exercises cursor logic rather than wire decoding.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@snowluma/protocol/msg-push', async (importOriginal) => {
const actual = await importOriginal<typeof import('@snowluma/protocol/msg-push')>();
return {
...actual,
fetchGroupMessageRange: vi.fn(),
fetchC2cMessageRange: vi.fn(),
fetchC2cRoamMessagePage: vi.fn(),
};
});
import {
fetchC2cMessageRange,
fetchC2cRoamMessagePage,
fetchGroupMessageRange,
} from '@snowluma/protocol/msg-push';
import { MessageApi } from '../src/bridge/apis/message';
const mockFetch = vi.mocked(fetchGroupMessageRange);
const mockC2cFetch = vi.mocked(fetchC2cMessageRange);
const mockC2cRoamFetch = vi.mocked(fetchC2cRoamMessagePage);
function gm(seq: number): any {
return {
kind: 'group_message',
groupName: '',
time: 0,
selfUin: 0,
senderUin: 1,
msgSeq: seq,
msgId: seq,
elements: [],
groupId: 9999,
senderNick: '',
senderCard: '',
senderRole: '',
};
}
const ctx = { identity: {}, sendRawPacket: vi.fn() } as any;
describe('MessageApi history pagination', () => {
// The walk-back loop enforces a real ~300ms gap between each SsoGetGroupMsg
// send (a deliberate anti-flood throttle, unchanged in production). Fake the
// clock so the test exercises the loop/cursor logic instantly instead of
// waiting out 300ms × up-to-12 real sends.
beforeEach(() => {
mockFetch.mockReset();
mockC2cFetch.mockReset();
mockC2cRoamFetch.mockReset();
vi.useFakeTimers();
});
afterEach(() => vi.useRealTimers());
// Drives a throttled getGroupHistory to completion under fake timers: kick it
// off, flush the queued send-gap timers, then await the result.
async function runHistory<T>(promise: Promise<T>): Promise<T> {
await vi.runAllTimersAsync();
return promise;
}
it('walks back across short-capped windows, dedups, returns newest `want` ascending', async () => {
// Server caps every window to its newest 10 sequences → forces walk-back.
mockFetch.mockImplementation(async (_s, _id, _self, _g, start, end) => {
const out = [];
for (let s = Math.max(start, end - 9); s <= end; s++) {
out.push(gm(s));
}
return out;
});
const res = await runHistory(new MessageApi(ctx).getGroupHistory(9999, 200, 50));
expect(res).toHaveLength(50);
expect(res[0].msgSeq).toBe(151); // newest 50 ending at 200 → 151..200
expect(res[res.length - 1].msgSeq).toBe(200);
for (let i = 1; i < res.length; i++) {
expect(res[i].msgSeq).toBeGreaterThan(res[i - 1].msgSeq); // strictly ascending, no dups
}
});
it('walks forward from and including the anchor when reverse order is disabled', async () => {
mockFetch.mockImplementation(async (_s, _id, _self, _g, start, end) => {
const out = [];
for (let s = start; s <= end; s++) {
out.push(gm(s));
}
return out;
});
const res = await runHistory(new MessageApi(ctx).getGroupHistory(9999, 100, 5, 0, false));
expect(res.map((message) => message.msgSeq)).toEqual([100, 101, 102, 103, 104]);
});
it('does not skip the anchor-side prefix when a forward window is short-capped', async () => {
mockFetch.mockImplementation(async (_s, _id, _self, _g, start, end) => {
const out = [];
for (let s = Math.max(start, end - 9); s <= end; s++) {
out.push(gm(s));
}
return out;
});
const res = await runHistory(new MessageApi(ctx).getGroupHistory(9999, 100, 20, 0, false));
expect(res.map((message) => message.msgSeq)).toEqual(
Array.from({ length: 20 }, (_, index) => 100 + index),
);
});
it('uses the same forward semantics for private history', async () => {
mockC2cFetch.mockImplementation(async (_s, _id, _self, _uid, start, end) => {
const out = [];
for (let s = start; s <= end; s++) {
out.push({ ...gm(s + 10_000), kind: 'friend_message', ntMsgSeq: s });
}
return out;
});
const res = await runHistory(
new MessageApi(ctx).getC2cHistory('u_friend', 300, 3, 0, false),
);
expect(res.map((message) => message.ntMsgSeq)).toEqual([300, 301, 302]);
});
it('fetches the latest private page by timestamp without inventing a sequence anchor', async () => {
mockC2cRoamFetch.mockResolvedValue({
cursor: { time: 0, random: 0 },
messages: [
{ ...gm(32742), kind: 'friend_message', ntMsgSeq: 63213, time: 1700000002 },
{ ...gm(32743), kind: 'friend_message', ntMsgSeq: 63214, time: 1700000001 },
],
} as any);
const result = await runHistory(
new MessageApi(ctx).getC2cLatestHistory('u_friend', 20, 10001, 1700000100),
);
expect(mockC2cRoamFetch).toHaveBeenCalledWith(
ctx,
ctx.identity,
10001,
'u_friend',
1700000100,
20,
0,
);
expect(result.map((message) => message.ntMsgSeq)).toEqual([63213, 63214]);
expect(mockC2cFetch).not.toHaveBeenCalled();
});
it('follows the private roam cursor until the requested count is filled', async () => {
mockC2cRoamFetch
.mockResolvedValueOnce({
cursor: { time: 1700000000, random: 7 },
messages: [
{ ...gm(32744), kind: 'friend_message', ntMsgSeq: 63215, time: 1700000002 },
],
} as any)
.mockResolvedValueOnce({
cursor: { time: 1699999900, random: 8 },
messages: [
{ ...gm(32742), kind: 'friend_message', ntMsgSeq: 63213, time: 1700000000 },
{ ...gm(32743), kind: 'friend_message', ntMsgSeq: 63214, time: 1700000001 },
],
} as any);
const result = await runHistory(
new MessageApi(ctx).getC2cLatestHistory('u_friend', 3, 10001, 1700000100),
);
expect(mockC2cRoamFetch).toHaveBeenNthCalledWith(
1,
ctx,
ctx.identity,
10001,
'u_friend',
1700000100,
3,
0,
);
expect(mockC2cRoamFetch).toHaveBeenNthCalledWith(
2,
ctx,
ctx.identity,
10001,
'u_friend',
1700000000,
2,
7,
);
expect(result.map((message) => message.ntMsgSeq)).toEqual([63213, 63214, 63215]);
});
it('splits a 200-message private history request into bounded pages', async () => {
mockC2cRoamFetch.mockImplementation(
async (_sender, _identity, _self, _friend, time, count) => ({
messages: Array.from({ length: count }, (_, index) => ({
...gm(time - index),
kind: 'friend_message',
ntMsgSeq: time - index,
})),
cursor: { time: time - count, random: 1 },
isComplete: false,
}),
);
const result = await runHistory(
new MessageApi(ctx).getC2cLatestHistory('u_friend', 200, 10001, 1000),
);
expect(result).toHaveLength(200);
expect(mockC2cRoamFetch.mock.calls.map((call) => call[5])).toEqual([100, 100]);
expect(result.map((message) => message.ntMsgSeq)).toEqual(
Array.from({ length: 200 }, (_, index) => 801 + index),
);
});
it('honors completion even when the last page still includes a cursor', async () => {
mockC2cRoamFetch.mockResolvedValue({
messages: [{ ...gm(1), kind: 'friend_message', ntMsgSeq: 1 }],
cursor: { time: 100, random: 2 },
isComplete: true,
});
const result = await runHistory(
new MessageApi(ctx).getC2cLatestHistory('u_friend', 20, 10001, 1000),
);
expect(result).toHaveLength(1);
expect(mockC2cRoamFetch).toHaveBeenCalledOnce();
});
it('does not return partial history after a later request fails', async () => {
mockC2cRoamFetch
.mockResolvedValueOnce({
messages: [{ ...gm(1), kind: 'friend_message', ntMsgSeq: 1 }],
cursor: { time: 100, random: 2 },
isComplete: false,
})
.mockRejectedValueOnce(new Error('SsoGetRoamMsg failed: result=123'));
const assertion = expect(
new MessageApi(ctx).getC2cLatestHistory('u_friend', 20, 10001, 1000),
).rejects.toThrow('result=123');
await vi.runAllTimersAsync();
await assertion;
});
it('rejects a stalled private roam cursor instead of returning a partial page', async () => {
mockC2cRoamFetch.mockResolvedValue({
cursor: { time: 1700000100, random: 0 },
messages: [{ ...gm(32742), kind: 'friend_message', ntMsgSeq: 63213, time: 1700000000 }],
} as any);
const assertion = expect(
new MessageApi(ctx).getC2cLatestHistory('u_friend', 2, 10001, 1700000100),
).rejects.toThrow('cursor did not advance');
await vi.runAllTimersAsync();
await assertion;
});
it('rejects a partial private page when the request budget is exhausted', async () => {
let page = 0;
mockC2cRoamFetch.mockImplementation(async () => {
page++;
return {
cursor: { time: 1700000100 - page, random: page },
messages: [
{
...gm(32000 + page),
kind: 'friend_message',
ntMsgSeq: 63000 + page,
time: 1700000100 - page,
},
],
} as any;
});
const assertion = expect(
new MessageApi(ctx).getC2cLatestHistory('u_friend', 20, 10001, 1700000200),
).rejects.toThrow('request budget exhausted');
await vi.runAllTimersAsync();
await assertion;
expect(mockC2cRoamFetch).toHaveBeenCalledTimes(12);
});
it('stops forward pagination at the unsigned sequence ceiling', async () => {
mockFetch.mockImplementation(async (_s, _id, _self, _g, start, end) => {
const out = [];
for (let s = start; s <= end; s++) {
out.push(gm(s));
}
return out;
});
const res = await runHistory(
new MessageApi(ctx).getGroupHistory(9999, 0xffff_fffe, 5, 0, false),
);
expect(res.map((message) => message.msgSeq)).toEqual([0xffff_fffe, 0xffff_ffff]);
});
it('stops at the sequence floor when fewer messages exist than requested', async () => {
mockFetch.mockImplementation(async (_s, _id, _self, _g, start, end) => {
const out = [];
for (let s = start; s <= end; s++) {
out.push(gm(s));
}
return out;
});
const res = await runHistory(new MessageApi(ctx).getGroupHistory(9999, 20, 100)); // only seq 1..20 exist
expect(res.map((m) => m.msgSeq)).toEqual(Array.from({ length: 20 }, (_, i) => i + 1));
});
it('caps the request count and bounds the request loop', async () => {
let calls = 0;
mockFetch.mockImplementation(async (_s, _id, _self, _g, _start, end) => {
calls++;
return [gm(end)]; // one message per window → never reaches a large `want`
});
const res = await runHistory(new MessageApi(ctx).getGroupHistory(9999, 100_000, 9999));
expect(calls).toBeLessThanOrEqual(12); // HISTORY_MAX_REQUESTS
expect(res.length).toBeLessThanOrEqual(200); // HISTORY_MAX_COUNT
});
it('rejects a failed later window instead of presenting partial history as complete', async () => {
mockFetch
.mockResolvedValueOnce([gm(200)])
.mockRejectedValueOnce(new Error('history transport failed'));
const assertion = expect(
new MessageApi(ctx).getGroupHistory(9999, 200, 20),
).rejects.toThrow('history transport failed');
await vi.runAllTimersAsync();
await assertion;
});
it('returns [] for an invalid anchor without sending', async () => {
expect(await new MessageApi(ctx).getGroupHistory(0, 100, 10)).toEqual([]);
expect(await new MessageApi(ctx).getGroupHistory(9999, 0, 10)).toEqual([]);
expect(mockFetch).not.toHaveBeenCalled();
});
});