SnowLuma v2.4.2 source snapshot
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
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
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,713 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user