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

714 lines
24 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import type { bool, int_32, pb, pb_repeated, uint_32, uint_64 } from '@snowluma/proton';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import { getCollectionList } from '../../src/web/collection';
interface RequestHeadOracle {
uin?: pb<1, uint_64>;
sequence?: pb<2, uint_32>;
commandType?: pb<3, uint_32>;
operationId?: pb<4, uint_32>;
clientVersion?: pb<5, uint_64>;
platform?: pb<6, uint_32>;
ticketType?: pb<7, uint_32>;
reserved?: pb<10, uint_32>;
ticket?: pb<11, string>;
field14?: pb<14, uint_32>;
field15?: pb<15, uint_32>;
}
interface GetCollectionListRequestOracle {
field1?: pb<1, uint_32>;
field2?: pb<2, uint_32>;
field3?: pb<3, uint_32>;
timeStamp?: pb<4, uint_64>;
orderType?: pb<5, uint_32>;
groupId?: pb<6, uint_64>;
count?: pb<7, uint_32>;
searchDown?: pb<8, uint_32>;
field9?: pb<9, uint_32>;
}
interface RequestOperationOracle {
getCollectionList?: pb<20000, GetCollectionListRequestOracle>;
}
interface RequestBodyOracle {
operation?: pb<1, RequestOperationOracle>;
}
interface ResponseHeadOracle {
trace?: pb<1, string>;
retCode?: pb<101, int_32>;
retMsg?: pb<102, string>;
promptMsg?: pb<103, string>;
}
interface CollectionAuthorOracle {
type?: pb<1, uint_32>;
numId?: pb<2, uint_64>;
strId?: pb<3, string>;
groupId?: pb<4, uint_64>;
groupName?: pb<5, string>;
uid?: pb<6, string>;
}
interface CollectionTextSummaryOracle {
text?: pb<1, string>;
truncated?: pb<2, bool>;
}
interface CollectionPictureInfoOracle {
url?: pb<1, string>;
width?: pb<6, uint_32>;
height?: pb<7, uint_32>;
}
interface CollectionLinkSummaryOracle {
url?: pb<1, string>;
title?: pb<2, string>;
publisher?: pb<3, string>;
brief?: pb<4, string>;
picList?: pb_repeated<5, CollectionPictureInfoOracle>;
contentType?: pb<6, uint_32>;
}
interface CollectionRichMediaSummaryOracle {
title?: pb<1, string>;
subTitle?: pb<2, string>;
brief?: pb<3, string>;
picList?: pb_repeated<4, CollectionPictureInfoOracle>;
contentType?: pb<5, uint_32>;
originalUri?: pb<6, string>;
publisher?: pb<7, string>;
richMediaVersion?: pb<8, uint_32>;
}
interface CollectionSummaryOracle {
textSummary?: pb<1, CollectionTextSummaryOracle>;
linkSummary?: pb<2, CollectionLinkSummaryOracle>;
richMediaSummary?: pb<8, CollectionRichMediaSummaryOracle>;
}
interface CollectionItemOracle {
cid?: pb<1, string>;
type?: pb<2, uint_32>;
status?: pb<3, uint_32>;
author?: pb<4, CollectionAuthorOracle>;
bid?: pb<5, uint_32>;
category?: pb<8, uint_32>;
createTime?: pb<9, uint_64>;
collectTime?: pb<10, uint_64>;
modifyTime?: pb<11, uint_64>;
sequence?: pb<12, uint_64>;
summary?: pb<15, CollectionSummaryOracle>;
shareUrl?: pb<18, string>;
customGroupId?: pb<20, uint_32>;
securityBeat?: pb<21, bool>;
}
interface GetCollectionListResponseOracle {
items?: pb_repeated<1, CollectionItemOracle>;
totalCount?: pb<2, uint_32>;
reachedBottom?: pb<3, uint_32>;
}
interface ResponseOperationOracle {
getCollectionList?: pb<20000, GetCollectionListResponseOracle>;
}
interface ResponseBodyOracle {
operation?: pb<2, ResponseOperationOracle>;
}
function encodeEnvelope(head: Uint8Array, body: Uint8Array): Uint8Array {
const totalLength = 16 + head.length + body.length;
const output = Buffer.alloc(totalLength);
output.set([0x20, 0x13, 0x03, 0x29, 0x00, 0x01], 0);
output.writeUInt32BE(totalLength, 6);
output.writeUInt32BE(body.length, 10);
output.set(head, 16);
output.set(body, 16 + head.length);
return output;
}
function decodeRequestEnvelope(bytes: Uint8Array): {
head: RequestHeadOracle;
request: GetCollectionListRequestOracle;
} {
const input = Buffer.from(bytes);
expect(Array.from(input.subarray(0, 6))).toEqual([0x20, 0x13, 0x03, 0x29, 0x00, 0x01]);
expect(input.readUInt32BE(6)).toBe(input.length);
const bodyLength = input.readUInt32BE(10);
const headEnd = input.length - bodyLength;
const head = protobuf_decode<RequestHeadOracle>(input.subarray(16, headEnd));
const body = protobuf_decode<RequestBodyOracle>(input.subarray(headEnd));
const request = body.operation?.getCollectionList;
if (!request) {
throw new Error('missing collection request body');
}
return { head, request };
}
function successResponse(items: CollectionItemOracle[], reachedBottom = 1): Uint8Array {
return encodeEnvelope(
protobuf_encode<ResponseHeadOracle>({ trace: 'response' }),
protobuf_encode<ResponseBodyOracle>({
operation: {
getCollectionList: {
items,
totalCount: items.length,
reachedBottom,
},
},
}),
);
}
function errorResponse(retCode: number, retMsg: string): Uint8Array {
return encodeEnvelope(
protobuf_encode<ResponseHeadOracle>({ trace: 'response', retCode, retMsg }),
protobuf_encode<ResponseBodyOracle>({ operation: { getCollectionList: {} } }),
);
}
describe('getCollectionList', () => {
afterEach(() => vi.unstubAllGlobals());
it('uses the Weiyun collection envelope and maps the public result', async () => {
const transport = vi.fn(
async (request: {
url: string;
headers: Readonly<Record<string, string>>;
body: Uint8Array;
timeoutMs: number;
}) => {
expect(request.url).toBe('https://collector.weiyun.com/collector.fcg');
expect(request.headers.Cookie).toBe('uin=10001;vt=27;vi=ticket-value;appid=5004');
expect(request.headers.Range).toBe('bytes=0-');
expect(request.timeoutMs).toBe(10_000);
const { head, request: body } = decodeRequestEnvelope(request.body);
expect(head).toMatchObject({
uin: 10001n,
sequence: 1,
commandType: 1,
operationId: 20000,
clientVersion: 0x6105f5e164fn,
platform: 4,
ticketType: 27,
ticket: 'ticket-value',
field14: 8,
field15: 9,
});
expect(body).toMatchObject({
field1: null,
field2: null,
field3: null,
timeStamp: 0xffff_ffff_ffff_ffffn,
orderType: 2,
groupId: null,
count: 50,
searchDown: 1,
field9: null,
});
return successResponse([
{
cid: 'cid-1',
type: 1,
status: 0,
author: {
type: 2,
numId: 123n,
strId: 'author-id',
groupId: 456n,
groupName: 'group-name',
uid: 'u_author',
},
bid: 3,
category: 4,
createTime: 1_000n,
collectTime: 1_100n,
modifyTime: 1_200n,
sequence: 1_300n,
summary: { textSummary: { text: 'hello', truncated: false } },
shareUrl: 'https://example.invalid/share',
customGroupId: 5,
securityBeat: true,
},
]);
},
);
const result = await getCollectionList({
uin: '10001',
pskey: 'ticket-value',
category: 0,
count: 50,
transport,
});
expect(result).toEqual({
errCode: 0,
errMsg: '',
collectionSearchList: {
collectionItemList: [
{
cid: 'cid-1',
type: 1,
status: 0,
author: {
type: 2,
numId: '123',
strId: 'author-id',
groupId: '456',
groupName: 'group-name',
uid: 'u_author',
},
bid: 3,
category: 4,
createTime: '1000',
collectTime: '1100',
modifyTime: '1200',
sequence: '1300',
summary: {
textSummary: { text: 'hello', truncated: false },
linkSummary: null,
gallerySummary: null,
audioSummary: null,
videoSummary: null,
fileSummary: null,
locationSummary: null,
richMediaSummary: null,
},
shareUrl: 'https://example.invalid/share',
customGroupId: 5,
securityBeat: true,
},
],
hasMore: false,
bottomTimeStamp: '1200',
},
});
expect(transport).toHaveBeenCalledTimes(1);
});
it('maps a type 2 link summary whose brief is a string without throwing', async () => {
// Regression: the old proto declared CollectionLinkSummary.picList at field 4,
// but the server puts the `brief` string there. Decoding a string as a message
// reads its first byte (0x43 = 'C') as a start-group tag and throws
// "protobuf unterminated group". The link summary now maps field 4 as brief.
const linkSummary: CollectionLinkSummaryOracle = {
url: 'https://example.invalid/article',
title: 'Example Article',
publisher: 'Example Publisher',
brief: 'Candy summary text that starts with a group-start byte',
picList: [{ url: 'https://example.invalid/cover.jpg', width: 1280, height: 720 }],
contentType: 1,
};
const result = await getCollectionList({
uin: '10001',
pskey: 'ticket-value',
transport: async () =>
successResponse(
[
{
cid: 'cid-link',
type: 2,
author: { numId: 1n },
modifyTime: 1_200n,
summary: { linkSummary },
},
],
1,
),
});
const item = result.collectionSearchList.collectionItemList[0];
expect(item.type).toBe(2);
expect(item.summary.linkSummary).toMatchObject({
url: 'https://example.invalid/article',
title: 'Example Article',
publisher: 'Example Publisher',
brief: 'Candy summary text that starts with a group-start byte',
picList: [{ url: 'https://example.invalid/cover.jpg', field6: 1280, field7: 720 }],
contentType: 1,
});
expect(item.summary.richMediaSummary).toBeNull();
});
it('maps a type 8 rich media summary at field 8', async () => {
const richMediaSummary: CollectionRichMediaSummaryOracle = {
title: 'Rich Media Title',
subTitle: 'Rich Media Subtitle',
brief: 'Rich media brief',
picList: [{ url: 'https://example.invalid/rich.jpg', width: 800, height: 600 }],
contentType: 2,
originalUri: 'https://example.invalid/original',
publisher: 'Rich Publisher',
richMediaVersion: 3,
};
const result = await getCollectionList({
uin: '10001',
pskey: 'ticket-value',
transport: async () =>
successResponse(
[
{
cid: 'cid-rich',
type: 8,
author: { numId: 2n },
modifyTime: 1_300n,
summary: { richMediaSummary },
},
],
1,
),
});
const item = result.collectionSearchList.collectionItemList[0];
expect(item.type).toBe(8);
expect(item.summary.richMediaSummary).toMatchObject({
title: 'Rich Media Title',
subTitle: 'Rich Media Subtitle',
brief: 'Rich media brief',
picList: [{ url: 'https://example.invalid/rich.jpg', field6: 800, field7: 600 }],
contentType: 2,
originalUri: 'https://example.invalid/original',
publisher: 'Rich Publisher',
richMediaVersion: 3,
});
expect(item.summary.linkSummary).toBeNull();
});
it('filters categories while following a strictly decreasing cursor', async () => {
const cursors: bigint[] = [];
const sequences: number[] = [];
const transport = vi.fn(async (request: { body: Uint8Array }) => {
const decoded = decodeRequestEnvelope(request.body);
cursors.push(decoded.request.timeStamp ?? 0n);
sequences.push(decoded.head.sequence ?? 0);
if (cursors.length === 1) {
return successResponse(
[
{
cid: 'other',
author: { numId: 1n },
category: 2,
modifyTime: 1_200n,
},
],
0,
);
}
return successResponse(
[
{
cid: 'match',
author: { numId: 1n },
category: 4,
modifyTime: 900n,
},
],
1,
);
});
const result = await getCollectionList({
uin: '10001',
pskey: 'ticket-value',
category: 4,
count: 1,
transport,
});
expect(cursors).toEqual([0xffff_ffff_ffff_ffffn, 1_200n]);
expect(sequences).toEqual([1, 2]);
expect(result.collectionSearchList.collectionItemList.map((item) => item.cid)).toEqual([
'match',
]);
expect(result.collectionSearchList.hasMore).toBe(false);
expect(result.collectionSearchList.bottomTimeStamp).toBe('900');
});
it('checks later pages before reporting more filtered collection items', async () => {
const transport = vi.fn(async (request: { body: Uint8Array }) => {
const { head } = decodeRequestEnvelope(request.body);
if (head.sequence === 1) {
return successResponse(
[
{
cid: 'only-match',
author: { numId: 1n },
category: 4,
modifyTime: 1_200n,
},
],
0,
);
}
return successResponse(
[
{
cid: 'different-category',
author: { numId: 2n },
category: 2,
modifyTime: 1_100n,
},
],
1,
);
});
const result = await getCollectionList({
uin: '10001',
pskey: 'ticket-value',
category: 4,
count: 1,
transport,
});
expect(transport).toHaveBeenCalledTimes(2);
expect(result.collectionSearchList.collectionItemList.map((item) => item.cid)).toEqual([
'only-match',
]);
expect(result.collectionSearchList.hasMore).toBe(false);
expect(result.collectionSearchList.bottomTimeStamp).toBe('1200');
});
it('surfaces explicit service failures instead of returning an empty list', async () => {
await expect(
getCollectionList({
uin: '10001',
pskey: 'ticket-value',
transport: async () => errorResponse(-17, 'denied'),
}),
).rejects.toThrow('collection service error -17: denied');
});
it('fails when the server reports more data without advancing pagination', async () => {
const response = successResponse([], 0);
await expect(
getCollectionList({
uin: '10001',
pskey: 'ticket-value',
category: 4,
count: 1,
transport: async () => response,
}),
).rejects.toThrow('collection pagination made no progress');
});
it('rejects an unbounded count before making a request', async () => {
const transport = vi.fn();
await expect(
getCollectionList({
uin: '10001',
pskey: 'ticket-value',
count: 501,
transport,
}),
).rejects.toThrow('collection count must be between 1 and 500');
expect(transport).not.toHaveBeenCalled();
});
it('stops after the explicit pagination budget', async () => {
let calls = 0;
const transport = vi.fn(async () => {
calls += 1;
return successResponse(
[
{
cid: `other-${calls}`,
author: { numId: 1n },
category: 2,
modifyTime: BigInt(10_000 - calls),
},
],
0,
);
});
await expect(
getCollectionList({
uin: '10001',
pskey: 'ticket-value',
category: 4,
count: 1,
transport,
}),
).rejects.toThrow('collection pagination exceeded 100 pages');
expect(transport).toHaveBeenCalledTimes(100);
});
it('bounds the total time spent paginating collection results', async () => {
vi.useFakeTimers();
try {
const timeouts: number[] = [];
let calls = 0;
const transport = vi.fn(async (request: { timeoutMs: number }) => {
calls += 1;
timeouts.push(request.timeoutMs);
vi.advanceTimersByTime(8_000);
return successResponse(
[
{
cid: `other-${calls}`,
author: { numId: 1n },
category: 2,
modifyTime: BigInt(10_000 - calls),
},
],
0,
);
});
await expect(
getCollectionList({
uin: '10001',
pskey: 'ticket-value',
category: 4,
count: 1,
transport,
}),
).rejects.toThrow('collection pagination exceeded 60000ms');
expect(transport).toHaveBeenCalledTimes(8);
expect(timeouts.slice(0, 7)).toEqual(Array(7).fill(10_000));
expect(timeouts[7]).toBe(4_000);
} finally {
vi.useRealTimers();
}
});
it.each([
['cid', { author: { numId: 1n }, modifyTime: 1n }, 'collection item is missing cid'],
['author', { cid: 'cid-1', modifyTime: 1n }, 'collection item cid-1 is missing author'],
[
'modifyTime',
{ cid: 'cid-1', author: { numId: 1n } },
'collection item cid-1 is missing modifyTime',
],
] as const)('rejects collection items missing required %s', async (_field, item, message) => {
await expect(
getCollectionList({
uin: '10001',
pskey: 'ticket-value',
transport: async () => successResponse([item], 1),
}),
).rejects.toThrow(message);
});
it('uses the last returned item for the public cursor when count truncates a page', async () => {
const result = await getCollectionList({
uin: '10001',
pskey: 'ticket-value',
count: 1,
transport: async () =>
successResponse(
[
{ cid: 'first', author: { numId: 1n }, modifyTime: 1_200n },
{ cid: 'not-returned', author: { numId: 2n }, modifyTime: 1_100n },
],
1,
),
});
expect(result.collectionSearchList.collectionItemList.map((item) => item.cid)).toEqual([
'first',
]);
expect(result.collectionSearchList.bottomTimeStamp).toBe('1200');
expect(result.collectionSearchList.hasMore).toBe(true);
});
it('cancels a failed HTTP response body before surfacing the status', async () => {
const cancel = vi.fn();
const stream = new ReadableStream<Uint8Array>({ cancel });
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(stream, {
status: 503,
statusText: 'Unavailable',
}),
),
);
await expect(
getCollectionList({
uin: '10001',
pskey: 'ticket-value',
}),
).rejects.toThrow('collection HTTP request failed: 503 Unavailable');
expect(cancel).toHaveBeenCalledOnce();
});
it.each([
['invalid', 'collection response has invalid content-length'],
[`${16 * 1024 * 1024 + 1}`, 'collection response exceeds 16777216 bytes'],
])('cancels response bodies rejected by content-length %s', async (contentLength, message) => {
const cancel = vi.fn();
const stream = new ReadableStream<Uint8Array>({ cancel });
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(stream, {
headers: { 'Content-Length': contentLength },
}),
),
);
await expect(
getCollectionList({
uin: '10001',
pskey: 'ticket-value',
}),
).rejects.toThrow(message);
expect(cancel).toHaveBeenCalledOnce();
});
it('preserves both the primary response error and cancellation failure', async () => {
const primary = new Error('collection response has invalid content-length');
const cleanup = new Error('cancel failed');
const stream = new ReadableStream<Uint8Array>({
cancel: () => {
throw cleanup;
},
});
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(stream, {
headers: { 'Content-Length': 'invalid' },
}),
),
);
let thrown: unknown;
try {
await getCollectionList({ uin: '10001', pskey: 'ticket-value' });
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(AggregateError);
const aggregate = thrown as AggregateError;
expect(aggregate.errors).toHaveLength(2);
expect(aggregate.errors[0]).toMatchObject({ message: primary.message });
expect(aggregate.errors[1]).toBe(cleanup);
});
});