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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<body>
|
||||
<!--
|
||||
Sanitized from a real QQ friend-dress SSR response. Only fields consumed
|
||||
by SnowLuma remain; account identifiers, timestamps, tracking markup,
|
||||
recommendation data, and embedded assets have been removed.
|
||||
-->
|
||||
<script>
|
||||
window.__INITIAL_ASYNCDATA__={
|
||||
"isHost": false,
|
||||
"targetUin": "10000",
|
||||
"isSvip": true,
|
||||
"avatarImage": "https://q.qlogo.cn/g?b=qq&nk=10000&s=100",
|
||||
"rawUsingList": [
|
||||
{
|
||||
"appId": 2,
|
||||
"itemId": 2141485,
|
||||
"name": "在说什么呢",
|
||||
"image": "https://tianquan.gtimg.cn/bubble/item/2141485/newPreview1.png",
|
||||
"extrainfo": { "price": 300 }
|
||||
},
|
||||
{
|
||||
"appId": 15,
|
||||
"itemId": 201534,
|
||||
"name": "二次元少女",
|
||||
"image": "https://tianquan.gtimg.cn/card/original/201534/templateThumb.jpg",
|
||||
"extrainfo": {}
|
||||
},
|
||||
{
|
||||
"appId": 22,
|
||||
"itemId": 2000,
|
||||
"name": "花瓣",
|
||||
"image": "https://tianquan.gtimg.cn/colorScreen/item/2000/listImg.png",
|
||||
"extrainfo": {}
|
||||
},
|
||||
{
|
||||
"appId": 17,
|
||||
"itemId": 2730,
|
||||
"name": "岁月成碑",
|
||||
"image": "https://tianquan.gtimg.cn/funcall/funCall/2730/web_image.jpg",
|
||||
"extrainfo": {}
|
||||
},
|
||||
{
|
||||
"appId": 4,
|
||||
"name": "默认挂件",
|
||||
"image": "https://tianquan.gtimg.cn/faceAddon/item/0/new1.png",
|
||||
"extrainfo": {}
|
||||
},
|
||||
{
|
||||
"appId": 23,
|
||||
"name": "默认头像",
|
||||
"image": "https://q1.qlogo.cn/qhis/FixtureAvatarHashRedacted/",
|
||||
"extrainfo": {}
|
||||
},
|
||||
{
|
||||
"appId": 47,
|
||||
"name": "拍了拍",
|
||||
"image": "https://tianquan.gtimg.cn/nudgeeffect/item/0/preview.png",
|
||||
"extrainfo": {}
|
||||
}
|
||||
]
|
||||
};(function(){})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
_preloadCallback({
|
||||
"code":0,
|
||||
"subcode":0,
|
||||
"message":"",
|
||||
"default":0,
|
||||
"data":
|
||||
{main:{attach:'',searchtype :'',hasMoreFeeds:false,daylist:'',uinlist:'',error:'',hotkey:'',icGroupData:[],host_level:'',friend_level:'',lastaccesstime:'',lastAccessRelateTime:'',begintime:'1780711209',endtime:'',dayspac:'11',hidedNameList:[], aisortBeginTime:'', aisortEndTime:'', aisortOffset:'', aisortNextTime:'',owner_bitmap:'08009c8802000401',pagenum:'2',externparam:'basetime=1780711209&pagenum=2&dayvalue=11&getadvlast=0&hasgetadv=&lastentertime=0&LastAdvPos=0&UnReadCount=1&UnReadSum=1&LastIsADV=0&UpdatedFollowUins=&UpdatedFollowCount=0&LastRecomBrandID=&TRKPreciList=&gdtadvcookie=eyJsYXN0X2Fkdl9wb3NpdGlvbiI6LTF9'},data:[{ver:'1',appid:'311',typeid:'0',key:'aef23101297f236ae4f10600',flag:'0',dataonly:'0',titleTemp:'',summaryTemp:'',feedno:'0',title:'',summary:'',appiconid:'311',clscFold:'icenter_list_extend',abstime:'1780711209',feedstime:'6月6日 10:00',userHome:'http://user.qzone.qq.com/20050606',namecardLink:'nameCard_20050606',opuin:'20050606',uin:'20050606',ouin:'',foldFeed:'',foldFeedTitle:'',showEbtn:'',scope:'0',hideExtend:'',nickname:'官方Qzone',emoji:[],remark:'',type:'',vip:'novip',bitmap:'989d9de01513c081',yybitmap:'100008000000000b',info_user_name:'',logimg:'http://qlogo3.store.qq.com/qzone/20050606/20050606/50?1450773587',bor:'',lastFeedBor:'',list_bor2:'',info_user_display:'',upernum:'',oprType:'0',moreflag:'',otherflag:'1_0_1_0_0_0_1',rightflag:'',sameuser:{},uper_isfriend:[],uperlist:[],smallstar:'',html:' \t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t \t\t\t\t\t\t\t\t\t\t\t\t\t\t \x3Cli class=\x22f-single f-s-s\x22 id=\x22fct_20050606_311_0_1780711209_0_1\x22>\x3Cdiv class=\x22f-single-head f-aside\x22>\x3Cdiv class=\x22f-adorn-top\x22>\x3C\/div> \x3Cdiv class=\x22user-pto\x22>\x3Ca href=\x22http:\/\/user.qzone.qq.com\/20050606\x22 target=\x22_blank\x22 class=\x22user-avatar q_namecard f-s-a\x22 link=\x22nameCard_20050606\x22 data-clicklog=\x22avatar\x22>\x3Cimg src=\x22https:\/\/qlogo3.store.qq.com\/qzone\/20050606\/20050606\/50?1450773587\x22>\x3C\/a>\x3C\/div>\x3Cdiv class=\x22user-op\x22>\x3Ca href=\x22javascript:;\x22 class=\x22arrow-down\x22 data-cmd=\x22qz_opr_more\x22 data-moreoperate=\x220\x22>\x3Ci class=\x22fui-icon icon-arrow-down\x22>\x3C\/i>\x3C\/a> \x3C\/div>\x3Cdiv class=\x22user-info\x22>\x3Cdiv class=\x22f-nick\x22>\x3Ca target=\x22_blank\x22 href=\x22http:\/\/user.qzone.qq.com\/20050606\x22 data-clicklog=\x22nick\x22 class=\x22f-name q_namecard \x22 link=\x22nameCard_20050606\x22>官方Qzone\x3C\/a> \x3Ca target=\x22_blank\x22 href=\x22http:\/\/page.qq.com\x22 class=\x22user-medal\x22>\x3Cimg class=\x22ui_mr5\x22 src=\x22\/ac\/qzone_v5\/client\/auth_icon.png\x22 title=\x22腾讯认证\x22 alt=\x22腾讯认证\x22 \/>\x3C\/a> \x3C\/div>\x3Cdiv class=\x22info-detail\x22>\t\t\t\t\t\x3Cspan class=\x22 ui-mr8 state\x22 >\t\t6月6日 10:00\t\t\x3C\/span>\t\t\x3Ca href=\x22javascript:;\x22 data-cmd=\x22qz_sign\x22 class=\x22f-sign-show state\x22 title=\x22我也要设置\x22>\x3C\/a>\x3C\/div>\x3C\/div>\x3C\/div>\x3Cdiv class=\x22f-single-content f-wrap\x22>\x3Cdiv class=\x22f-item f-s-i\t\x22 id=\x22feed_20050606_311_0_1780711209_0_1\x22 data-feedsflag=\x22\x22 data-iswupfeed=\x221\x22 data-key=\x22aef23101297f236ae4f10600\x22 data-specialtype=\x22\x22 data-extend-info=\x221_0_1_0_0_0_1|989d9de01513c081|100008000000000b\x22 data-functype=\x22func_friend_feed\x22 data-hasfollowed=\x221\x22>\x3Cdiv class=\x22f-info qz_info_cut\x22>\t\t\t\t\t时间从不说谎,它把你以为的妄想,悄悄兑换成了现实;它把你以为忘了的那些日子,原原本本保存了下来。\x3Cbr\/>21年了,QQ空间也记得。\x3Cbr\/>今天,「那年今日」更新了—— 翻开那些年的说说、照片和动态,被收进时光里的每一个你,都还在。\x3Cbr\/>不用刻意回忆,不用费力寻找,它自己找到你,轻轻拍了拍你的肩。\x3Cbr\/>\t\t \x3Ca href=\x22javascript:;\x22 data-cmd=\x22qz_toggle\x22 data-pos=\x221\x22>展开全文\x3C\/a>\x3Cimg src=\x22http:\/\/qzonestyle.gtimg.cn\/qzone_v6\/img\/feed\/loading.gif\x22 class=\x22load_img none\x22>\x3C\/div>\x3Cdiv class=\x22f-info qz_info_complete none\x22>\x3C\/div>\x3Cdiv class=\x22qz_summary wupfeed\x22 id=\x22hex_20050606_311_0_1780711209_0_1\x22>\x3Ci class=\x22none\x22 name=\x22feed_data\x22 \t data-bitmap=\x22989d9de01513c081\x22\t data-yybitmap=\x22100008000000000b\x22\t data-vipstarbitmap=\x220100004002000080\x22\t\t data-fkey=\x22aef23101297f236ae4f10600\x22\t data-tid=\x22aef23101297f236ae4f10600\x22\t data-uin=\x2220050606\x22\t data-origfkey=\x22\x22\t data-origtid=\x22aef23101297f236ae4f10600\x22\t data-origuin=\x2220050606\x22\t data-subid=\x22\x22\t data-totweet=\x22\x22\t data-issignin=\x22\x22\t data-source=\x22\x22\t data-retweetcount=\x221\x22\t data-stLine truncated
|
||||
);
|
||||
@@ -0,0 +1,264 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { FriendDressError, getFriendDressWebAPI } from '@snowluma/protocol/web/friend-dress';
|
||||
import { RequestUtil } from '@snowluma/protocol/web/request-util';
|
||||
|
||||
// 真实装扮页 SSR HTML(脱敏:uin 改为 10000、头像 hash 已打码)。
|
||||
// __INITIAL_ASYNCDATA__ 里 7 项装扮:气泡(2)/名片(15)/彩色屏保(22)/来电(17)/
|
||||
// 挂件(4)/头像(23)/头像双击动作(47) —— 其中 2/23 属服务器不回真值的废数据。
|
||||
const SAMPLE_HTML = readFileSync(
|
||||
fileURLToPath(new URL('./fixtures/friend-dress-sample.html', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const cookies = { uin: 'o10001', skey: 'SKEY', p_uin: 'o10001', p_skey: 'PSKEY' };
|
||||
|
||||
/** 把任意 asyncData 对象包成最小可解析的 SSR HTML。 */
|
||||
const wrapHtml = (data: unknown): string =>
|
||||
`<html><script>window.__INITIAL_ASYNCDATA__=${JSON.stringify(data)};(function(){})()</script></html>`;
|
||||
|
||||
const mockHtml = (html: string) =>
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(html as never);
|
||||
|
||||
describe('friend-dress / real sample parsing', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('sends cookie + mobile UA to zb.vip.qq.com with the target uin', async () => {
|
||||
const request = mockHtml(SAMPLE_HTML);
|
||||
await getFriendDressWebAPI(cookies, '10000');
|
||||
|
||||
const [url, method, , headers, limits] = request.mock.calls[0]!;
|
||||
expect(url).toContain('https://zb.vip.qq.com/v2/pages/aioDressPage?');
|
||||
expect(url).toContain('targetUin=10000');
|
||||
// traceDetail 是预编码的 base64 串,必须原样出现(不能被二次编码成 base64%2D…)。
|
||||
expect(url).toContain('&traceDetail=base64-');
|
||||
expect(method).toBe('GET');
|
||||
expect(headers).toMatchObject({
|
||||
Cookie: 'uin=o10001; skey=SKEY; p_uin=o10001; p_skey=PSKEY',
|
||||
});
|
||||
expect((headers as Record<string, string>)['User-Agent']).toContain('QQ/');
|
||||
expect(limits).toEqual({
|
||||
timeoutMs: 10_000,
|
||||
maxResponseBytes: 2 * 1024 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses the real page: svip, avatar, and only resolvable dress items', async () => {
|
||||
mockHtml(SAMPLE_HTML);
|
||||
const dress = await getFriendDressWebAPI(cookies, '10000');
|
||||
|
||||
expect(dress.target_uin).toBe('10000');
|
||||
expect(dress.is_svip).toBe(true);
|
||||
expect(dress.avatar_url).toBe('https://q.qlogo.cn/g?b=qq&nk=10000&s=100');
|
||||
|
||||
// 气泡(2)/头像(23) 是服务器永远回默认款的废数据,必须被剔除。
|
||||
const appIds = dress.items.map((i) => i.app_id);
|
||||
expect(appIds).not.toContain(2);
|
||||
expect(appIds).not.toContain(23);
|
||||
expect(appIds).toEqual([15, 22, 17, 4, 47]);
|
||||
});
|
||||
|
||||
it('maps item fields: kind, itemId default 0, price default 0', async () => {
|
||||
mockHtml(SAMPLE_HTML);
|
||||
const dress = await getFriendDressWebAPI(cookies, '10000');
|
||||
|
||||
const card = dress.items.find((i) => i.app_id === 15)!;
|
||||
expect(card.kind).toBe('名片');
|
||||
expect(card.item_id).toBe(201534);
|
||||
expect(card.name).toBe('二次元少女');
|
||||
expect(card.preview_url).toBe(
|
||||
'https://tianquan.gtimg.cn/card/original/201534/templateThumb.jpg',
|
||||
);
|
||||
expect(card.price).toBe(0);
|
||||
|
||||
// 默认挂件没有 itemId 字段 → 0。
|
||||
const widget = dress.items.find((i) => i.app_id === 4)!;
|
||||
expect(widget.item_id).toBe(0);
|
||||
expect(widget.kind).toBe('挂件');
|
||||
});
|
||||
|
||||
it('derives the funcall video url from the real web_image.jpg preview', async () => {
|
||||
mockHtml(SAMPLE_HTML);
|
||||
const dress = await getFriendDressWebAPI(cookies, '10000');
|
||||
|
||||
const funcall = dress.items.find((i) => i.app_id === 17)!;
|
||||
expect(funcall.preview_url).toBe(
|
||||
'https://tianquan.gtimg.cn/funcall/funCall/2730/web_image.jpg',
|
||||
);
|
||||
expect(funcall.video_url).toBe('https://tianquan.gtimg.cn/funcall/funCall/2730/media.mp4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('friend-dress / synthetic payloads', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const base = {
|
||||
targetUin: '10000',
|
||||
isSvip: false,
|
||||
avatarImage: '',
|
||||
rawUsingList: [] as unknown[],
|
||||
};
|
||||
|
||||
it('an empty rawUsingList is a valid result, not an error', async () => {
|
||||
mockHtml(wrapHtml(base));
|
||||
const dress = await getFriendDressWebAPI(cookies, '10000');
|
||||
expect(dress.items).toEqual([]);
|
||||
expect(dress.is_svip).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT return the image url as video when a funcall preview has an unexpected name', async () => {
|
||||
mockHtml(
|
||||
wrapHtml({
|
||||
...base,
|
||||
rawUsingList: [
|
||||
{
|
||||
appId: 17,
|
||||
itemId: 1,
|
||||
name: 'x',
|
||||
image: 'https://cdn.example/funCall/1/preview.png',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const dress = await getFriendDressWebAPI(cookies, '10000');
|
||||
expect(dress.items[0]!.video_url).toBe('');
|
||||
expect(dress.items[0]!.preview_url).toBe('https://cdn.example/funCall/1/preview.png');
|
||||
});
|
||||
|
||||
it('extracts the card video from immersiveMaterial', async () => {
|
||||
mockHtml(
|
||||
wrapHtml({
|
||||
...base,
|
||||
rawUsingList: [
|
||||
{
|
||||
appId: 15,
|
||||
itemId: 1,
|
||||
name: 'a',
|
||||
image: 'https://cdn.example/a.jpg',
|
||||
extraappinfo: {
|
||||
extraInfo: {
|
||||
immersiveMaterial: '{"videoUrl":"https://cdn.example/a.mp4"}',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const dress = await getFriendDressWebAPI(cookies, '10000');
|
||||
expect(dress.items[0]!.video_url).toBe('https://cdn.example/a.mp4');
|
||||
});
|
||||
|
||||
it('labels unknown appIds without dropping them', async () => {
|
||||
mockHtml(
|
||||
wrapHtml({
|
||||
...base,
|
||||
rawUsingList: [{ appId: 999, itemId: 7, name: 'n', image: '' }],
|
||||
}),
|
||||
);
|
||||
const dress = await getFriendDressWebAPI(cookies, '10000');
|
||||
expect(dress.items[0]!.kind).toBe('appId=999');
|
||||
});
|
||||
});
|
||||
|
||||
describe('friend-dress / error classification', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const expectKind = async (uin: string, kind: string) => {
|
||||
const e = await getFriendDressWebAPI(cookies, uin).catch((err: unknown) => err);
|
||||
expect(e).toBeInstanceOf(FriendDressError);
|
||||
expect((e as FriendDressError).kind).toBe(kind);
|
||||
return e as FriendDressError;
|
||||
};
|
||||
|
||||
it('network: transport failure is not folded into a parse error', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockRejectedValue(
|
||||
new Error('Unexpected status code: 403'),
|
||||
);
|
||||
const e = await expectKind('10000', 'network');
|
||||
expect(e.message).toContain('403');
|
||||
});
|
||||
|
||||
it('parse: html without __INITIAL_ASYNCDATA__ (login-wall / risk-control shell)', async () => {
|
||||
mockHtml('<html><body>请先登录</body></html>');
|
||||
await expectKind('10000', 'parse');
|
||||
});
|
||||
|
||||
it('parse: __INITIAL_ASYNCDATA__ present but not valid JSON', async () => {
|
||||
mockHtml('<script>window.__INITIAL_ASYNCDATA__={oops};(function(){})()</script>');
|
||||
await expectKind('10000', 'parse');
|
||||
});
|
||||
|
||||
it('parse: a top-level array never matches the SSR object shape', async () => {
|
||||
mockHtml(wrapHtml([1, 2]));
|
||||
await expectKind('10000', 'parse');
|
||||
});
|
||||
|
||||
it('structure: malformed immersiveMaterial is not silently discarded', async () => {
|
||||
mockHtml(
|
||||
wrapHtml({
|
||||
targetUin: '10000',
|
||||
rawUsingList: [
|
||||
{
|
||||
appId: 15,
|
||||
itemId: 2,
|
||||
name: 'broken',
|
||||
image: 'https://cdn.example/b.jpg',
|
||||
extraappinfo: { extraInfo: { immersiveMaterial: '{not json' } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const e = await expectKind('10000', 'structure');
|
||||
expect(e.message).toContain('immersiveMaterial');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['not a string', { videoUrl: 'https://cdn.example/video.mp4' }],
|
||||
['an array', '[]'],
|
||||
['missing videoUrl', '{}'],
|
||||
['non-string videoUrl', '{"videoUrl":42}'],
|
||||
])('structure: immersiveMaterial %s is not silently discarded', async (_label, material) => {
|
||||
mockHtml(
|
||||
wrapHtml({
|
||||
targetUin: '10000',
|
||||
rawUsingList: [
|
||||
{
|
||||
appId: 15,
|
||||
itemId: 2,
|
||||
name: 'broken',
|
||||
image: 'https://cdn.example/b.jpg',
|
||||
extraappinfo: { extraInfo: { immersiveMaterial: material } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const e = await expectKind('10000', 'structure');
|
||||
expect(e.message).toContain('immersiveMaterial');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['rawUsingList missing', { targetUin: '10000' }],
|
||||
['rawUsingList not an array', { targetUin: '10000', rawUsingList: 'nope' }],
|
||||
['item not an object', { targetUin: '10000', rawUsingList: ['nope'] }],
|
||||
['item appId missing', { targetUin: '10000', rawUsingList: [{}] }],
|
||||
['item appId not a number', { targetUin: '10000', rawUsingList: [{ appId: 'four' }] }],
|
||||
['targetUin not a string', { targetUin: 10000, rawUsingList: [] }],
|
||||
])('structure: %s', async (_label, payload) => {
|
||||
mockHtml(wrapHtml(payload));
|
||||
await expectKind('10000', 'structure');
|
||||
});
|
||||
|
||||
it('uin_mismatch: page answers for a different account than requested', async () => {
|
||||
mockHtml(wrapHtml({ targetUin: '20000', rawUsingList: [] }));
|
||||
const e = await expectKind('10000', 'uin_mismatch');
|
||||
expect(e.message).toContain('20000');
|
||||
expect(e.message).toContain('10000');
|
||||
});
|
||||
|
||||
it('structure: targetUin missing cannot prove which account owns the response', async () => {
|
||||
mockHtml(wrapHtml({ rawUsingList: [] }));
|
||||
const e = await expectKind('10000', 'structure');
|
||||
expect(e.message).toContain('targetUin');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { uploadImageToGroupAlbum, uploadVideoToGroupAlbum } from '../../src/web/group-album';
|
||||
import { RequestUtil } from '../../src/web/request-util';
|
||||
|
||||
const MP4_BASE64 = 'AAAAGGZ0eXBtcDQyAAAAAG1wNDJpc29t';
|
||||
const MP4_SOURCE = `base64://${MP4_BASE64}`;
|
||||
const MP4_BYTES = Buffer.from(MP4_BASE64, 'base64');
|
||||
const JPEG_SOURCE = 'base64:///9j/4AAQSkZJRgABAQAAAQABAAD/2wAAAA==';
|
||||
const COVER = Buffer.from('album-cover');
|
||||
|
||||
function controlReq(body: unknown): Record<string, unknown> {
|
||||
const req = (body as { control_req: Array<Record<string, unknown>> }).control_req[0];
|
||||
expect(req).toBeTruthy();
|
||||
return req;
|
||||
}
|
||||
|
||||
describe('group album image upload', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('rejects an MP4 before starting a remote upload', async () => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetJson')
|
||||
.mockRejectedValue(new Error('unexpected remote upload'));
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
|
||||
await expect(
|
||||
uploadImageToGroupAlbum(
|
||||
{ skey: 'SK', p_skey: 'PSK' },
|
||||
'12345',
|
||||
'album-id',
|
||||
'album-name',
|
||||
MP4_SOURCE,
|
||||
'10000',
|
||||
),
|
||||
).rejects.toThrow('群相册上传仅支持 JPEG、PNG、GIF、WebP 或 BMP 图片');
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('group album video upload', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('rejects a JPEG before starting a remote upload', async () => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetJson')
|
||||
.mockRejectedValue(new Error('unexpected remote upload'));
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const probe = vi.fn();
|
||||
|
||||
await expect(
|
||||
uploadVideoToGroupAlbum(
|
||||
{ skey: 'SK', p_skey: 'PSK' },
|
||||
'12345',
|
||||
'album-id',
|
||||
'album-name',
|
||||
JPEG_SOURCE,
|
||||
'10000',
|
||||
{ probe },
|
||||
),
|
||||
).rejects.toThrow('群相册视频上传仅支持 MP4 等 ISO BMFF 视频');
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads the video with SHA1 then the cover bound to the returned vid', async () => {
|
||||
const sha1 = createHash('sha1').update(MP4_BYTES).digest('hex');
|
||||
const coverMd5 = createHash('md5').update(COVER).digest('hex');
|
||||
const bodies: unknown[] = [];
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetJson')
|
||||
.mockImplementation(async (_url, _method, body) => {
|
||||
bodies.push(body);
|
||||
const appid = controlReq(body).appid;
|
||||
|
||||
if (appid === 'video_qun') {
|
||||
return {
|
||||
ret: 0,
|
||||
msg: '',
|
||||
data: { session: 'video-session', slice_size: 16384 },
|
||||
};
|
||||
}
|
||||
|
||||
return { ret: 0, msg: '', data: { session: 'cover-session', slice_size: 16384 } };
|
||||
});
|
||||
|
||||
const fetch = vi.fn(async (url: string) => ({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
if (url.includes('FileUploadVideo')) {
|
||||
return { ret: 0, msg: '', data: { biz: { sVid: 'VID123' } } };
|
||||
}
|
||||
|
||||
return {
|
||||
ret: 0,
|
||||
msg: '',
|
||||
data: { biz: { sPhotoID: 'PID', sBURL: 'https://example.test/v.jpg' } },
|
||||
};
|
||||
},
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
|
||||
const result = await uploadVideoToGroupAlbum(
|
||||
{ skey: 'SK', p_skey: 'PSK' },
|
||||
'12345',
|
||||
'album-id',
|
||||
'album-name',
|
||||
MP4_SOURCE,
|
||||
'10000',
|
||||
{
|
||||
now: () => 1700000000,
|
||||
probe: async () => ({ duration: 6.077, cover: new Uint8Array(COVER) }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ id: 'VID123' });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(String(request.mock.calls[0]![0])).toContain(`FileBatchControl/${sha1}`);
|
||||
expect(String(request.mock.calls[1]![0])).toContain(`FileBatchControl/${coverMd5}`);
|
||||
|
||||
const videoReq = controlReq(bodies[0]);
|
||||
expect(videoReq).toMatchObject({
|
||||
appid: 'video_qun',
|
||||
checksum: sha1,
|
||||
check_type: 1,
|
||||
cmd: 'FileUploadVideo',
|
||||
env: { refer: 'qzone', deviceInfo: 'h5' },
|
||||
});
|
||||
expect(videoReq.biz_req).toMatchObject({
|
||||
iUploadType: 3,
|
||||
iPlayTime: 6077,
|
||||
iIsNew: 111,
|
||||
sTitle: 'video.mp4',
|
||||
extend_info: {
|
||||
video_type: '3',
|
||||
domainid: '5',
|
||||
photo_num: '0',
|
||||
video_num: '1',
|
||||
qun_id: '12345',
|
||||
},
|
||||
});
|
||||
|
||||
const coverReq = controlReq(bodies[1]);
|
||||
expect(coverReq).toMatchObject({
|
||||
appid: 'qun',
|
||||
checksum: coverMd5,
|
||||
check_type: 0,
|
||||
cmd: '',
|
||||
env: { refer: 'huodong', deviceInfo: 'h5' },
|
||||
});
|
||||
expect(coverReq.biz_req).toMatchObject({
|
||||
iUploadType: 2,
|
||||
iBatchID: 1700000000,
|
||||
sAlbumID: 'album-id',
|
||||
sAlbumName: 'album-name',
|
||||
mutliPicInfo: { iBatUploadNum: 1 },
|
||||
stExtendInfo: { mapParams: { vid: 'VID123', photo_num: '0', video_num: '1' } },
|
||||
stExternalMapExt: { is_client_upload_cover: '1', is_pic_video_mix_feeds: '1' },
|
||||
});
|
||||
|
||||
const sliceUrls = fetch.mock.calls.map((call) => String(call[0]));
|
||||
expect(sliceUrls.some((url) => url.includes('/sliceUpload/FileUploadVideo?'))).toBe(true);
|
||||
expect(sliceUrls.some((url) => url.includes('/sliceUpload/FileUpload?'))).toBe(true);
|
||||
});
|
||||
|
||||
it('fails when the video slices do not return a vid', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({
|
||||
ret: 0,
|
||||
msg: '',
|
||||
data: { session: 'video-session', slice_size: 16384 },
|
||||
});
|
||||
const fetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ ret: 0, msg: '', data: { biz: {} } }),
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
|
||||
await expect(
|
||||
uploadVideoToGroupAlbum(
|
||||
{ skey: 'SK', p_skey: 'PSK' },
|
||||
'12345',
|
||||
'album-id',
|
||||
'album-name',
|
||||
MP4_SOURCE,
|
||||
'10000',
|
||||
{
|
||||
now: () => 1700000000,
|
||||
probe: async () => ({ duration: 6, cover: new Uint8Array(COVER) }),
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('视频上传未返回视频 id');
|
||||
|
||||
expect(RequestUtil.HttpGetJson).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
getGroupEssenceMsgAll,
|
||||
type GroupEssenceMsgRet,
|
||||
} from '@snowluma/protocol/web/group-essence';
|
||||
import { RequestUtil } from '@snowluma/protocol/web/request-util';
|
||||
|
||||
const cookie = { skey: 'test-skey' };
|
||||
|
||||
function page(isEnd: boolean): GroupEssenceMsgRet {
|
||||
return {
|
||||
retcode: 0,
|
||||
data: {
|
||||
is_end: isEnd,
|
||||
msg_list: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('group essence pagination', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('rejects instead of returning a partial list when a later page fails', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetJson')
|
||||
.mockResolvedValueOnce(page(false) as never)
|
||||
.mockRejectedValueOnce(new Error('socket closed'));
|
||||
|
||||
await expect(getGroupEssenceMsgAll(cookie, '123456789')).rejects.toThrow('socket closed');
|
||||
});
|
||||
|
||||
it('surfaces a non-zero QQ response code', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({
|
||||
retcode: 100,
|
||||
retmsg: 'permission denied',
|
||||
data: {
|
||||
is_end: true,
|
||||
msg_list: [],
|
||||
},
|
||||
} satisfies GroupEssenceMsgRet as never);
|
||||
|
||||
await expect(getGroupEssenceMsgAll(cookie, '123456789')).rejects.toThrow(
|
||||
/retcode 100.*permission denied/,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an omitted list', { is_end: true }],
|
||||
['a null list', { is_end: true, msg_list: null }],
|
||||
])('normalizes an ended success response with %s to an empty page', async (_case, data) => {
|
||||
const request = vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({
|
||||
retcode: 0,
|
||||
data,
|
||||
} as never);
|
||||
|
||||
await expect(getGroupEssenceMsgAll(cookie, '123456789')).resolves.toEqual([
|
||||
{
|
||||
retcode: 0,
|
||||
data: { is_end: true, msg_list: [] },
|
||||
},
|
||||
]);
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('rejects a missing message list before the response explicitly ends', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({
|
||||
retcode: 0,
|
||||
data: { is_end: false },
|
||||
} as never);
|
||||
|
||||
await expect(getGroupEssenceMsgAll(cookie, '123456789')).rejects.toThrow(
|
||||
/invalid group essence response.*msg_list/,
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when the configured page limit cannot reach the end', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue(page(false) as never);
|
||||
|
||||
await expect(getGroupEssenceMsgAll(cookie, '123456789')).rejects.toThrow(
|
||||
/pagination exceeded 20 pages/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
GROUP_NOTICE_TYPE_NEW_MEMBERS,
|
||||
calculateBkn,
|
||||
getGroupNoticeWebAPI,
|
||||
groupNoticeImageUrl,
|
||||
parseGroupNoticeImageUploadResponse,
|
||||
resolveGroupNoticeOptions,
|
||||
setGroupNoticeWebAPI,
|
||||
type WebApiGroupNoticeRet,
|
||||
} from '@snowluma/protocol/web/group-notice';
|
||||
import { RequestUtil } from '@snowluma/protocol/web/request-util';
|
||||
|
||||
describe('group-notice / publish HTTP layer', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const cookies = { skey: 'BODY_KEY', p_skey: 'URL_KEY', uin: 'o10000' };
|
||||
|
||||
it('publishes a regular announcement with the exact options and image metadata', async () => {
|
||||
const request = vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({ ec: 0 } as never);
|
||||
|
||||
await setGroupNoticeWebAPI(cookies, '12345', 'regular notice', {
|
||||
pinned: 1,
|
||||
isShowEditCard: 0,
|
||||
tipWindowType: 0,
|
||||
confirmRequired: 0,
|
||||
picId: 'pic-id',
|
||||
imgWidth: 640,
|
||||
imgHeight: 360,
|
||||
});
|
||||
|
||||
const [url, method, rawBody, headers, isJsonRet, isArgJson] = request.mock.calls[0]!;
|
||||
expect(url).toBe(
|
||||
`https://web.qun.qq.com/cgi-bin/announce/add_qun_notice?bkn=${calculateBkn('URL_KEY')}`,
|
||||
);
|
||||
expect(method).toBe('POST');
|
||||
const body = new URLSearchParams(rawBody as string);
|
||||
expect(Object.fromEntries(body)).toMatchObject({
|
||||
qid: '12345',
|
||||
bkn: calculateBkn('BODY_KEY'),
|
||||
text: 'regular notice',
|
||||
pinned: '1',
|
||||
type: '1',
|
||||
pic: 'pic-id',
|
||||
imgWidth: '640',
|
||||
imgHeight: '360',
|
||||
});
|
||||
expect(JSON.parse(body.get('settings')!)).toEqual({
|
||||
is_show_edit_card: 0,
|
||||
tip_window_type: 0,
|
||||
confirm_required: 0,
|
||||
});
|
||||
expect(headers).toMatchObject({ 'Content-Type': 'application/x-www-form-urlencoded' });
|
||||
expect(isJsonRet).toBe(true);
|
||||
expect(isArgJson).toBe(false);
|
||||
});
|
||||
|
||||
it('routes semantic new-member announcements to the instruction endpoint with type 20', async () => {
|
||||
const request = vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({ ec: 0 } as never);
|
||||
|
||||
await setGroupNoticeWebAPI(cookies, '12345', 'welcome', { sendToNewMembers: true });
|
||||
|
||||
const [url, , rawBody] = request.mock.calls[0]!;
|
||||
expect(url).toContain('/cgi-bin/announce/add_qun_instruction?');
|
||||
expect(new URLSearchParams(rawBody as string).get('type')).toBe(
|
||||
String(GROUP_NOTICE_TYPE_NEW_MEMBERS),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps raw type=20 compatibility while selecting the correct endpoint', async () => {
|
||||
const request = vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({ ec: 0 } as never);
|
||||
|
||||
await setGroupNoticeWebAPI(cookies, '12345', 'welcome', { type: 20 });
|
||||
|
||||
expect(request.mock.calls[0]![0]).toContain('/cgi-bin/announce/add_qun_instruction?');
|
||||
});
|
||||
|
||||
it('fails before I/O when raw type and semantic target conflict', async () => {
|
||||
const request = vi.spyOn(RequestUtil, 'HttpGetJson');
|
||||
|
||||
await expect(
|
||||
setGroupNoticeWebAPI(cookies, '12345', 'bad', {
|
||||
type: 1,
|
||||
sendToNewMembers: true,
|
||||
}),
|
||||
).rejects.toThrow('conflicts');
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects unsupported raw types and non-binary options', () => {
|
||||
expect(() => resolveGroupNoticeOptions({ type: 6 })).toThrow('type must be 1');
|
||||
expect(() => resolveGroupNoticeOptions({ pinned: 2 })).toThrow('pinned must be 0 or 1');
|
||||
expect(() => resolveGroupNoticeOptions({ tipWindowType: -1 })).toThrow(
|
||||
'tipWindowType must be 0 or 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the established regular-announcement defaults', () => {
|
||||
expect(resolveGroupNoticeOptions()).toMatchObject({
|
||||
pinned: 0,
|
||||
type: 1,
|
||||
sendToNewMembers: false,
|
||||
isShowEditCard: 1,
|
||||
tipWindowType: 1,
|
||||
confirmRequired: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('propagates transport failures instead of returning undefined', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetJson').mockRejectedValue(
|
||||
new Error('Unexpected status code: 403'),
|
||||
);
|
||||
await expect(setGroupNoticeWebAPI(cookies, '12345', 'notice')).rejects.toThrow('403');
|
||||
});
|
||||
});
|
||||
|
||||
describe('group-notice / image download url', () => {
|
||||
it('builds the public CDN url from a picture id', () => {
|
||||
expect(groupNoticeImageUrl('pic-1')).toBe('https://gdynamic.qpic.cn/gdynamic/pic-1/0');
|
||||
});
|
||||
|
||||
it('rejects empty or path-like ids', () => {
|
||||
expect(() => groupNoticeImageUrl('')).toThrow('empty');
|
||||
expect(() => groupNoticeImageUrl(' ')).toThrow('empty');
|
||||
expect(() => groupNoticeImageUrl('a/b')).toThrow('malformed');
|
||||
expect(() => groupNoticeImageUrl('a?x=1')).toThrow('malformed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('group-notice / image upload response', () => {
|
||||
it('parses HTML-escaped image metadata', () => {
|
||||
expect(
|
||||
parseGroupNoticeImageUploadResponse(
|
||||
JSON.stringify({
|
||||
ec: 0,
|
||||
id: '{"id":"pic-1","w":"640","h":"360"}',
|
||||
}),
|
||||
),
|
||||
).toEqual({ id: 'pic-1', width: 640, height: 360 });
|
||||
});
|
||||
|
||||
it('surfaces server and malformed-response failures', () => {
|
||||
expect(() =>
|
||||
parseGroupNoticeImageUploadResponse(JSON.stringify({ ec: 14, em: 'denied' })),
|
||||
).toThrow('ec=14 em=denied');
|
||||
expect(() => parseGroupNoticeImageUploadResponse('not json')).toThrow('invalid JSON');
|
||||
expect(() =>
|
||||
parseGroupNoticeImageUploadResponse(
|
||||
JSON.stringify({ ec: 0, id: '{"id":"pic","w":0,"h":10}' }),
|
||||
),
|
||||
).toThrow('malformed image metadata');
|
||||
});
|
||||
});
|
||||
|
||||
describe('group-notice / list HTTP layer', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('requests both regular and new-member collections and preserves server errors', async () => {
|
||||
const response: WebApiGroupNoticeRet = { ec: 14, em: 'permission denied' };
|
||||
const request = vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue(response as never);
|
||||
|
||||
await expect(getGroupNoticeWebAPI({ skey: 'SK', p_skey: 'PSK' }, '9876')).resolves.toBe(
|
||||
response,
|
||||
);
|
||||
|
||||
const [url, method, rawBody] = request.mock.calls[0]!;
|
||||
expect(url).toContain('/cgi-bin/announce/list_announce?');
|
||||
expect(method).toBe('POST');
|
||||
const body = new URLSearchParams(rawBody as string);
|
||||
expect(body.get('i')).toBe('1');
|
||||
expect(body.get('ni')).toBe('1');
|
||||
});
|
||||
|
||||
it('propagates transport failures instead of hiding them as an empty list', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetJson').mockRejectedValue(new Error('socket closed'));
|
||||
await expect(getGroupNoticeWebAPI({ skey: 'SK' }, '9876')).rejects.toThrow('socket closed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
getDaySignedListWebAPI,
|
||||
mapDaySignedList,
|
||||
type DaySignedInfo,
|
||||
type DaySignedListResponse,
|
||||
} from '@snowluma/protocol/web/group-signin';
|
||||
import { RequestUtil } from '@snowluma/protocol/web/request-util';
|
||||
|
||||
// The signed-in list comes back from qun.qq.com's GetDaySignedList trpc
|
||||
// endpoint nested under response.page[0].infos. We pin the field renames
|
||||
// and the rank de-skewing formula NapCat uses ((signInRank - 1) / 2 + 1),
|
||||
// because that transform is the only real logic in this otherwise-thin
|
||||
// HTTP port and a silent off-by-one would be invisible end-to-end.
|
||||
|
||||
function resp(infos: DaySignedInfo[]): DaySignedListResponse {
|
||||
return {
|
||||
retCode: 0,
|
||||
response: { page: [{ infos, offset: 0, total: infos.length }] },
|
||||
funcCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe('group-signin / mapDaySignedList', () => {
|
||||
it('renames fields and de-skews signInRank', () => {
|
||||
const out = mapDaySignedList(
|
||||
resp([
|
||||
{
|
||||
uid: '10001',
|
||||
uidGroupNick: 'Alice',
|
||||
signedTimeStamp: '1700000000',
|
||||
signInRank: 1,
|
||||
},
|
||||
{ uid: '10002', uidGroupNick: 'Bob', signedTimeStamp: '1700000050', signInRank: 3 },
|
||||
{
|
||||
uid: '10003',
|
||||
uidGroupNick: 'Carol',
|
||||
signedTimeStamp: '1700000100',
|
||||
signInRank: 5,
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{ user_id: 10001, nick: 'Alice', time: 1700000000, rank: 1 },
|
||||
{ user_id: 10002, nick: 'Bob', time: 1700000050, rank: 2 },
|
||||
{ user_id: 10003, nick: 'Carol', time: 1700000100, rank: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns [] when infos is empty', () => {
|
||||
expect(mapDaySignedList(resp([]))).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] when page is missing entirely', () => {
|
||||
expect(mapDaySignedList({ retCode: 0, response: {}, funcCode: 0 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('group-signin / getDaySignedListWebAPI (HTTP layer)', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const cookies = { p_skey: 'PSK', skey: 'SK', uin: 'o10000', p_uin: 'o10000' };
|
||||
// g_tk = djb2-31bit over p_skey 'PSK'
|
||||
const expectedGtk = (() => {
|
||||
let h = 5381;
|
||||
|
||||
for (const c of 'PSK') {
|
||||
h += (h << 5) + c.charCodeAt(0);
|
||||
}
|
||||
|
||||
return (h & 0x7fffffff).toString();
|
||||
})();
|
||||
|
||||
it('POSTs the exact url + json body and maps the result', async () => {
|
||||
const spy = vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({
|
||||
retCode: 0,
|
||||
funcCode: 0,
|
||||
response: {
|
||||
page: [
|
||||
{
|
||||
infos: [
|
||||
{
|
||||
uid: '777',
|
||||
uidGroupNick: 'Z',
|
||||
signedTimeStamp: '1700000000',
|
||||
signInRank: 3,
|
||||
},
|
||||
],
|
||||
offset: 0,
|
||||
total: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies DaySignedListResponse as never);
|
||||
|
||||
const out = await getDaySignedListWebAPI(cookies, '54321', '10000', new Date(2026, 0, 2));
|
||||
|
||||
expect(out).toEqual([{ user_id: 777, nick: 'Z', time: 1700000000, rank: 2 }]);
|
||||
const [url, method, body, headers, isJsonRet, isArgJson] = spy.mock.calls[0]!;
|
||||
expect(url).toBe(`https://qun.qq.com/v2/signin/trpc/GetDaySignedList?g_tk=${expectedGtk}`);
|
||||
expect(method).toBe('POST');
|
||||
expect(body).toEqual({
|
||||
dayYmd: '20260102',
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
uid: '10000',
|
||||
groupId: '54321',
|
||||
});
|
||||
expect((headers as Record<string, string>)['Content-Type']).toBe('application/json');
|
||||
expect((headers as Record<string, string>).Cookie).toContain('p_skey=PSK');
|
||||
expect(isJsonRet).toBe(true);
|
||||
expect(isArgJson).toBe(true);
|
||||
});
|
||||
|
||||
it('propagates a transport error instead of swallowing it to []', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetJson').mockRejectedValue(
|
||||
new Error('Unexpected status code: 403'),
|
||||
);
|
||||
await expect(getDaySignedListWebAPI(cookies, '1', '1')).rejects.toThrow('403');
|
||||
});
|
||||
|
||||
it('throws when the response has no page (auth/cookie failure), not []', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({
|
||||
retCode: 0,
|
||||
funcCode: 0,
|
||||
response: {},
|
||||
} satisfies DaySignedListResponse as never);
|
||||
await expect(getDaySignedListWebAPI(cookies, '1', '1')).rejects.toThrow(
|
||||
'无法获取该群组打卡列表',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getQzoneMsgDetail } from '@snowluma/protocol/web/qzone';
|
||||
import { RequestUtil, getBknFromCookie } from '@snowluma/protocol/web/request-util';
|
||||
|
||||
const cookies = { p_skey: 'PSK', skey: 'SK', uin: 'o10000' };
|
||||
// Synthetic data using Qzone-API's documented commentlist / list_3 wire fields.
|
||||
const reply = { tid: 1, uin: 10000, name: 'Bot', create_time: 1700000002, content: 'Thanks!' };
|
||||
const comment = {
|
||||
tid: 0,
|
||||
uin: '20002',
|
||||
name: 'Reader',
|
||||
create_time: '1700000001',
|
||||
content: 'Hello\n[em]e100[/em]',
|
||||
list_3: [reply],
|
||||
};
|
||||
const detail = { code: 0, tid: 'T1', content: 'Post', cmtnum: 12, commentlist: [comment] };
|
||||
|
||||
describe('qzone / getQzoneMsgDetail', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('reads a comment page through the authenticated detail CGI and maps replies', async () => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue(`_preloadCallback(${JSON.stringify(detail)});`);
|
||||
const result = await getQzoneMsgDetail(cookies, '30003', 'T1', 5, 10);
|
||||
expect(result).toEqual({
|
||||
tid: 'T1',
|
||||
content: 'Post',
|
||||
comment_num: 12,
|
||||
comments: [
|
||||
{
|
||||
comment_id: '0',
|
||||
uin: 20002,
|
||||
name: 'Reader',
|
||||
time: 1700000001,
|
||||
content: 'Hello\n[em]e100[/em]',
|
||||
replies: [
|
||||
{
|
||||
comment_id: '1',
|
||||
parent_comment_id: '0',
|
||||
parent_comment_uin: 20002,
|
||||
uin: 10000,
|
||||
name: 'Bot',
|
||||
time: 1700000002,
|
||||
content: 'Thanks!',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
const [url, method, body, headers] = request.mock.calls[0]!;
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.origin + parsed.pathname).toBe(
|
||||
'https://h5.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msgdetail_v6',
|
||||
);
|
||||
expect(Object.fromEntries(parsed.searchParams)).toMatchObject({
|
||||
uin: '30003',
|
||||
tid: 'T1',
|
||||
pos: '5',
|
||||
num: '10',
|
||||
not_trunc_con: '1',
|
||||
g_tk: getBknFromCookie(cookies),
|
||||
});
|
||||
expect(method).toBe('GET');
|
||||
expect(body).toBe('');
|
||||
expect(headers).toMatchObject({ Cookie: expect.stringContaining('p_skey=PSK') });
|
||||
});
|
||||
|
||||
it.each([[undefined], [null], [[]]])(
|
||||
'accepts an empty comment page (%j)',
|
||||
async (commentlist) => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue(
|
||||
JSON.stringify({ ...detail, content: '', cmtnum: 0, commentlist }),
|
||||
);
|
||||
await expect(getQzoneMsgDetail(cookies, '10000', 'T1')).resolves.toEqual({
|
||||
tid: 'T1',
|
||||
content: '',
|
||||
comment_num: 0,
|
||||
comments: [],
|
||||
});
|
||||
const query = new URL(request.mock.calls[0]![0]).searchParams;
|
||||
expect(query.get('pos')).toBe('0');
|
||||
expect(query.get('num')).toBe('20');
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves the total when a page is empty and normalizes absent replies', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValueOnce(JSON.stringify({ ...detail, commentlist: [] }))
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({ ...detail, commentlist: [{ ...comment, list_3: null }] }),
|
||||
);
|
||||
await expect(getQzoneMsgDetail(cookies, '10000', 'T1', 20)).resolves.toMatchObject({
|
||||
comment_num: 12,
|
||||
comments: [],
|
||||
});
|
||||
const result = await getQzoneMsgDetail(cookies, '10000', 'T1');
|
||||
expect(result.comments[0]!.replies).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([{ code: -3000, subcode: 0 }, { code: 0, subcode: -4001 }, { subcode: -4001 }])(
|
||||
'propagates remote failure %j',
|
||||
async (codes) => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(
|
||||
JSON.stringify({ ...codes, message: 'denied' }),
|
||||
);
|
||||
await expect(getQzoneMsgDetail(cookies, '10000', 'T1')).rejects.toThrow(
|
||||
/qzone msg detail failed: .*denied/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{},
|
||||
{ ...detail, tid: undefined },
|
||||
{ ...detail, tid: 'OTHER' },
|
||||
{ ...detail, code: '0' },
|
||||
{ ...detail, content: {} },
|
||||
{ ...detail, cmtnum: -1 },
|
||||
{ ...detail, commentlist: {} },
|
||||
{ ...detail, commentlist: [null] },
|
||||
{ ...detail, commentlist: [{ ...comment, uin: 'bad' }] },
|
||||
{ ...detail, commentlist: [{ ...comment, tid: {} }] },
|
||||
{ ...detail, commentlist: [{ ...comment, create_time: 'yesterday' }] },
|
||||
{ ...detail, commentlist: [{ ...comment, list_3: {} }] },
|
||||
{ ...detail, commentlist: [{ ...comment, list_3: [{ ...reply, content: {} }] }] },
|
||||
])('rejects malformed data instead of returning an empty success (%#)', async (data) => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(JSON.stringify(data));
|
||||
await expect(getQzoneMsgDetail(cookies, '10000', 'T1')).rejects.toThrow(/qzone msg detail/);
|
||||
});
|
||||
|
||||
it('propagates transport and JSONP parse errors', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockRejectedValueOnce(new Error('network down'))
|
||||
.mockResolvedValueOnce('<html>login</html>');
|
||||
await expect(getQzoneMsgDetail(cookies, '10000', 'T1')).rejects.toThrow('network down');
|
||||
await expect(getQzoneMsgDetail(cookies, '10000', 'T1')).rejects.toThrow(
|
||||
'invalid response from qzone api',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getQzoneFeedDetail, getQzoneShareArk } from '../../src/web/qzone-public';
|
||||
import { RequestUtil, getBknFromCookie } from '../../src/web/request-util';
|
||||
|
||||
const cookies = { p_skey: 'fixture', uin: 'o20002' };
|
||||
// Synthetic post: author 10001, Unix time 1700000000, sequence word 1.
|
||||
const tid = '1127000000f1536501000000';
|
||||
const ugcId = 'CLcCEJFOGIDiz6oGIgAqADIBMEIYMTEyNzAwMDAwMGYxNTM2NTAxMDAwMDAw';
|
||||
const feed = {
|
||||
cell_common: { domain_type: 1, union_id: { feed_id: tid, ugc_id: ugcId }, operate_mask: 0 },
|
||||
cell_summary: { summary: 'Fixture post' },
|
||||
cell_pic: { pics: [{ url: 'https://example.com/one.png' }, { future_image: true }] },
|
||||
cell_video: { opaque_video: 'preserved' },
|
||||
cell_comment: { comments: [{ id: '9007199254740993', unknown: true }] },
|
||||
cell_operation: {
|
||||
share_outside: {
|
||||
share_access_resource: { busi_id: 'qzone_feed', resource_id: 'resource' },
|
||||
},
|
||||
},
|
||||
cell_feed_transfer: 'original-transfer',
|
||||
cell_future: { nested: [null, '18446744073709551615', { preserved: true }] },
|
||||
};
|
||||
const detail = {
|
||||
feed: { feed },
|
||||
attach_info: 'opaque-comment-continuation',
|
||||
contribute_bar: null,
|
||||
future_detail_field: { preserved: true },
|
||||
};
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('Qzone feed detail by tid', () => {
|
||||
it.each([tid, tid.toUpperCase()])(
|
||||
'requests the personal-space detail with the encoded identity (%s)',
|
||||
async (input) => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetJson')
|
||||
.mockResolvedValue({ retcode: 0, data: detail });
|
||||
const result = await getQzoneFeedDetail(cookies, input);
|
||||
|
||||
expect(result).toEqual({ ...detail, feed });
|
||||
expect(result.feed).toBe(feed);
|
||||
expect(request).toHaveBeenCalledExactlyOnceWith(
|
||||
`https://h5.qzone.qq.com/http2rpc/gotrpc/v1/trpc.qzone.feeds_reader.FeedsReader/GetFeedDetail?g_tk=${getBknFromCookie(cookies)}`,
|
||||
'POST',
|
||||
{
|
||||
union_id: { feed_id: tid, ugc_id: ugcId, adv_id: null },
|
||||
feed_action_domain_type: 1,
|
||||
from_page: 1,
|
||||
},
|
||||
expect.objectContaining({
|
||||
Cookie: 'p_skey=fixture; uin=o20002',
|
||||
'Content-Type': 'application/json',
|
||||
'x-oidb': JSON.stringify({
|
||||
uint32_command: '0x97b1',
|
||||
uint32_service_type: '1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the author and time unsigned and emits the explicit empty identity fields', async () => {
|
||||
const highTid = 'ffffffffffffffff01000000';
|
||||
const request = vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({
|
||||
retcode: 0,
|
||||
data: { feed: { feed: { cell_common: { union_id: { feed_id: highTid } } } } },
|
||||
});
|
||||
|
||||
await getQzoneFeedDetail(cookies, highTid);
|
||||
const body = request.mock.calls[0]![2] as { union_id: { ugc_id: string } };
|
||||
// Independent wire vector: app 311; two uint32-max values encoded as
|
||||
// positive int64; present empty strings in fields 4 and 5; field 8 tid.
|
||||
expect(Buffer.from(body.union_id.ugc_id, 'base64').toString('hex')).toBe(
|
||||
'08b70210ffffffff0f18ffffffff0f22002a003201304218' +
|
||||
'666666666666666666666666666666663031303030303030',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([undefined, null, ''])(
|
||||
'preserves an empty or absent upstream comment cursor (%s)',
|
||||
async (attach_info) => {
|
||||
const request = vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({
|
||||
retcode: 0,
|
||||
data: { ...detail, attach_info },
|
||||
});
|
||||
|
||||
expect((await getQzoneFeedDetail(cookies, tid)).attach_info).toBe(attach_info ?? null);
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['', 'T1', 'g'.repeat(24), 'a'.repeat(23), 'a'.repeat(25), `${tid}\n`, ` ${tid}`])(
|
||||
'rejects unsupported or malformed post IDs before requesting (%j)',
|
||||
async (input) => {
|
||||
const request = vi.spyOn(RequestUtil, 'HttpGetJson');
|
||||
|
||||
await expect(getQzoneFeedDetail(cookies, input)).rejects.toThrow('24 位十六进制');
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{},
|
||||
{ feed: null },
|
||||
{ feed: [] },
|
||||
{ feed: { feed: [] } },
|
||||
{ feed: { feed: {} } },
|
||||
{ feed: { feed: { cell_common: { union_id: { feed_id: 'other' } } } } },
|
||||
{ ...detail, attach_info: {} },
|
||||
])('rejects incomplete, mismatched or malformed details (%#)', async (data) => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetJson')
|
||||
.mockResolvedValue({ retcode: 0, data });
|
||||
|
||||
await expect(getQzoneFeedDetail(cookies, tid)).rejects.toThrow('GetFeedDetail');
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('propagates access and transport failures without a second request', async () => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetJson')
|
||||
.mockResolvedValueOnce({ retcode: 10023, message: '无权限访问' })
|
||||
.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
await expect(getQzoneFeedDetail(cookies, tid)).rejects.toThrow('10023 无权限访问');
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
await expect(getQzoneFeedDetail(cookies, tid)).rejects.toThrow('network down');
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('passes the returned feed straight to card generation without rebuilding sharing data', async () => {
|
||||
const ark = '{ "app": "com.tencent.tuwen.lua", "fixture": true }';
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetJson')
|
||||
.mockResolvedValueOnce({ retcode: 0, data: detail })
|
||||
.mockResolvedValueOnce({ retcode: 0, data: { template_data: ark } });
|
||||
const result = await getQzoneFeedDetail(cookies, tid);
|
||||
|
||||
await expect(getQzoneShareArk(cookies, result.feed)).resolves.toEqual({
|
||||
ark,
|
||||
requires_confirmation: false,
|
||||
notice: null,
|
||||
});
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining('/trpc.qq.share.Service/CreateShare?g_tk='),
|
||||
'POST',
|
||||
{
|
||||
info: {
|
||||
busi_id: 'qzone_feed',
|
||||
resource_id: 'resource',
|
||||
template: 'ark_busi_select',
|
||||
busi_data: 'original-transfer',
|
||||
plat: 1,
|
||||
target: 3,
|
||||
},
|
||||
},
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('can read a share-restricted feed while preserving the share refusal', async () => {
|
||||
const restrictedFeed = {
|
||||
...feed,
|
||||
cell_common: { ...feed.cell_common, operate_mask: 1 << 17 },
|
||||
};
|
||||
const request = vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({
|
||||
retcode: 0,
|
||||
data: { feed: { feed: restrictedFeed } },
|
||||
});
|
||||
const result = await getQzoneFeedDetail(cookies, tid);
|
||||
|
||||
expect(result.feed).toBe(restrictedFeed);
|
||||
await expect(getQzoneShareArk(cookies, result.feed)).rejects.toThrow('不支持分享');
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { mapFeeds, parseQzoneCallback } from '@snowluma/protocol/web/qzone';
|
||||
|
||||
// Real feeds3_html_more response captured live (issue #182). Its `data` value
|
||||
// is a JS object literal (unquoted keys, single-quoted strings, \xNN escapes,
|
||||
// trailing `undefined`) — JSON.parse throws on it; parseQzoneCallback must not.
|
||||
const SAMPLE = readFileSync(
|
||||
fileURLToPath(new URL('./fixtures/qzone-feeds-sample.jsonp', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
interface RawFeeds {
|
||||
code?: number;
|
||||
data?: { data?: unknown[]; hasmore?: number | string };
|
||||
}
|
||||
|
||||
describe('qzone feeds JS-literal parsing (issue #182)', () => {
|
||||
it('JSON.parse chokes on the real body (regression baseline)', () => {
|
||||
const inner = SAMPLE.slice(SAMPLE.indexOf('{'), SAMPLE.lastIndexOf('}') + 1);
|
||||
expect(() => JSON.parse(inner)).toThrow();
|
||||
});
|
||||
|
||||
it('parseQzoneCallback evaluates the JS-literal payload', () => {
|
||||
const data = parseQzoneCallback<RawFeeds>(SAMPLE);
|
||||
expect(data.code).toBe(0);
|
||||
expect(Array.isArray(data.data?.data)).toBe(true);
|
||||
// The array carries a trailing `undefined` hole alongside the real feed.
|
||||
expect(data.data!.data!.length).toBe(2);
|
||||
expect(data.data!.data!.some((x) => x === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('mapFeeds drops the undefined hole and normalises the feed', () => {
|
||||
const data = parseQzoneCallback(SAMPLE);
|
||||
const result = mapFeeds(data as any);
|
||||
expect(result.feeds).toHaveLength(1);
|
||||
const f = result.feeds[0]!;
|
||||
expect(f.uin).toBe(20050606);
|
||||
expect(f.nickname).toBe('官方Qzone');
|
||||
expect(f.appid).toBe(311);
|
||||
expect(f.time).toBe(1780711209);
|
||||
expect(f.key).toBe('aef23101297f236ae4f10600');
|
||||
// \xNN escapes decoded by the JS eval, html passed through verbatim.
|
||||
expect(f.html).toContain('<li class="f-single f-s-s"');
|
||||
expect(f.html).toContain('官方Qzone');
|
||||
expect(f.tid).toBe(f.key);
|
||||
expect(f.content).toContain('QQ空间');
|
||||
expect(f.content).not.toContain('展开全文');
|
||||
expect(f.content_complete).toBe(false);
|
||||
expect(f.images).toEqual([]);
|
||||
expect(f.like_count).toBe(3173513);
|
||||
expect(f.comment_count).toBeNull();
|
||||
expect(result.has_more).toBe(false);
|
||||
});
|
||||
|
||||
it('parses as inert data — never resolves identifiers or executes code', () => {
|
||||
// The parser has no notion of globals, member access, or calls: any such
|
||||
// token is rejected, so a tampered body cannot reach `process`/`Function`
|
||||
// etc. (the whole reason we do NOT eval this payload).
|
||||
expect(() => parseQzoneCallback('_preloadCallback({x: process})')).toThrow();
|
||||
expect(() => parseQzoneCallback('_preloadCallback({x: callback.constructor})')).toThrow();
|
||||
expect(() =>
|
||||
parseQzoneCallback('_preloadCallback({x: (function(){return 1})()})'),
|
||||
).toThrow();
|
||||
expect(() => parseQzoneCallback('_preloadCallback(eval("1+1"))')).toThrow();
|
||||
});
|
||||
|
||||
it('does not pollute Object.prototype via a __proto__ key', () => {
|
||||
parseQzoneCallback('_preloadCallback({"__proto__":{"polluted":1},"code":0})');
|
||||
expect(({} as any).polluted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('decodes JS string escapes (\\xNN, \\/) literally, not as markup', () => {
|
||||
const d = parseQzoneCallback<{ s?: string }>("_preloadCallback({s:'\\x3Cb\\x3E&\\/x'})");
|
||||
expect(d.s).toBe('<b>&/x');
|
||||
});
|
||||
|
||||
it('rejects a body that captures no object', () => {
|
||||
expect(() => parseQzoneCallback('_preloadCallback(42)')).toThrow('invalid feeds response');
|
||||
expect(() => parseQzoneCallback('void 0')).toThrow('invalid feeds response');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getQzoneFeeds } from '../../src/web/qzone';
|
||||
import { RequestUtil } from '../../src/web/request-util';
|
||||
import { parseQzoneFeedContent } from '../../src/web/qzone-feed-content';
|
||||
|
||||
const cookies = { p_skey: 'fixture' };
|
||||
|
||||
function page(key: string, more: boolean, state: Record<string, unknown> = {}) {
|
||||
return `_preloadCallback(${JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
main: {
|
||||
hasMoreFeeds: more,
|
||||
error: '',
|
||||
begintime: '1700000000',
|
||||
dayspac: '5',
|
||||
pagenum: '2',
|
||||
externparam: 'basetime=1700000000&pagenum=2&opaque=a%26b',
|
||||
...state,
|
||||
},
|
||||
data: [
|
||||
{
|
||||
uin: '10002',
|
||||
nickname: 'Reader',
|
||||
abstime: '1700000001',
|
||||
appid: '311',
|
||||
key,
|
||||
html: `<li class="f-single"><div class="f-item" data-key="${key}">
|
||||
<div class="f-info">Hello & <a>friend</a><br>Second line<img alt="[笑]" src="https://example.com/face.gif"></div>
|
||||
<i name="feed_data" data-uin="10002" data-fkey="${key}" data-tid="${key}"></i>
|
||||
<div class="qz_summary"><a class="img-item" data-originurl="https://example.com/full.jpg?a=1&b=2"><img src="https://example.com/thumb.jpg"></a></div>
|
||||
</div><div class="f-single-foot"><a class="qz_like_btn_v3" data-likecnt="12">赞</a>
|
||||
<div class="mod-comments"><div class="f-info">Another person's comment</div><img src="https://example.com/comment.jpg"></div>
|
||||
</div></li>`,
|
||||
commentcnt: '3',
|
||||
},
|
||||
null,
|
||||
],
|
||||
},
|
||||
})});`;
|
||||
}
|
||||
|
||||
describe('Qzone friend feed continuation', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('uses server continuation state and preserves structured content alongside legacy fields', async () => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValueOnce(
|
||||
page('first', true, {
|
||||
aisortEndTime: '17',
|
||||
aisortOffset: '4',
|
||||
aisortBeginTime: '11',
|
||||
aisortNextTime: '2',
|
||||
icServerTime: '1700000010',
|
||||
daylist: 'd',
|
||||
uinlist: '10002',
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(page('second', false));
|
||||
const first = await getQzoneFeeds(cookies, '10001');
|
||||
expect(first.has_more).toBe(true);
|
||||
expect(first.next_cursor).toEqual(expect.any(String));
|
||||
expect(first.feeds[0]).toMatchObject({
|
||||
uin: 10002,
|
||||
appid: 311,
|
||||
key: 'first',
|
||||
tid: 'first',
|
||||
content: 'Hello & friend\nSecond line[笑]',
|
||||
content_complete: true,
|
||||
images: ['https://example.com/full.jpg?a=1&b=2'],
|
||||
like_count: 12,
|
||||
comment_count: 3,
|
||||
html: expect.stringContaining('Another person'),
|
||||
});
|
||||
const second = await getQzoneFeeds(cookies, '10001', 1, 10, first.next_cursor!);
|
||||
expect(second).toMatchObject({ has_more: false, next_cursor: null });
|
||||
expect(second.feeds[0].key).toBe('second');
|
||||
const query = new URL(request.mock.calls[1][0]).searchParams;
|
||||
expect(Object.fromEntries(query)).toMatchObject({
|
||||
uin: '10001',
|
||||
pagenum: '2',
|
||||
begintime: '1700000000',
|
||||
dayspac: '5',
|
||||
externparam: 'basetime=1700000000&pagenum=2&opaque=a%26b',
|
||||
aisortEndTime: '17',
|
||||
aisortOffset: '4',
|
||||
aisortBeginTime: '11',
|
||||
getAisort: '2',
|
||||
icServerTime: '1700000010',
|
||||
daylist: 'd',
|
||||
uinlist: '10002',
|
||||
refresh: '0',
|
||||
});
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('resolves a legacy page number by traversing continuations, not restarting at that number', async () => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValueOnce(page('first', true))
|
||||
.mockResolvedValueOnce(page('second', false));
|
||||
const result = await getQzoneFeeds(cookies, '10001', 2, 20);
|
||||
expect(result.feeds[0].key).toBe('second');
|
||||
expect(request.mock.calls.map(([url]) => new URL(url).searchParams.get('pagenum'))).toEqual(
|
||||
['1', '2'],
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects stalled continuation instead of looping or silently ending the list', async () => {
|
||||
const request = vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(page('same', true));
|
||||
await expect(getQzoneFeeds(cookies, '10001', 3)).rejects.toThrow(/未推进|advance/);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps empty intermediate pages traversable and stops at the real end', async () => {
|
||||
const empty = JSON.parse(page('unused', true).slice(17, -2));
|
||||
empty.data.data = [null];
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValueOnce(JSON.stringify(empty))
|
||||
.mockResolvedValueOnce(page('last', false));
|
||||
const first = await getQzoneFeeds(cookies, '10001');
|
||||
expect(first.feeds).toEqual([]);
|
||||
expect(first.has_more).toBe(true);
|
||||
expect(
|
||||
(await getQzoneFeeds(cookies, '10001', 1, 10, first.next_cursor!)).feeds[0].key,
|
||||
).toBe('last');
|
||||
request.mockReset().mockResolvedValue(page('last', false));
|
||||
expect(await getQzoneFeeds(cookies, '10001', 8)).toEqual({
|
||||
feeds: [],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
});
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects foreign-account, corrupt and mismatched-page cursors before HTTP', async () => {
|
||||
const request = vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(page('first', true));
|
||||
const first = await getQzoneFeeds(cookies, '10001');
|
||||
request.mockClear();
|
||||
await expect(getQzoneFeeds(cookies, '10002', 1, 10, first.next_cursor!)).rejects.toThrow(
|
||||
'账号',
|
||||
);
|
||||
await expect(getQzoneFeeds(cookies, '10001', 3, 10, first.next_cursor!)).rejects.toThrow(
|
||||
'不匹配',
|
||||
);
|
||||
|
||||
for (const cursor of ['', 'bad!', 'e30', 'a'.repeat(65537)]) {
|
||||
await expect(getQzoneFeeds(cookies, '10001', 1, 10, cursor)).rejects.toThrow('cursor');
|
||||
}
|
||||
|
||||
const raw = JSON.parse(Buffer.from(first.next_cursor!, 'base64url').toString());
|
||||
raw.state.url = 'https://example.com/foreign';
|
||||
await expect(
|
||||
getQzoneFeeds(
|
||||
cookies,
|
||||
'10001',
|
||||
1,
|
||||
10,
|
||||
Buffer.from(JSON.stringify(raw)).toString('base64url'),
|
||||
),
|
||||
).rejects.toThrow('cursor');
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves opaque parameters and sort state when a later page omits optional fields', async () => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValueOnce(page('first', true, { aisortOffset: '9', aisortNextTime: '2' }))
|
||||
.mockResolvedValueOnce(
|
||||
page('second', true, {
|
||||
pagenum: '3',
|
||||
begintime: '1699999000',
|
||||
externparam: '',
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(page('third', false));
|
||||
const first = await getQzoneFeeds(cookies, '10001');
|
||||
const second = await getQzoneFeeds(cookies, '10001', 1, 10, first.next_cursor!);
|
||||
await getQzoneFeeds(cookies, '10001', 1, 10, second.next_cursor!);
|
||||
const query = new URL(request.mock.calls[2][0]).searchParams;
|
||||
expect(query.get('aisortOffset')).toBe('9');
|
||||
expect(query.get('getAisort')).toBe('2');
|
||||
expect(query.get('externparam')).toBe('basetime=1700000000&pagenum=2&opaque=a%26b');
|
||||
});
|
||||
|
||||
it('does not regard the changing server clock as pagination progress', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValueOnce(page('first', true, { icServerTime: '20' }))
|
||||
.mockResolvedValueOnce(page('first', true, { icServerTime: '21' }));
|
||||
await expect(getQzoneFeeds(cookies, '10001', 3)).rejects.toThrow('未推进');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ code: 0, subcode: -1, data: { data: [] } },
|
||||
{ code: 0, data: { main: { error: 'more', hasMoreFeeds: false }, data: [] } },
|
||||
{ code: 0, data: { main: { hasMoreFeeds: 'false' }, data: [] } },
|
||||
{ code: 0, data: { main: { hasMoreFeeds: true }, data: [] } },
|
||||
{ code: 0, data: { main: { hasMoreFeeds: true, pagenum: {} }, data: [] } },
|
||||
{ code: 0, data: { hasmore: 1, data: [] } },
|
||||
{ code: 0, data: { main: [], data: [] } },
|
||||
{ code: 0, data: { main: { hasMoreFeeds: false }, data: [false] } },
|
||||
{ code: 0, data: { main: { hasMoreFeeds: false }, data: [{ uin: 'broken' }] } },
|
||||
])(
|
||||
'exposes malformed or failed responses instead of returning an empty success (%j)',
|
||||
async (response) => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(JSON.stringify(response));
|
||||
await expect(getQzoneFeeds(cookies, '10001')).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it('propagates transport failures without retrying or resetting pagination', async () => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockRejectedValue(new Error('network unavailable'));
|
||||
await expect(getQzoneFeeds(cookies, '10001')).rejects.toThrow('network unavailable');
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Qzone friend feed content', () => {
|
||||
function parse(body: string, like?: unknown, comments?: unknown) {
|
||||
return parseQzoneFeedContent(
|
||||
`<div class="f-item" data-key="post">${body}</div>`,
|
||||
'post',
|
||||
10002,
|
||||
311,
|
||||
like,
|
||||
comments,
|
||||
);
|
||||
}
|
||||
|
||||
it('marks excerpts as incomplete, removes expansion controls and prefers the full body when present', () => {
|
||||
const excerpt =
|
||||
'<div class="f-info qz_info_cut">Start<a data-cmd="qz_toggle">展开全文</a></div>';
|
||||
expect(parse(excerpt + '<div class="f-info qz_info_complete none"></div>')).toMatchObject({
|
||||
content: 'Start',
|
||||
content_complete: false,
|
||||
});
|
||||
expect(
|
||||
parse(excerpt + '<div class="f-info qz_info_complete none">Start and finish</div>'),
|
||||
).toMatchObject({ content: 'Start and finish', content_complete: true });
|
||||
});
|
||||
|
||||
it('preserves explicit zero, distinguishes unknown values and never estimates counts from visible comments', () => {
|
||||
expect(parse('<div class="f-info"></div>', '0', 0)).toMatchObject({
|
||||
content: '',
|
||||
content_complete: true,
|
||||
images: [],
|
||||
like_count: 0,
|
||||
comment_count: 0,
|
||||
});
|
||||
expect(parse('<div class="mod-comments"><div>one</div></div>', '', 'many')).toMatchObject({
|
||||
content: null,
|
||||
content_complete: null,
|
||||
like_count: null,
|
||||
comment_count: null,
|
||||
});
|
||||
expect(
|
||||
parseQzoneFeedContent('<div>unrecognised template</div>', 'post', 10002, 311),
|
||||
).toMatchObject({ tid: null, content: null, images: null });
|
||||
});
|
||||
|
||||
it('excludes video covers, nested reposts, avatars and comment pictures', () => {
|
||||
expect(
|
||||
parse(`
|
||||
<img src="https://example.com/avatar.jpg">
|
||||
<div class="f-ct-video"><a class="img-item"><img src="https://example.com/video.jpg"></a></div>
|
||||
<a class="img-item" data-v_itemid="video"><img src="https://example.com/video2.jpg"></a>
|
||||
<div class="mod-comments"><a class="img-item"><img src="https://example.com/comment.jpg"></a></div>
|
||||
<div class="f-item-passive"><div class="f-info">repost</div><a class="img-item"><img src="https://example.com/repost.jpg"></a></div>
|
||||
<a class="img-item"><img truesrc="//example.com/photo.jpg?a=1&b=2" src="https://example.com/loading.gif"></a>
|
||||
<a class="img-item"><img src="javascript:bad()"></a>
|
||||
`),
|
||||
).toMatchObject({ content: null, images: ['https://example.com/photo.jpg?a=1&b=2'] });
|
||||
});
|
||||
|
||||
it('parses markup as inert data and decodes entities without including script or style text', () => {
|
||||
expect(
|
||||
parse(
|
||||
'<div class="f-info"><b>😀<script>throw 1</script><style>secret</style><p>Next</p></div>',
|
||||
),
|
||||
).toMatchObject({ content: '<b>😀\nNext' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getQzoneProfile, setQzoneProfile } from '@snowluma/protocol/web/qzone';
|
||||
import { RequestUtil, getBknFromCookie } from '@snowluma/protocol/web/request-util';
|
||||
|
||||
const cookies = { p_skey: 'PSK', skey: 'SK', uin: 'o10000' };
|
||||
|
||||
describe('qzone / getQzoneProfile', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('reads the selected space with the current account credentials and returns only its name and description', async () => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue(
|
||||
'_Callback({"code":0,"data":{"spacename":"我的空间","desc":"生活 & 照片","nickname":"QQ昵称","signature":"private"}});',
|
||||
);
|
||||
|
||||
await expect(getQzoneProfile(cookies, '10000', '20002')).resolves.toEqual({
|
||||
name: '我的空间',
|
||||
description: '生活 & 照片',
|
||||
});
|
||||
|
||||
const [url, method, body, headers] = request.mock.calls[0]!;
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.origin + parsed.pathname).toBe(
|
||||
'https://h5.qzone.qq.com/proxy/domain/base.qzone.qq.com/cgi-bin/user/cgi_userinfo_get_all',
|
||||
);
|
||||
expect(Object.fromEntries(parsed.searchParams)).toMatchObject({
|
||||
uin: '20002',
|
||||
vuin: '10000',
|
||||
fupdate: '1',
|
||||
g_tk: getBknFromCookie(cookies),
|
||||
});
|
||||
expect(method).toBe('GET');
|
||||
expect(body).toBe('');
|
||||
expect(headers).toMatchObject({ Cookie: expect.stringContaining('p_skey=PSK') });
|
||||
});
|
||||
|
||||
it('defaults to the current space and preserves empty fields', async () => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue('{"code":0,"data":{"spacename":"","desc":""}}');
|
||||
|
||||
await expect(getQzoneProfile(cookies, '10000')).resolves.toEqual({
|
||||
name: '',
|
||||
description: '',
|
||||
});
|
||||
expect(new URL(request.mock.calls[0]![0]).searchParams.get('uin')).toBe('10000');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{},
|
||||
{ code: 0 },
|
||||
{ code: 0, data: { spacename: 'Name' } },
|
||||
{ code: 0, data: { spacename: 42, desc: '' } },
|
||||
])('rejects an incomplete profile instead of substituting empty values (%j)', async (data) => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(JSON.stringify(data));
|
||||
await expect(getQzoneProfile(cookies, '10000')).rejects.toThrow(/qzone profile/);
|
||||
});
|
||||
|
||||
it('propagates login, access and transport failures', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValueOnce('{"code":-3000,"message":"need login"}')
|
||||
.mockResolvedValueOnce('{"code":0,"subcode":-4001,"message":"denied"}')
|
||||
.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
await expect(getQzoneProfile(cookies, '10000')).rejects.toThrow('code=-3000');
|
||||
await expect(getQzoneProfile(cookies, '10000')).rejects.toThrow('subcode=-4001');
|
||||
await expect(getQzoneProfile(cookies, '10000')).rejects.toThrow('network down');
|
||||
});
|
||||
});
|
||||
|
||||
describe('qzone / setQzoneProfile', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it.each([
|
||||
{ profile: { name: '新名称' }, fields: { spacename: '新名称' }, bitmap: '2048' },
|
||||
{ profile: { description: '' }, fields: { desc: '' }, bitmap: '4096' },
|
||||
{
|
||||
profile: { name: '名称 & +', description: '第一行\r\n第二行' },
|
||||
fields: { spacename: '名称 & +', desc: '第一行\n第二行' },
|
||||
bitmap: '6144',
|
||||
},
|
||||
])(
|
||||
'updates only the supplied fields with bitmap $bitmap',
|
||||
async ({ profile, fields, bitmap }) => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue('frameElement.callback({"code":0});');
|
||||
|
||||
await expect(setQzoneProfile(cookies, '10000', profile)).resolves.toBeUndefined();
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, method, body, headers] = request.mock.calls[0]!;
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.origin + parsed.pathname).toBe(
|
||||
'https://h5.qzone.qq.com/proxy/domain/w.qzone.qq.com/cgi-bin/user/cgi_apply_updateuserinfo_new',
|
||||
);
|
||||
expect(parsed.searchParams.get('g_tk')).toBe(getBknFromCookie(cookies));
|
||||
expect(method).toBe('POST');
|
||||
expect(Object.fromEntries(new URLSearchParams(body))).toEqual({
|
||||
uin: '10000',
|
||||
pageindex: '3',
|
||||
fupdate: '1',
|
||||
mb: bitmap,
|
||||
...fields,
|
||||
});
|
||||
expect(headers).toMatchObject({
|
||||
Cookie: expect.stringContaining('p_skey=PSK'),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([-3001, -3002, -3000])(
|
||||
'reports upstream rejection %s without retrying',
|
||||
async (code) => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue(JSON.stringify({ code, message: 'verification required' }));
|
||||
|
||||
await expect(setQzoneProfile(cookies, '10000', { name: 'Name' })).rejects.toThrow(
|
||||
`code=${code}`,
|
||||
);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a missing success code, non-zero subcode, HTML and network failures', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValueOnce('{}')
|
||||
.mockResolvedValueOnce('{"code":0,"subcode":-1,"message":"denied"}')
|
||||
.mockResolvedValueOnce('<html>login</html>')
|
||||
.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
await expect(setQzoneProfile(cookies, '10000', { name: 'Name' })).rejects.toThrow(
|
||||
/qzone profile update/,
|
||||
);
|
||||
await expect(setQzoneProfile(cookies, '10000', { name: 'Name' })).rejects.toThrow(
|
||||
'subcode=-1',
|
||||
);
|
||||
await expect(setQzoneProfile(cookies, '10000', { name: 'Name' })).rejects.toThrow(
|
||||
'invalid response from qzone api',
|
||||
);
|
||||
await expect(setQzoneProfile(cookies, '10000', { name: 'Name' })).rejects.toThrow(
|
||||
'network down',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { JsonObject } from '@snowluma/common/json';
|
||||
import * as qzone from '@snowluma/protocol/web/qzone-public';
|
||||
import { RequestUtil, getBknFromCookie } from '@snowluma/protocol/web/request-util';
|
||||
|
||||
const cookies = { p_skey: 'test-pskey', uin: 'o10001' };
|
||||
const union: JsonObject = {
|
||||
uf_key: 'feed-1',
|
||||
ugc_id: '9007199254740999',
|
||||
feed_id: 'feed-id',
|
||||
ext_ids: [{ numeric: '9007199254740999' }],
|
||||
};
|
||||
const feed: JsonObject = {
|
||||
cell_common: { union_id: union, feed_type: 1, domain_type: 3, cur_like_key: 'like-key' },
|
||||
cell_feed_transfer: { opaque: 'transfer' },
|
||||
cell_user_info: { user: { uid: 'author-uid', uin: '20002', nick: '作者' } },
|
||||
cell_unknown: { keep: true },
|
||||
};
|
||||
const user = { uin: '10001', uid: 'self-uid', nick: '我' };
|
||||
const comment = {
|
||||
id: 'comment-1',
|
||||
user: { uin: '20002', uid: 'comment-uid', nick: '' },
|
||||
reply_attach_info: 'reply-first',
|
||||
opaque: true,
|
||||
};
|
||||
const reply = { id: 'reply-1', user: { uin: '30003', uid: 'reply-uid', nick: '' } };
|
||||
const baseParams = { union_id: union, cell_feed_transfer: feed.cell_feed_transfer };
|
||||
|
||||
function mockRequest(data: JsonObject = {}) {
|
||||
return vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({ retcode: 0, data });
|
||||
}
|
||||
|
||||
function body(request: ReturnType<typeof mockRequest>, index = 0): JsonObject {
|
||||
return request.mock.calls[index]![2] as JsonObject;
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('public Qzone reader', () => {
|
||||
it('uses the JSON gateway with the current account cookies and the method oidb headers', async () => {
|
||||
const flags = { public_qzone_entrance_switch: true, can_upvote: true, unknown_flag: false };
|
||||
const request = mockRequest({
|
||||
results: [
|
||||
{ uin: '20002', entrances: {} },
|
||||
{ uin: '10001', entrances: flags },
|
||||
],
|
||||
});
|
||||
await expect(qzone.getQzonePublicCapabilities(cookies, '10001')).resolves.toEqual(flags);
|
||||
const [url, method, , headers] = request.mock.calls[0]!;
|
||||
expect(url).toBe(
|
||||
`https://h5.qzone.qq.com/http2rpc/gotrpc/v1/trpc.public_qzone.entrance_ctrl.EntranceCtrl/BatchEntranceWhitelist?g_tk=${getBknFromCookie(cookies)}`,
|
||||
);
|
||||
expect(method).toBe('POST');
|
||||
expect(body(request)).toEqual({ uins: ['10001'], entrance_types: [1, 15] });
|
||||
expect(headers).toEqual({
|
||||
Cookie: 'p_skey=test-pskey; uin=o10001',
|
||||
'Content-Type': 'application/json',
|
||||
Referer: 'https://h5.qzone.qq.com/pc-qzone/index.html',
|
||||
'x-web-qua': 'V1_HT5_QZ_9.9.32_001_IDC_B',
|
||||
'x-oidb': JSON.stringify({ uint32_command: '0x9741', uint32_service_type: '0' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('continues short and ad-only pages using the full last non-ad union and server has_more', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(12345);
|
||||
const ad = { cell_common: { feed_type: 3, union_id: { uf_key: 'ad' } } };
|
||||
const request = mockRequest()
|
||||
.mockResolvedValueOnce({
|
||||
retcode: 0,
|
||||
data: { feeds: [{ feed }], has_more: true, attach_info: 'page-2', extra: 'keep' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
retcode: 0,
|
||||
data: { feeds: [{ feed: ad }], has_more: true, attach_info: 'page-3' },
|
||||
})
|
||||
.mockResolvedValueOnce({ retcode: 0, data: { feeds: [], has_more: false } });
|
||||
const first = await qzone.getQzonePublicFeeds(cookies, '10001');
|
||||
expect(first).toMatchObject({ feeds: [feed], has_more: true, extra: 'keep' });
|
||||
expect(body(request)).toEqual({ refresh_id: '10001_12345' });
|
||||
const second = await qzone.getQzonePublicFeeds(cookies, '10001', first.next_cursor!);
|
||||
expect(body(request, 1)).toEqual({
|
||||
refresh_id: '10001_12345',
|
||||
attach_info: 'page-2',
|
||||
last_client_feed_union_id: union,
|
||||
});
|
||||
const last = await qzone.getQzonePublicFeeds(cookies, '10001', second.next_cursor!);
|
||||
expect(body(request, 2)).toEqual({
|
||||
refresh_id: '10001_12345',
|
||||
attach_info: 'page-3',
|
||||
last_client_feed_union_id: union,
|
||||
});
|
||||
expect(last).toEqual({ feeds: [], has_more: false, next_cursor: null });
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('preserves unknown feed cells without using them as pagination anchors', async () => {
|
||||
const unknown = { cell_common: { feed_type: 4 }, cell_unknown: { keep: true } };
|
||||
const request = mockRequest({
|
||||
feeds: [{ feed }, { feed: unknown }],
|
||||
has_more: true,
|
||||
attach_info: 'next',
|
||||
});
|
||||
const first = await qzone.getQzonePublicFeeds(cookies, '10001');
|
||||
expect(first.feeds).toEqual([feed, unknown]);
|
||||
request.mockResolvedValueOnce({
|
||||
retcode: 0,
|
||||
data: { feeds: [{ feed: unknown }], has_more: true, attach_info: 'after-unknown' },
|
||||
});
|
||||
const next = await qzone.getQzonePublicFeeds(cookies, '10001', first.next_cursor!);
|
||||
request.mockResolvedValueOnce({ retcode: 0, data: { feeds: [], has_more: false } });
|
||||
await qzone.getQzonePublicFeeds(cookies, '10001', next.next_cursor!);
|
||||
expect(body(request, 2).last_client_feed_union_id).toEqual(union);
|
||||
});
|
||||
|
||||
it('rejects malformed or cross-account cursors before a network request', async () => {
|
||||
const request = mockRequest({ feeds: [{ feed }], has_more: true, attach_info: 'next' });
|
||||
const first = await qzone.getQzonePublicFeeds(cookies, '10001');
|
||||
request.mockClear();
|
||||
|
||||
for (const cursor of [
|
||||
'',
|
||||
'!',
|
||||
'a'.repeat(65537),
|
||||
Buffer.from('{}').toString('base64url'),
|
||||
first.next_cursor!,
|
||||
]) {
|
||||
await expect(qzone.getQzonePublicFeeds(cookies, '20002', cursor)).rejects.toThrow(
|
||||
/cursor/,
|
||||
);
|
||||
}
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ feeds: [], has_more: true },
|
||||
{ feeds: [{ feed: null }], has_more: false },
|
||||
{ feeds: null, has_more: false },
|
||||
{ feeds: [], has_more: 1 },
|
||||
])('reports invalid upstream page data (%j)', async (data) => {
|
||||
mockRequest(data);
|
||||
await expect(qzone.getQzonePublicFeeds(cookies, '10001')).rejects.toThrow(
|
||||
/GetPublicRecomFeeds/,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves detail/comment/reply result fields and forwards their separate pagination state', async () => {
|
||||
const detail = {
|
||||
feed: { feed: { ...feed, cell_comment: { comments: [comment] } } },
|
||||
attach_info: 'comments-next',
|
||||
has_more: true,
|
||||
};
|
||||
const request = mockRequest(detail);
|
||||
await expect(qzone.getQzonePublicFeedDetail(cookies, feed)).resolves.toEqual(detail);
|
||||
expect(body(request)).toEqual({ ...baseParams, feed_action_domain_type: 3, from_page: 1 });
|
||||
request.mockResolvedValueOnce({
|
||||
retcode: 0,
|
||||
data: {
|
||||
cell_comment: { comments: [comment] },
|
||||
attach_info: 'comments-end',
|
||||
has_more: false,
|
||||
},
|
||||
});
|
||||
await qzone.getQzonePublicComments(cookies, feed, 'comments-next');
|
||||
expect(body(request, 1)).toEqual({ ...baseParams, attach_info: 'comments-next' });
|
||||
request.mockResolvedValue({
|
||||
retcode: 0,
|
||||
data: { replys: [reply], reply_attach_info: 'reply-next', has_more: true },
|
||||
});
|
||||
await qzone.getQzonePublicReplies(cookies, feed, comment, '', 20);
|
||||
expect(body(request, 2)).toEqual({
|
||||
...baseParams,
|
||||
cur_comment: comment,
|
||||
comment_attach_info: '',
|
||||
reply_attach_info: 'reply-first',
|
||||
page_size: 20,
|
||||
});
|
||||
await qzone.getQzonePublicReplies(cookies, feed, comment, 'reply-next', 5);
|
||||
expect(body(request, 3).reply_attach_info).toBe('reply-next');
|
||||
});
|
||||
});
|
||||
|
||||
describe('public Qzone interactions', () => {
|
||||
it.each([
|
||||
[true, 'Upvote', '0x9d18', qzone.setQzonePublicUpvote, { upvote: { num: 1 } }],
|
||||
[false, 'Downvote', '0x9d19', qzone.setQzonePublicUpvote, { upvote: { num: 0 } }],
|
||||
[true, 'DoLike', '0x9699', qzone.setQzonePublicLike, { like: { num: 1 } }],
|
||||
[false, 'UnLike', '0x969c', qzone.setQzonePublicLike, { like: { num: 0 } }],
|
||||
] as const)(
|
||||
'sends %s to %s with its own success field',
|
||||
async (enable, method, command, invoke, result) => {
|
||||
const request = mockRequest(result);
|
||||
await expect(invoke(cookies, feed, enable)).resolves.toEqual(result);
|
||||
expect(request.mock.calls[0]![0]).toContain(`/${method}?`);
|
||||
expect(JSON.parse(request.mock.calls[0]![3]!['x-oidb']!)).toEqual({
|
||||
uint32_command: command,
|
||||
uint32_service_type: '1',
|
||||
});
|
||||
expect(body(request)).toEqual(
|
||||
method.includes('Like')
|
||||
? { ...baseParams, like_key: 'like-key', like_type: 1 }
|
||||
: baseParams,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('encodes text as plain RichItem and retains rich arrays and reply target identities', async () => {
|
||||
const request = mockRequest({ comment });
|
||||
await qzone.commentQzonePublicFeed(cookies, feed, user, '中文 & +\n第二行');
|
||||
expect(body(request)).toEqual({
|
||||
...baseParams,
|
||||
comment: { user, content: [{ type: 0, content: '中文 & +\n第二行' }] },
|
||||
});
|
||||
const rich = [
|
||||
{ type: 0, content: '文字' },
|
||||
{ type: 2, unknown: { keep: true } },
|
||||
];
|
||||
request.mockResolvedValue({ retcode: 0, data: { reply } });
|
||||
await qzone.replyQzonePublicFeed(cookies, feed, user, comment, rich);
|
||||
expect(body(request, 1)).toEqual({
|
||||
...baseParams,
|
||||
comment,
|
||||
reply: { user, target_user: comment.user, content: rich },
|
||||
});
|
||||
await qzone.replyQzonePublicFeed(cookies, feed, user, comment, '回复', reply);
|
||||
expect((body(request, 2).reply as JsonObject).target_user).toEqual(reply.user);
|
||||
});
|
||||
|
||||
it('likes comments and replies using their upstream like.id rather than comment IDs', async () => {
|
||||
const request = mockRequest({ like: { count: 1 } });
|
||||
await qzone.setQzonePublicCommentLike(
|
||||
cookies,
|
||||
feed,
|
||||
{ ...comment, like: { id: 'comment-like-key' } },
|
||||
true,
|
||||
);
|
||||
expect(body(request)).toEqual({
|
||||
...baseParams,
|
||||
like_key: 'comment-like-key',
|
||||
like_type: 2,
|
||||
});
|
||||
await qzone.setQzonePublicCommentLike(cookies, feed, comment, false, {
|
||||
...reply,
|
||||
like: { id: 'reply-like-key' },
|
||||
});
|
||||
expect(body(request, 1)).toEqual({
|
||||
...baseParams,
|
||||
like_key: 'reply-like-key',
|
||||
like_type: 3,
|
||||
});
|
||||
await expect(qzone.setQzonePublicCommentLike(cookies, feed, comment, true)).rejects.toThrow(
|
||||
/like_key/,
|
||||
);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('passes complete comment/reply objects for deletion and forwards without a second comment', async () => {
|
||||
const request = mockRequest({ unknown: 'keep' });
|
||||
await qzone.deleteQzonePublicComment(cookies, feed, comment);
|
||||
expect(body(request)).toEqual({ ...baseParams, comment });
|
||||
await qzone.deleteQzonePublicReply(cookies, feed, comment, reply);
|
||||
expect(body(request, 1)).toEqual({ ...baseParams, comment, reply });
|
||||
await qzone.forwardQzonePublicFeed(cookies, feed, '');
|
||||
expect(body(request, 2)).toEqual({ ...baseParams, reason: '' });
|
||||
expect(request.mock.calls[2]![0]).toContain(
|
||||
'/trpc.qzone.feeds_writer.FeedsWriter/ForwardFeed?',
|
||||
);
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('follows public authors by uid and other authors by uin, exposing nested relation failures', async () => {
|
||||
const request = mockRequest({ ret_code: '0' });
|
||||
await qzone.setQzonePublicFollow(cookies, feed, true);
|
||||
expect(body(request)).toEqual({
|
||||
follow_from_type: 1,
|
||||
target_uid: 'author-uid',
|
||||
follow_feed_id: 'feed-1',
|
||||
cell_feed_transfer: feed.cell_feed_transfer,
|
||||
});
|
||||
const other = {
|
||||
...feed,
|
||||
cell_common: { ...(feed.cell_common as JsonObject), domain_type: 1 },
|
||||
};
|
||||
await qzone.setQzonePublicFollow(cookies, other, false);
|
||||
expect(body(request, 1).target_uin).toBe('20002');
|
||||
expect(request.mock.calls[1]![0]).toContain('/Unfollow?');
|
||||
request.mockResolvedValueOnce({ retcode: 0, data: { ret_code: 403, ret_msg: 'denied' } });
|
||||
await expect(qzone.setQzonePublicFollow(cookies, feed, true)).rejects.toThrow(
|
||||
'ret_code=403 denied',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects missing operation identities before writing', async () => {
|
||||
const request = mockRequest();
|
||||
expect(() => qzone.forwardQzonePublicFeed(cookies, {}, '')).toThrow(/union_id/);
|
||||
await expect(
|
||||
qzone.setQzonePublicLike(cookies, { cell_common: { union_id: union } }, true),
|
||||
).rejects.toThrow(/like_key/);
|
||||
await expect(
|
||||
qzone.setQzonePublicFollow(cookies, { cell_common: { union_id: union } }, true),
|
||||
).rejects.toThrow(/uid\/uin/);
|
||||
await expect(qzone.replyQzonePublicFeed(cookies, feed, user, {}, 'text')).rejects.toThrow(
|
||||
/user/,
|
||||
);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('public Qzone errors', () => {
|
||||
it.each([
|
||||
null,
|
||||
{},
|
||||
{ retcode: 0 },
|
||||
{ retcode: 0, data: [] },
|
||||
{ retcode: 4003, message: 'need login' },
|
||||
])('does not report malformed or failed envelopes as success (%j)', async (response) => {
|
||||
const request = vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue(response);
|
||||
await expect(qzone.getQzonePublicFeedDetail(cookies, feed)).rejects.toThrow(
|
||||
/qzone public GetFeedDetail failed/,
|
||||
);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('propagates transport errors and does not retry writes or accept missing success fields', async () => {
|
||||
const request = mockRequest().mockRejectedValueOnce(new Error('connection closed'));
|
||||
await expect(qzone.commentQzonePublicFeed(cookies, feed, user, 'text')).rejects.toThrow(
|
||||
'connection closed',
|
||||
);
|
||||
await expect(qzone.setQzonePublicLike(cookies, feed, true)).rejects.toThrow(/缺少 like/);
|
||||
await expect(qzone.setQzonePublicUpvote(cookies, feed, false)).rejects.toThrow(
|
||||
/缺少 upvote/,
|
||||
);
|
||||
await expect(qzone.commentQzonePublicFeed(cookies, feed, user, 'text')).rejects.toThrow(
|
||||
/缺少 comment/,
|
||||
);
|
||||
await expect(
|
||||
qzone.replyQzonePublicFeed(cookies, feed, user, comment, 'text'),
|
||||
).rejects.toThrow(/缺少 reply/);
|
||||
expect(request).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { replyQzoneComment } from '@snowluma/protocol/web/qzone';
|
||||
import { RequestUtil, getBknFromCookie } from '@snowluma/protocol/web/request-util';
|
||||
|
||||
const cookies = { p_skey: 'PSK', skey: 'SK', uin: 'o10000' };
|
||||
const parent = { commentId: '0', commentUin: '30003' };
|
||||
|
||||
// Synthetic envelopes and parameters following the public web comment module.
|
||||
// These tests do not contact Qzone or claim live delivery/rendering coverage.
|
||||
describe('qzone / replyQzoneComment', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('uses the reply endpoint and keeps login, post owner, parent and new reply IDs distinct', async () => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue('frameElement.callback({"code":0,"data":{"id":8}});');
|
||||
const result = await replyQzoneComment(
|
||||
cookies,
|
||||
'10000',
|
||||
'20002',
|
||||
'POST',
|
||||
'回复 & 好',
|
||||
parent,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
comment_id: '8',
|
||||
parent_comment_id: '0',
|
||||
parent_comment_uin: 30003,
|
||||
});
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
const [url, method, body, headers] = request.mock.calls[0]!;
|
||||
expect(url).toBe(
|
||||
`https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_addreply_ugc?g_tk=${getBknFromCookie(cookies)}`,
|
||||
);
|
||||
expect(method).toBe('POST');
|
||||
expect(headers).toMatchObject({
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Cookie: expect.stringContaining('p_skey=PSK'),
|
||||
});
|
||||
expect(Object.fromEntries(new URLSearchParams(body as string))).toEqual({
|
||||
uin: '10000',
|
||||
hostUin: '20002',
|
||||
hostuin: '10000',
|
||||
topicId: '20002_POST',
|
||||
commentId: '0',
|
||||
commentUin: '30003',
|
||||
content: '回复 & 好',
|
||||
inCharset: 'utf-8',
|
||||
outCharset: 'utf-8',
|
||||
ref: '',
|
||||
code_version: '1',
|
||||
format: 'fs',
|
||||
qzreferrer: 'https://user.qzone.qq.com/20002',
|
||||
});
|
||||
});
|
||||
|
||||
it('addresses a nested reply author without substituting that author for the parent', async () => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue('{"code":0,"data":{"id":"9007199254740993"}}');
|
||||
const result = await replyQzoneComment(cookies, '10000', '20002', 'POST', '内容 😀 & +', {
|
||||
...parent,
|
||||
replyTo: { uin: '40004', name: ' 名%,}😀 ' },
|
||||
});
|
||||
|
||||
const form = new URLSearchParams(request.mock.calls[0]![2] as string);
|
||||
expect(form.get('commentId')).toBe('0');
|
||||
expect(form.get('commentUin')).toBe('30003');
|
||||
expect(form.get('content')).toBe('@{uin:40004,nick:名%25%2C%7D😀,auto:1} 内容 😀 & +');
|
||||
expect(form.has('replyId')).toBe(false);
|
||||
expect(result.comment_id).toBe('9007199254740993');
|
||||
});
|
||||
|
||||
it('preserves a zero new reply ID', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue('{"code":0,"data":{"id":0}}');
|
||||
expect(
|
||||
(await replyQzoneComment(cookies, '10000', '20002', 'POST', 'Reply', parent))
|
||||
.comment_id,
|
||||
).toBe('0');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...parent, commentId: '' },
|
||||
{ ...parent, commentId: ' ' },
|
||||
{ ...parent, commentUin: '0' },
|
||||
{ ...parent, commentUin: 'wrong' },
|
||||
{ ...parent, commentUin: '9007199254740993' },
|
||||
{ ...parent, replyTo: { uin: '0', name: 'Reader' } },
|
||||
{ ...parent, replyTo: { uin: '40004', name: ' ' } },
|
||||
])('rejects invalid parent/recipient before network access (%j)', async (target) => {
|
||||
const request = vi.spyOn(RequestUtil, 'HttpGetText');
|
||||
await expect(
|
||||
replyQzoneComment(cookies, '10000', '20002', 'POST', 'Reply', target),
|
||||
).rejects.toThrow();
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['0', '20002', 'POST', 'Reply'],
|
||||
['10000', '0', 'POST', 'Reply'],
|
||||
['10000', '20002', ' ', 'Reply'],
|
||||
['10000', '20002', 'POST', ' '],
|
||||
])(
|
||||
'rejects invalid post identity or body (%s, %s, %s, %s)',
|
||||
async (self, owner, tid, content) => {
|
||||
const request = vi.spyOn(RequestUtil, 'HttpGetText');
|
||||
await expect(
|
||||
replyQzoneComment(cookies, self, owner, tid, content, parent),
|
||||
).rejects.toThrow();
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ code: -3000, message: 'permission denied' },
|
||||
{ code: -3001, message: 'verification required' },
|
||||
{ code: 0, subcode: -7, message: 'comment removed' },
|
||||
])(
|
||||
'preserves server errors without retrying or posting an ordinary comment (%j)',
|
||||
async (response) => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue(JSON.stringify(response));
|
||||
await expect(
|
||||
replyQzoneComment(cookies, '10000', '20002', 'POST', 'Reply', parent),
|
||||
).rejects.toThrow(response.message);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
null,
|
||||
[],
|
||||
{},
|
||||
{ code: '0' },
|
||||
{ code: 0, subcode: '0' },
|
||||
{ code: 0 },
|
||||
{ code: 0, commentid: 9 },
|
||||
{ code: 0, data: null },
|
||||
{ code: 0, data: { id: '' } },
|
||||
{ code: 0, data: { id: ' ' } },
|
||||
{ code: 0, data: { id: {} } },
|
||||
{ code: 0, data: { id: -1 } },
|
||||
{ code: 0, data: { id: 9007199254740992 } },
|
||||
])('does not report malformed acknowledgements as success (%j)', async (response) => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue(JSON.stringify(response));
|
||||
await expect(
|
||||
replyQzoneComment(cookies, '10000', '20002', 'POST', 'Reply', parent),
|
||||
).rejects.toThrow();
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('warns against blind retries when an accepted reply omits its ID', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue('{"code":0,"data":{}}');
|
||||
await expect(
|
||||
replyQzoneComment(cookies, '10000', '20002', 'POST', 'Reply', parent),
|
||||
).rejects.toThrow(
|
||||
'accepted but returned an invalid reply ID; check delivery before retrying',
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates network failures without another write', async () => {
|
||||
const request = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockRejectedValue(new Error('network down'));
|
||||
await expect(
|
||||
replyQzoneComment(cookies, '10000', '20002', 'POST', 'Reply', parent),
|
||||
).rejects.toThrow('network down');
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getQzoneHomeFeeds, getQzoneShareArk } from '../../src/web/qzone-public';
|
||||
import { RequestUtil } from '../../src/web/request-util';
|
||||
|
||||
const cookies = { p_skey: 'test', uin: 'o10001' };
|
||||
const feed = {
|
||||
cell_common: { union_id: { feed_id: 'feed-id' }, operate_mask: 0 },
|
||||
cell_operation: {
|
||||
share_outside: { share_access_resource: { busi_id: 'qzone_feed', resource_id: 'opaque' } },
|
||||
},
|
||||
cell_feed_transfer: 'original-transfer',
|
||||
unknown: { keep: true },
|
||||
};
|
||||
const ark = '{ "app": "com.tencent.tuwen.lua", "config": {"token":"fixture"} }';
|
||||
const request = (data: unknown) =>
|
||||
vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({ retcode: 0, data });
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('Qzone share card', () => {
|
||||
it('generates the original card and returns the upstream confirmation notice', async () => {
|
||||
const req = request({
|
||||
template_data: ark,
|
||||
err: { code: 0 },
|
||||
info: {
|
||||
busi_data: Buffer.from(
|
||||
JSON.stringify({ need_break: true, tips: { content: '分享后所有人可见' } }),
|
||||
).toString('base64'),
|
||||
},
|
||||
});
|
||||
await expect(getQzoneShareArk(cookies, feed)).resolves.toEqual({
|
||||
ark,
|
||||
requires_confirmation: true,
|
||||
notice: '分享后所有人可见',
|
||||
});
|
||||
expect(req).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.stringContaining('/trpc.qq.share.Service/CreateShare?g_tk='),
|
||||
'POST',
|
||||
{
|
||||
info: {
|
||||
busi_id: 'qzone_feed',
|
||||
resource_id: 'opaque',
|
||||
template: 'ark_busi_select',
|
||||
busi_data: 'original-transfer',
|
||||
plat: 1,
|
||||
target: 3,
|
||||
},
|
||||
},
|
||||
expect.objectContaining({
|
||||
'x-oidb': JSON.stringify({ uint32_command: '0x9409', uint32_service_type: '1' }),
|
||||
Cookie: 'p_skey=test; uin=o10001',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a valid card without a confirmation notice', async () => {
|
||||
request({ template_data: ark, err: {} });
|
||||
await expect(getQzoneShareArk(cookies, feed)).resolves.toEqual({
|
||||
ark,
|
||||
requires_confirmation: false,
|
||||
notice: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a successful card when the upstream business error is null', async () => {
|
||||
const card = '{ "app": "com.tencent.miniapp.lua", "view": "miniapp" }';
|
||||
const req = request({
|
||||
template_data: card,
|
||||
err: null,
|
||||
info: {
|
||||
busi_data: Buffer.from(
|
||||
JSON.stringify({ err_code: 0, err_msg: '', need_break: false, tips: {} }),
|
||||
).toString('base64'),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(getQzoneShareArk(cookies, feed)).resolves.toEqual({
|
||||
ark: card,
|
||||
requires_confirmation: false,
|
||||
notice: null,
|
||||
});
|
||||
expect(req).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('rejects unavailable sharing and incomplete feeds before requesting a card', async () => {
|
||||
const req = request({ template_data: ark });
|
||||
await expect(
|
||||
getQzoneShareArk(cookies, { ...feed, cell_common: { operate_mask: 1 << 17 } }),
|
||||
).rejects.toThrow('不支持分享');
|
||||
await expect(
|
||||
getQzoneShareArk(cookies, { ...feed, cell_feed_transfer: '' }),
|
||||
).rejects.toThrow('缺少分享信息');
|
||||
await expect(getQzoneShareArk(cookies, {})).rejects.toThrow('缺少分享信息');
|
||||
expect(req).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ err: { code: 3, msg: '参数错误' }, template_data: ark }, '3 参数错误'],
|
||||
[{ err: { code: '0' }, template_data: ark }, '业务响应异常'],
|
||||
[{ err: false, template_data: ark }, '业务响应异常'],
|
||||
[{ err: 0, template_data: ark }, '业务响应异常'],
|
||||
[{ err: '', template_data: ark }, '业务响应异常'],
|
||||
[{ err: [], template_data: ark }, '业务响应异常'],
|
||||
[{ err: null }, '未返回卡片'],
|
||||
[{}, '未返回卡片'],
|
||||
[{ template_data: '[]' }, '卡片格式异常'],
|
||||
[{ template_data: ark, info: { busi_data: '!' } }, '提示编码异常'],
|
||||
[{ template_data: ark, info: { busi_data: Buffer.from('{').toString('base64') } }, 'JSON'],
|
||||
])('surfaces rejected or malformed share responses', async (data, error) => {
|
||||
request(data);
|
||||
await expect(getQzoneShareArk(cookies, feed)).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
it('propagates access errors without a retry', async () => {
|
||||
const req = request(null).mockResolvedValue({ retcode: 10023, message: '无权限访问' });
|
||||
await expect(getQzoneShareArk(cookies, feed)).rejects.toThrow('10023 无权限访问');
|
||||
expect(req).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Qzone homepage feeds', () => {
|
||||
it('reads own homepage and preserves full cells across explicit pagination', async () => {
|
||||
const req = request({ feeds: [{ feed }], has_more: true, attach_info: 'next', extra: 1 });
|
||||
const first = await getQzoneHomeFeeds(cookies, '10001');
|
||||
expect(first).toMatchObject({ feeds: [feed], has_more: true, extra: 1 });
|
||||
expect(req.mock.calls[0]![2]).toEqual({
|
||||
host_uid: '',
|
||||
host_uin: '10001',
|
||||
attach_info: '',
|
||||
refresh_attach_info: '',
|
||||
feed_action_domain_type: 1,
|
||||
});
|
||||
req.mockResolvedValueOnce({ retcode: 0, data: { feeds: [], has_more: false } });
|
||||
await expect(
|
||||
getQzoneHomeFeeds(cookies, '10001', undefined, first.next_cursor!),
|
||||
).resolves.toEqual({ feeds: [], has_more: false, next_cursor: null });
|
||||
expect(req.mock.calls[1]![2]).toMatchObject({ attach_info: 'next' });
|
||||
});
|
||||
|
||||
it('rejects a cursor reused for another account, homepage or stream', async () => {
|
||||
const req = request({ feeds: [{ feed }], has_more: true, attach_info: 'next' });
|
||||
const first = await getQzoneHomeFeeds(cookies, '10001');
|
||||
req.mockClear();
|
||||
|
||||
for (const [self, host, cursor] of [
|
||||
['20002', '10001', first.next_cursor!],
|
||||
['10001', '20002', first.next_cursor!],
|
||||
[
|
||||
'10001',
|
||||
'10001',
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
uin: '10001',
|
||||
attach_info: 'next',
|
||||
refresh_id: 'x',
|
||||
}),
|
||||
).toString('base64url'),
|
||||
],
|
||||
]) {
|
||||
await expect(getQzoneHomeFeeds(cookies, self!, host!, cursor!)).rejects.toThrow(
|
||||
'cursor',
|
||||
);
|
||||
}
|
||||
|
||||
expect(req).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces missing or stalled pagination and permission failures', async () => {
|
||||
const req = request({ feeds: [], has_more: true });
|
||||
await expect(getQzoneHomeFeeds(cookies, '10001')).rejects.toThrow('分页未推进');
|
||||
req.mockResolvedValue({ retcode: 10023, message: '无权限访问' });
|
||||
await expect(getQzoneHomeFeeds(cookies, '10001', '20002')).rejects.toThrow(
|
||||
'10023 无权限访问',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { buildQzoneUpdatePayload, updateQzoneMsgRight } from '@snowluma/protocol/web/qzone';
|
||||
import { RequestUtil } from '@snowluma/protocol/web/request-util';
|
||||
|
||||
// updateQzoneMsgRight is the two-step msgdetail_v6 → emotion_cgi_update flow.
|
||||
// The real logic is the payload REBUILD (detail → publish-shaped form body,
|
||||
// ported from php-qzone's live-verified updateRight): richval reconstruction
|
||||
// from pic_id, pic_bo from the `bo=` query param, conlist → con. We pin that
|
||||
// plus both request shapes and the throw contracts.
|
||||
|
||||
const cookies = { p_skey: 'PSK', skey: 'SK', uin: 'o10000', p_uin: 'o10000' };
|
||||
|
||||
describe('qzone / buildQzoneUpdatePayload', () => {
|
||||
it('rebuilds a text-only payload with ugcright_id defaulting to tid', () => {
|
||||
const body = buildQzoneUpdatePayload(
|
||||
{ tid: 'T1', uin: 10000, content: 'hello', ugc_right: 1 },
|
||||
'10000',
|
||||
);
|
||||
expect(body.get('tid')).toBe('T1');
|
||||
expect(body.get('con')).toBe('hello');
|
||||
expect(body.get('richtype')).toBe('');
|
||||
expect(body.get('richval')).toBe('');
|
||||
expect(body.get('pic_bo')).toBe('');
|
||||
expect(body.get('ugcright_id')).toBe('T1');
|
||||
expect(body.get('hostuin')).toBe('10000');
|
||||
expect(body.get('format')).toBe('fs');
|
||||
});
|
||||
|
||||
it('rebuilds richval from pic_id and pic_bo from the bo= query param', () => {
|
||||
const body = buildQzoneUpdatePayload(
|
||||
{
|
||||
tid: 'T2',
|
||||
uin: 10000,
|
||||
content: 'pics',
|
||||
conlist: [{ con: 'pi' }, { con: 'cs' }],
|
||||
richtype: 1,
|
||||
pic: [
|
||||
{
|
||||
pic_id: 'X,ALBUM1,LLOC1,extra',
|
||||
pictype: 22,
|
||||
height: 800,
|
||||
width: 600,
|
||||
url1: 'https://photo.example/a.jpg?bo=abc%2Cdef&x=1',
|
||||
},
|
||||
],
|
||||
},
|
||||
'10000',
|
||||
);
|
||||
expect(body.get('con')).toBe('pics');
|
||||
expect(body.get('richtype')).toBe('1');
|
||||
expect(body.get('richval')).toBe(',ALBUM1,LLOC1,LLOC1,22,800,600,,0,0');
|
||||
expect(body.get('pic_bo')).toBe('abc,def\tabc,def');
|
||||
});
|
||||
|
||||
it('throws when an image post detail carries unusable pic info', () => {
|
||||
expect(() =>
|
||||
buildQzoneUpdatePayload(
|
||||
{ tid: 'T3', richtype: 1, pic: [{ pic_id: 'broken' }] },
|
||||
'10000',
|
||||
),
|
||||
).toThrow('unsupported pic info');
|
||||
expect(() => buildQzoneUpdatePayload({ tid: 'T3', richtype: 1, pic: [] }, '10000')).toThrow(
|
||||
'missing pic info',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the detail has no tid', () => {
|
||||
expect(() => buildQzoneUpdatePayload({}, '10000')).toThrow('missing tid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('qzone / updateQzoneMsgRight (HTTP layer)', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const detailBody =
|
||||
'_Callback({"code":0,"subcode":0,"tid":"T1","uin":10000,"content":"hello","ugc_right":1});';
|
||||
|
||||
it('GETs the detail then POSTs the rebuilt payload with the new ugc_right', async () => {
|
||||
const spy = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValueOnce(detailBody)
|
||||
.mockResolvedValueOnce('frameElement.callback({"code":0,"subcode":0,"ugc_right":64});');
|
||||
|
||||
const out = await updateQzoneMsgRight(cookies, '10000', 'T1', 64);
|
||||
expect(out).toEqual({ ugc_right: 64 });
|
||||
|
||||
const [detailUrl, detailMethod] = spy.mock.calls[0]!;
|
||||
expect(detailMethod).toBe('GET');
|
||||
expect(detailUrl).toContain(
|
||||
'https://h5.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msgdetail_v6?',
|
||||
);
|
||||
const dq = new URLSearchParams((detailUrl as string).split('?')[1]);
|
||||
expect(dq.get('tid')).toBe('T1');
|
||||
expect(dq.get('uin')).toBe('10000');
|
||||
|
||||
const [updateUrl, updateMethod, updateBody] = spy.mock.calls[1]!;
|
||||
expect(updateMethod).toBe('POST');
|
||||
expect(updateUrl).toContain(
|
||||
'https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_update?',
|
||||
);
|
||||
const uq = new URLSearchParams(updateBody as string);
|
||||
expect(uq.get('tid')).toBe('T1');
|
||||
expect(uq.get('con')).toBe('hello');
|
||||
expect(uq.get('ugc_right')).toBe('64');
|
||||
expect(uq.get('allow_uins')).toBeNull();
|
||||
});
|
||||
|
||||
it('sets allow_uins (deduped) for ugc_right 16 and requires it', async () => {
|
||||
const spy = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValueOnce(detailBody)
|
||||
.mockResolvedValueOnce('{"code":0,"subcode":0}');
|
||||
|
||||
await updateQzoneMsgRight(cookies, '10000', 'T1', 16, '10001|10001|10002');
|
||||
const uq = new URLSearchParams(spy.mock.calls[1]![2] as string);
|
||||
expect(uq.get('ugc_right')).toBe('16');
|
||||
expect(uq.get('allow_uins')).toBe('10001|10002');
|
||||
|
||||
await expect(updateQzoneMsgRight(cookies, '10000', 'T1', 128)).rejects.toThrow(
|
||||
'target_uins is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an invalid ugc_right before any request', async () => {
|
||||
const spy = vi.spyOn(RequestUtil, 'HttpGetText');
|
||||
await expect(updateQzoneMsgRight(cookies, '10000', 'T1', 2)).rejects.toThrow(
|
||||
'ugc_right must be one of',
|
||||
);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws on a non-zero detail code and skips the update POST', async () => {
|
||||
const spy = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValueOnce('{"code":-3000,"subcode":-3000,"message":"need login"}');
|
||||
await expect(updateQzoneMsgRight(cookies, '10000', 'T1', 64)).rejects.toThrow('code=-3000');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws on a non-zero update subcode', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValueOnce(detailBody)
|
||||
.mockResolvedValueOnce('{"code":0,"subcode":-200,"message":"denied"}');
|
||||
await expect(updateQzoneMsgRight(cookies, '10000', 'T1', 64)).rejects.toThrow(
|
||||
'subcode=-200',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { uploadQzoneImage, uploadQzoneImageFromSource } from '../../src/web/qzone';
|
||||
import { RequestUtil } from '../../src/web/request-util';
|
||||
|
||||
const cookies = { p_skey: 'PSK', skey: 'SK', uin: 'o10000', p_uin: 'o10000' };
|
||||
const expectedGtk = (() => {
|
||||
let h = 5381;
|
||||
|
||||
for (const c of 'PSK') {
|
||||
h += (h << 5) + c.charCodeAt(0);
|
||||
}
|
||||
|
||||
return (h & 0x7fffffff).toString();
|
||||
})();
|
||||
|
||||
const successBody = `<script>frameElement.callback({
|
||||
"code": 0,
|
||||
"subcode": 0,
|
||||
"data": {
|
||||
"albumid": "12345",
|
||||
"lloc": "abc123",
|
||||
"url": "https://example.qzone.qq.com/image.jpg",
|
||||
"type": 22,
|
||||
"height": 800,
|
||||
"width": 600
|
||||
}
|
||||
});</script>`;
|
||||
|
||||
describe('qzone / uploadQzoneImage', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('POSTs the upload form and returns richval + direct url', async () => {
|
||||
const spy = vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(successBody);
|
||||
|
||||
const out = await uploadQzoneImage(cookies, '10000', 'AQID');
|
||||
|
||||
expect(out).toEqual({
|
||||
richval: ',12345,abc123,abc123,22,800,600,,800,600',
|
||||
url: 'https://example.qzone.qq.com/image.jpg',
|
||||
albumid: '12345',
|
||||
lloc: 'abc123',
|
||||
type: 22,
|
||||
width: 600,
|
||||
height: 800,
|
||||
});
|
||||
|
||||
const [url, method, body, headers] = spy.mock.calls[0]!;
|
||||
expect(url).toBe(
|
||||
`https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image?g_tk=${expectedGtk}`,
|
||||
);
|
||||
expect(method).toBe('POST');
|
||||
const h = headers as Record<string, string>;
|
||||
expect(h['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||
expect(h.Cookie).toContain('p_skey=PSK');
|
||||
expect(h.Cookie).toContain('skey=SK');
|
||||
|
||||
const form = new URLSearchParams(body as string);
|
||||
expect(form.get('uin')).toBe('10000');
|
||||
expect(form.get('p_uin')).toBe('10000');
|
||||
expect(form.get('p_skey')).toBe('PSK');
|
||||
expect(form.get('base64')).toBe('1');
|
||||
expect(form.get('jsonhtml_callback')).toBe('callback');
|
||||
expect(form.get('picfile')).toBe('AQID');
|
||||
expect(form.get('qzreferrer')).toBe('https://user.qzone.qq.com/10000/main');
|
||||
});
|
||||
|
||||
it('accepts base64:// data-URI sources and strips the prefix before upload', async () => {
|
||||
const spy = vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(successBody);
|
||||
|
||||
await uploadQzoneImageFromSource(cookies, '10000', 'base64://data:image/png;base64,AQID');
|
||||
|
||||
const form = new URLSearchParams(spy.mock.calls[0]![2] as string);
|
||||
expect(form.get('picfile')).toBe('AQID');
|
||||
});
|
||||
|
||||
it('defaults optional image metadata to 0 when the upload succeeds without it', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(
|
||||
'callback({"code":0,"data":{"albumid":"12345","lloc":"abc123","url":"https://example.qzone.qq.com/image.jpg"}});',
|
||||
);
|
||||
|
||||
await expect(uploadQzoneImage(cookies, '10000', 'AQID')).resolves.toMatchObject({
|
||||
richval: ',12345,abc123,abc123,0,0,0,,0,0',
|
||||
type: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid base64 before any request', async () => {
|
||||
const spy = vi.spyOn(RequestUtil, 'HttpGetText');
|
||||
|
||||
await expect(uploadQzoneImage(cookies, '10000', 'not base64?')).rejects.toThrow(
|
||||
'valid base64',
|
||||
);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws on non-zero code or subcode from Qzone', async () => {
|
||||
const spy = vi.spyOn(RequestUtil, 'HttpGetText');
|
||||
|
||||
spy.mockResolvedValueOnce('callback({"code":-1,"message":"upload failed"});');
|
||||
await expect(uploadQzoneImage(cookies, '10000', 'AQID')).rejects.toThrow('code=-1');
|
||||
|
||||
spy.mockResolvedValueOnce('callback({"code":0,"subcode":-2,"message":"sub failed"});');
|
||||
await expect(uploadQzoneImage(cookies, '10000', 'AQID')).rejects.toThrow('subcode=-2');
|
||||
});
|
||||
|
||||
it('throws when the success response lacks required upload fields', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(
|
||||
'callback({"code":0,"data":{"albumid":"12345"}});',
|
||||
);
|
||||
|
||||
await expect(uploadQzoneImage(cookies, '10000', 'AQID')).rejects.toThrow(
|
||||
'响应缺少必要字段',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,711 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
commentQzoneMsg,
|
||||
deleteQzoneMsg,
|
||||
getQzoneFeeds,
|
||||
getQzoneMsgList,
|
||||
mapFeeds,
|
||||
mapMsgList,
|
||||
parseQzoneJson,
|
||||
publishQzoneMsg,
|
||||
setQzoneLike,
|
||||
} from '@snowluma/protocol/web/qzone';
|
||||
import { RequestUtil } from '@snowluma/protocol/web/request-util';
|
||||
|
||||
// The 说说 list comes from taotao.qzone.qq.com's emotion_cgi_msglist_v6 CGI,
|
||||
// proxied through h5.qzone.qq.com. The body is JSONP (`_preloadCallback({…})`)
|
||||
// so we pin both the JSONP-stripping and the field renames (created_time→time,
|
||||
// cmtnum→comment_num, secret→is_private, pic→largest-url images), plus the
|
||||
// auth-failure throw contract — these are the only real logic in an otherwise
|
||||
// thin HTTP port and would be invisible end-to-end if they regressed.
|
||||
|
||||
const cookies = { p_skey: 'PSK', skey: 'SK', uin: 'o10000', p_uin: 'o10000' };
|
||||
const expectedGtk = (() => {
|
||||
let h = 5381;
|
||||
|
||||
for (const c of 'PSK') {
|
||||
h += (h << 5) + c.charCodeAt(0);
|
||||
}
|
||||
|
||||
return (h & 0x7fffffff).toString();
|
||||
})();
|
||||
|
||||
describe('qzone / parseQzoneJson', () => {
|
||||
it('parses a raw JSON body', () => {
|
||||
expect(parseQzoneJson<{ a: number }>('{"a":1}')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('strips a JSONP callback wrapper', () => {
|
||||
expect(parseQzoneJson<{ a: number }>('_preloadCallback({"a":1});')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('throws on a non-object body (HTML error page)', () => {
|
||||
expect(() => parseQzoneJson('<html>nope</html>')).toThrow(
|
||||
'invalid response from qzone api',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('qzone / mapMsgList', () => {
|
||||
it('renames fields, flags private, and picks the largest pic url', () => {
|
||||
const out = mapMsgList({
|
||||
code: 0,
|
||||
total: 42,
|
||||
msglist: [
|
||||
{
|
||||
tid: 'T1',
|
||||
content: 'hello',
|
||||
created_time: 1700000000,
|
||||
cmtnum: 3,
|
||||
secret: 0,
|
||||
pic: [{ url1: 'a', url2: 'b', url3: 'c' }],
|
||||
},
|
||||
{
|
||||
tid: 'T2',
|
||||
content: 'secret one',
|
||||
created_time: 1700000050,
|
||||
cmtnum: 0,
|
||||
secret: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out).toEqual({
|
||||
total: 42,
|
||||
msglist: [
|
||||
{
|
||||
tid: 'T1',
|
||||
content: 'hello',
|
||||
time: 1700000000,
|
||||
comment_num: 3,
|
||||
is_private: false,
|
||||
images: ['c'],
|
||||
},
|
||||
{
|
||||
tid: 'T2',
|
||||
content: 'secret one',
|
||||
time: 1700000050,
|
||||
comment_num: 0,
|
||||
is_private: true,
|
||||
images: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to list length when total is absent and tolerates missing fields', () => {
|
||||
const out = mapMsgList({ msglist: [{ tid: 'T1' }] });
|
||||
expect(out).toEqual({
|
||||
total: 1,
|
||||
msglist: [
|
||||
{ tid: 'T1', content: '', time: 0, comment_num: 0, is_private: false, images: [] },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('qzone / getQzoneMsgList (HTTP layer)', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('GETs the exact proxied url + params and maps the JSONP body', async () => {
|
||||
const spy = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue(
|
||||
'_preloadCallback({"code":0,"total":1,"msglist":[{"tid":"T1","content":"hi","created_time":1700000000,"cmtnum":2,"secret":0,"pic":[{"url1":"a","url3":"c"}]}]});',
|
||||
);
|
||||
|
||||
const out = await getQzoneMsgList(cookies, '10000', 0, 20);
|
||||
|
||||
expect(out).toEqual({
|
||||
total: 1,
|
||||
msglist: [
|
||||
{
|
||||
tid: 'T1',
|
||||
content: 'hi',
|
||||
time: 1700000000,
|
||||
comment_num: 2,
|
||||
is_private: false,
|
||||
images: ['c'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const [url, method, body, headers] = spy.mock.calls[0]!;
|
||||
expect(method).toBe('GET');
|
||||
expect(body).toBe('');
|
||||
expect((headers as Record<string, string>).Cookie).toContain('p_skey=PSK');
|
||||
expect(url).toContain(
|
||||
'https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_msglist_v6?',
|
||||
);
|
||||
const q = new URLSearchParams((url as string).split('?')[1]);
|
||||
expect(q.get('uin')).toBe('10000');
|
||||
expect(q.get('pos')).toBe('0');
|
||||
expect(q.get('num')).toBe('20');
|
||||
expect(q.get('g_tk')).toBe(expectedGtk);
|
||||
expect(q.get('format')).toBe('jsonp');
|
||||
});
|
||||
|
||||
it('retries via the user.qzone.qq.com route on a -10000 rate-limit', async () => {
|
||||
const spy = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValueOnce('{"code":-10000,"message":"使用人数过多,请稍后再试"}')
|
||||
.mockResolvedValueOnce(
|
||||
'{"code":0,"total":1,"msglist":[{"tid":"T1","content":"hi","created_time":1700000000,"cmtnum":0,"secret":0,"pic":[]}]}',
|
||||
);
|
||||
|
||||
const out = await getQzoneMsgList(cookies, '10000', 0, 20);
|
||||
|
||||
expect(out).toEqual({
|
||||
total: 1,
|
||||
msglist: [
|
||||
{
|
||||
tid: 'T1',
|
||||
content: 'hi',
|
||||
time: 1700000000,
|
||||
comment_num: 0,
|
||||
is_private: false,
|
||||
images: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
expect(spy.mock.calls[0]![0]).toContain(
|
||||
'https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/',
|
||||
);
|
||||
const [retryUrl, , , retryHeaders] = spy.mock.calls[1]!;
|
||||
expect(retryUrl).toContain(
|
||||
'https://user.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msglist_v6?',
|
||||
);
|
||||
expect((retryHeaders as Record<string, string>).Referer).toBe(
|
||||
'https://user.qzone.qq.com/10000',
|
||||
);
|
||||
const q = new URLSearchParams((retryUrl as string).split('?')[1]);
|
||||
expect(q.get('format')).toBe('json');
|
||||
expect(q.get('g_tk')).toBe(expectedGtk);
|
||||
});
|
||||
|
||||
it('returns an empty list (not a throw) for a genuinely empty space', async () => {
|
||||
// The throw-on-auth-failure contract hinges on distinguishing a missing
|
||||
// msglist (cookie failure → throw) from an empty msglist (no 说说 → []).
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue('{"code":0,"total":0,"msglist":[]}');
|
||||
await expect(getQzoneMsgList(cookies, '10000')).resolves.toEqual({ total: 0, msglist: [] });
|
||||
});
|
||||
|
||||
it('propagates a transport error instead of swallowing it', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockRejectedValue(
|
||||
new Error('Unexpected status code: 403'),
|
||||
);
|
||||
await expect(getQzoneMsgList(cookies, '10000')).rejects.toThrow('403');
|
||||
});
|
||||
|
||||
it('throws on a non-zero qzone code (auth/permission), not an empty list', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(
|
||||
'{"code":-3000,"message":"need login","subcode":0}',
|
||||
);
|
||||
await expect(getQzoneMsgList(cookies, '10000')).rejects.toThrow('code=-3000');
|
||||
});
|
||||
|
||||
it('throws when msglist is absent (cookie failure), not an empty list', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue('{"code":0,"total":0}');
|
||||
await expect(getQzoneMsgList(cookies, '10000')).rejects.toThrow('无法获取空间说说列表');
|
||||
});
|
||||
});
|
||||
|
||||
describe('qzone / mapFeeds', () => {
|
||||
it('maps structured fields, prefers key over feedskey, and reads has_more', () => {
|
||||
const out = mapFeeds({
|
||||
code: 0,
|
||||
data: {
|
||||
hasmore: 1,
|
||||
data: [
|
||||
{
|
||||
uin: 12345,
|
||||
nickname: 'Alice',
|
||||
abstime: 1700000000,
|
||||
appid: 311,
|
||||
key: 'K1',
|
||||
html: '<div>a</div>',
|
||||
},
|
||||
{
|
||||
uin: '67890',
|
||||
nickname: 'Bob',
|
||||
abstime: '1700000050',
|
||||
appid: '4',
|
||||
feedskey: 'FK2',
|
||||
html: '<div>b</div>',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(out).toMatchObject({
|
||||
has_more: true,
|
||||
feeds: [
|
||||
{
|
||||
uin: 12345,
|
||||
nickname: 'Alice',
|
||||
time: 1700000000,
|
||||
appid: 311,
|
||||
key: 'K1',
|
||||
html: '<div>a</div>',
|
||||
},
|
||||
{
|
||||
uin: 67890,
|
||||
nickname: 'Bob',
|
||||
time: 1700000050,
|
||||
appid: 4,
|
||||
key: 'FK2',
|
||||
html: '<div>b</div>',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates missing fields and empty data', () => {
|
||||
expect(mapFeeds({ code: 0, data: { hasmore: 0, data: [] } })).toEqual({
|
||||
feeds: [],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('qzone / getQzoneFeeds (HTTP layer)', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('GETs the exact feeds url + params and maps the JSONP body', async () => {
|
||||
const spy = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue(
|
||||
'_preloadCallback({"code":0,"data":{"main":{"hasMoreFeeds":true,"pagenum":"2","externparam":"page=2","begintime":"1699999999"},"data":[{"uin":12345,"nickname":"Alice","abstime":1700000000,"appid":311,"key":"K1","html":"<div>a</div>"}]}});',
|
||||
);
|
||||
|
||||
const out = await getQzoneFeeds(cookies, '10000', 1, 10);
|
||||
|
||||
expect(out).toMatchObject({
|
||||
has_more: true,
|
||||
feeds: [
|
||||
{
|
||||
uin: 12345,
|
||||
nickname: 'Alice',
|
||||
time: 1700000000,
|
||||
appid: 311,
|
||||
key: 'K1',
|
||||
html: '<div>a</div>',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const [url, method, body, headers] = spy.mock.calls[0]!;
|
||||
expect(method).toBe('GET');
|
||||
expect(body).toBe('');
|
||||
expect((headers as Record<string, string>).Cookie).toContain('p_skey=PSK');
|
||||
// Routed through the h5.qzone proxy gateway (NOT ic2 directly), because
|
||||
// the qzone.qq.com cookie jar only authenticates against the proxy origin.
|
||||
expect(url).toContain(
|
||||
'https://h5.qzone.qq.com/proxy/domain/ic2.qzone.qq.com/cgi-bin/feeds/feeds3_html_more?',
|
||||
);
|
||||
const q = new URLSearchParams((url as string).split('?')[1]);
|
||||
expect(q.get('uin')).toBe('10000');
|
||||
expect(q.get('pagenum')).toBe('1');
|
||||
expect(q.get('count')).toBe('10');
|
||||
expect(q.get('g_tk')).toBe(expectedGtk);
|
||||
// The request asks for JSONP — pin it so the JSONP-bodied response above
|
||||
// actually exercises the requested format (not just the tolerant parser).
|
||||
expect(q.get('format')).toBe('jsonp');
|
||||
});
|
||||
|
||||
it('returns an empty list (not a throw) for a genuinely empty feed', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(
|
||||
'_preloadCallback({"code":0,"data":{"data":[],"hasmore":0}});',
|
||||
);
|
||||
await expect(getQzoneFeeds(cookies, '10000')).resolves.toEqual({
|
||||
feeds: [],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on a non-zero qzone code', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(
|
||||
'_preloadCallback({"code":-3000,"message":"need login"});',
|
||||
);
|
||||
await expect(getQzoneFeeds(cookies, '10000')).rejects.toThrow('code=-3000');
|
||||
});
|
||||
|
||||
it('throws when the data array is absent (cookie failure), not an empty list', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(
|
||||
'_preloadCallback({"code":0,"data":{}});',
|
||||
);
|
||||
await expect(getQzoneFeeds(cookies, '10000')).rejects.toThrow('无法获取空间好友动态');
|
||||
});
|
||||
});
|
||||
|
||||
describe('qzone / publishQzoneMsg (HTTP layer)', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('POSTs a form-urlencoded body and reads the real t1_tid/t1_time (string) success fields', async () => {
|
||||
// The real publish_v6 success envelope names the new feed id `t1_tid`
|
||||
// and the post time `t1_time` (a STRING) — reading `tid`/`now` would
|
||||
// false-throw on every successful publish.
|
||||
const spy = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue('{"code":0,"t1_tid":"NEWTID","t1_time":"1700000000"}');
|
||||
|
||||
const out = await publishQzoneMsg(cookies, '10000', 'hello 世界 & friends');
|
||||
|
||||
expect(out).toEqual({ tid: 'NEWTID', time: 1700000000 });
|
||||
|
||||
const [url, method, body, headers] = spy.mock.calls[0]!;
|
||||
expect(method).toBe('POST');
|
||||
expect(url).toBe(
|
||||
`https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_publish_v6?g_tk=${expectedGtk}`,
|
||||
);
|
||||
const h = headers as Record<string, string>;
|
||||
expect(h['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||
expect(h.Cookie).toContain('p_skey=PSK');
|
||||
// form body carries the content (urlencoded, incl. the `&`) and host uin
|
||||
const form = new URLSearchParams(body as string);
|
||||
expect(form.get('con')).toBe('hello 世界 & friends');
|
||||
expect(form.get('hostuin')).toBe('10000');
|
||||
expect(form.get('who')).toBe('1');
|
||||
expect(form.get('ugc_right')).toBe('1');
|
||||
expect(form.has('allow_uins')).toBe(false);
|
||||
expect(form.get('format')).toBe('json');
|
||||
expect(form.get('qzreferrer')).toBe('https://user.qzone.qq.com/10000');
|
||||
});
|
||||
|
||||
it('threads image richType/richval into the publish form', async () => {
|
||||
const spy = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue('{"code":0,"t1_tid":"NEWTID","t1_time":"1700000000"}');
|
||||
const richval = ',12345,abc,abc,22,800,600,,800,600\t,12346,def,def,22,640,480,,640,480';
|
||||
|
||||
await publishQzoneMsg(cookies, '10000', 'with images', 1, richval);
|
||||
|
||||
const form = new URLSearchParams(spy.mock.calls[0]![2] as string);
|
||||
expect(form.get('richtype')).toBe('1');
|
||||
expect(form.get('richval')).toBe(richval);
|
||||
});
|
||||
|
||||
it('threads publish permission fields for restricted visibility', async () => {
|
||||
const spy = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue('{"code":0,"t1_tid":"RIGHTTID","t1_time":"1700000000"}');
|
||||
|
||||
await expect(
|
||||
publishQzoneMsg(
|
||||
cookies,
|
||||
'10000',
|
||||
'limited',
|
||||
undefined,
|
||||
undefined,
|
||||
16,
|
||||
'10001 | 10002|10001',
|
||||
),
|
||||
).resolves.toEqual({
|
||||
tid: 'RIGHTTID',
|
||||
time: 1700000000,
|
||||
});
|
||||
|
||||
const [, , body] = spy.mock.calls[0]!;
|
||||
const form = new URLSearchParams(body as string);
|
||||
expect(form.get('ugc_right')).toBe('16');
|
||||
expect(form.get('allow_uins')).toBe('10001|10002');
|
||||
expect(form.get('who')).toBe('1');
|
||||
});
|
||||
|
||||
it('ignores target_uins for non-restricted visibility', async () => {
|
||||
const spy = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue('{"code":0,"t1_tid":"FRIENDTID","t1_time":"1700000000"}');
|
||||
|
||||
await expect(
|
||||
publishQzoneMsg(cookies, '10000', 'friends', undefined, undefined, 4, 'not-a-uin'),
|
||||
).resolves.toEqual({
|
||||
tid: 'FRIENDTID',
|
||||
time: 1700000000,
|
||||
});
|
||||
|
||||
const [, , body] = spy.mock.calls[0]!;
|
||||
const form = new URLSearchParams(body as string);
|
||||
expect(form.get('ugc_right')).toBe('4');
|
||||
expect(form.has('allow_uins')).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to tid/now for alternate client builds', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(
|
||||
'{"code":0,"tid":"ALT","now":1700000001}',
|
||||
);
|
||||
await expect(publishQzoneMsg(cookies, '10000', 'hi')).resolves.toEqual({
|
||||
tid: 'ALT',
|
||||
time: 1700000001,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects empty content before any request', async () => {
|
||||
const spy = vi.spyOn(RequestUtil, 'HttpGetText');
|
||||
await expect(publishQzoneMsg(cookies, '10000', '')).rejects.toThrow('content is required');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects incomplete publish rich params before any request', async () => {
|
||||
const spy = vi.spyOn(RequestUtil, 'HttpGetText');
|
||||
await expect(publishQzoneMsg(cookies, '10000', 'hi', 1)).rejects.toThrow(
|
||||
'richType and richval',
|
||||
);
|
||||
await expect(publishQzoneMsg(cookies, '10000', 'hi', undefined, ',123')).rejects.toThrow(
|
||||
'richType and richval',
|
||||
);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects restricted visibility without target_uins before any request', async () => {
|
||||
const spy = vi.spyOn(RequestUtil, 'HttpGetText');
|
||||
await expect(
|
||||
publishQzoneMsg(cookies, '10000', 'hi', undefined, undefined, 128),
|
||||
).rejects.toThrow('target_uins is required');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws on a non-zero qzone code (rejected/rate-limited)', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(
|
||||
'{"code":-10000,"message":"too frequent"}',
|
||||
);
|
||||
await expect(publishQzoneMsg(cookies, '10000', 'hi')).rejects.toThrow('code=-10000');
|
||||
});
|
||||
|
||||
it('throws when the success body carries no feed id (neither t1_tid nor tid)', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue('{"code":0,"t1_time":"1700000000"}');
|
||||
await expect(publishQzoneMsg(cookies, '10000', 'hi')).rejects.toThrow('缺少 tid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('qzone / deleteQzoneMsg (HTTP layer)', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('POSTs the tid form body (format=fs) to the proxied delete CGI and resolves on code 0', async () => {
|
||||
// NB: the success-body shape is a guess extrapolated from sibling CGIs —
|
||||
// no public delete impl parses the response — so this mock pins our
|
||||
// success contract, not a verified server envelope (see helper comment).
|
||||
const spy = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue('{"code":0,"subcode":0,"message":""}');
|
||||
|
||||
await expect(deleteQzoneMsg(cookies, '10000', 'TID123')).resolves.toBeUndefined();
|
||||
|
||||
const [url, method, body, headers] = spy.mock.calls[0]!;
|
||||
expect(method).toBe('POST');
|
||||
expect(url).toBe(
|
||||
`https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_delete_v6?g_tk=${expectedGtk}`,
|
||||
);
|
||||
const h = headers as Record<string, string>;
|
||||
expect(h['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||
expect(h.Cookie).toContain('p_skey=PSK');
|
||||
const form = new URLSearchParams(body as string);
|
||||
expect(form.get('tid')).toBe('TID123');
|
||||
expect(form.get('hostuin')).toBe('10000');
|
||||
// delete_v6 wants format=fs (NOT json), per the working community script
|
||||
expect(form.get('format')).toBe('fs');
|
||||
expect(form.get('json')).toBeNull(); // no bogus json param
|
||||
});
|
||||
|
||||
it('rejects an empty tid before any request', async () => {
|
||||
const spy = vi.spyOn(RequestUtil, 'HttpGetText');
|
||||
await expect(deleteQzoneMsg(cookies, '10000', '')).rejects.toThrow('tid is required');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws on a non-zero qzone code (foreign/unknown tid or auth failure)', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(
|
||||
'{"code":-4001,"message":"no permission"}',
|
||||
);
|
||||
await expect(deleteQzoneMsg(cookies, '10000', 'TID123')).rejects.toThrow('code=-4001');
|
||||
});
|
||||
|
||||
it('throws on a non-zero subcode even when code is 0', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(
|
||||
'{"code":0,"subcode":-12,"message":"sub error"}',
|
||||
);
|
||||
await expect(deleteQzoneMsg(cookies, '10000', 'TID123')).rejects.toThrow('subcode=-12');
|
||||
});
|
||||
|
||||
it('resolves when neither code nor subcode is present (extrapolated success branch)', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue('{"message":""}');
|
||||
await expect(deleteQzoneMsg(cookies, '10000', 'TID123')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('qzone / setQzoneLike (HTTP layer)', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('POSTs to internal_dolike_app with the mood unikey/curkey/fid/abstime when liking', async () => {
|
||||
const spy = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue('{"code":0,"subcode":0}');
|
||||
|
||||
await expect(
|
||||
setQzoneLike(cookies, '10000', '20002', 'TIDX', true, 1700000000),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const [url, method, body, headers] = spy.mock.calls[0]!;
|
||||
expect(method).toBe('POST');
|
||||
// like CGI + unikey/curkey shape + opuin + appid CONFIRMED (QLiker.py, CSDN)
|
||||
expect(url).toBe(
|
||||
`https://h5.qzone.qq.com/proxy/domain/w.qzone.qq.com/cgi-bin/likes/internal_dolike_app?g_tk=${expectedGtk}`,
|
||||
);
|
||||
const h = headers as Record<string, string>;
|
||||
expect(h['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||
expect(h.Cookie).toContain('p_skey=PSK');
|
||||
const form = new URLSearchParams(body as string);
|
||||
// unikey/curkey address the target's 说说 (mood), fid is the tid, opuin is self
|
||||
expect(form.get('unikey')).toBe('http://user.qzone.qq.com/20002/mood/TIDX');
|
||||
expect(form.get('curkey')).toBe('http://user.qzone.qq.com/20002/mood/TIDX');
|
||||
expect(form.get('fid')).toBe('TIDX');
|
||||
expect(form.get('opuin')).toBe('10000');
|
||||
expect(form.get('appid')).toBe('311');
|
||||
// abstime threaded through (every real dolike impl sends it)
|
||||
expect(form.get('abstime')).toBe('1700000000');
|
||||
});
|
||||
|
||||
it('defaults abstime to 0 when not supplied', async () => {
|
||||
const spy = vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue('{"code":0}');
|
||||
await setQzoneLike(cookies, '10000', '20002', 'TIDX', true);
|
||||
const form = new URLSearchParams(spy.mock.calls[0]![2] as string);
|
||||
expect(form.get('abstime')).toBe('0');
|
||||
});
|
||||
|
||||
it('hits internal_unlike_app when unliking (NB: unlike endpoint UNVERIFIED, pending a live capture)', async () => {
|
||||
const spy = vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue('{"code":0}');
|
||||
await setQzoneLike(cookies, '10000', '10000', 'TIDX', false);
|
||||
const [url] = spy.mock.calls[0]!;
|
||||
expect(url).toContain('/cgi-bin/likes/internal_unlike_app?');
|
||||
});
|
||||
|
||||
it('rejects an empty tid before any request', async () => {
|
||||
const spy = vi.spyOn(RequestUtil, 'HttpGetText');
|
||||
await expect(setQzoneLike(cookies, '10000', '10000', '', true)).rejects.toThrow(
|
||||
'tid is required',
|
||||
);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws on a non-zero code (no permission / unknown tid)', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(
|
||||
'{"code":-3000,"message":"no perm"}',
|
||||
);
|
||||
await expect(setQzoneLike(cookies, '10000', '20002', 'TIDX', true)).rejects.toThrow(
|
||||
'like failed: code=-3000',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on a non-zero subcode too', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue('{"code":0,"subcode":-7}');
|
||||
await expect(setQzoneLike(cookies, '10000', '20002', 'TIDX', false)).rejects.toThrow(
|
||||
'unlike failed: subcode=-7',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('qzone / commentQzoneMsg (HTTP layer)', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('POSTs to re_feeds with the topicId/uin/hostUin/content and returns the comment id', async () => {
|
||||
const spy = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue('{"code":0,"commentid":987}');
|
||||
|
||||
const out = await commentQzoneMsg(cookies, '10000', '20002', 'TIDX', '说得好 & 顶');
|
||||
|
||||
expect(out).toEqual({ comment_id: '987' });
|
||||
|
||||
const [url, method, body, headers] = spy.mock.calls[0]!;
|
||||
expect(method).toBe('POST');
|
||||
expect(url).toBe(
|
||||
`https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_re_feeds?g_tk=${expectedGtk}`,
|
||||
);
|
||||
const h = headers as Record<string, string>;
|
||||
expect(h['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||
expect(h.Cookie).toContain('p_skey=PSK');
|
||||
const form = new URLSearchParams(body as string);
|
||||
// topicId base <hostUin>_<tid> is confirmed; the __1 suffix is on 2/3
|
||||
// impls (one omits it) — kept as the default, not asserted as canonical.
|
||||
expect(form.get('topicId')).toBe('20002_TIDX__1');
|
||||
// uin=commenter(self), hostUin=feed owner — verified not-swapped
|
||||
expect(form.get('uin')).toBe('10000');
|
||||
expect(form.get('hostUin')).toBe('20002');
|
||||
expect(form.get('content')).toBe('说得好 & 顶');
|
||||
expect(form.get('format')).toBe('json');
|
||||
// qzreferrer carries the commenter's own space (matches the impls)
|
||||
expect(form.get('qzreferrer')).toBe('https://user.qzone.qq.com/10000');
|
||||
// the re_feeds param family (3/3 impls) the publish sibling also sends
|
||||
expect(form.get('feedsType')).toBe('100');
|
||||
expect(form.get('private')).toBe('0');
|
||||
expect(form.get('paramstr')).toBe('1');
|
||||
});
|
||||
|
||||
it('threads image direct-url richval into the comment form', async () => {
|
||||
const spy = vi
|
||||
.spyOn(RequestUtil, 'HttpGetText')
|
||||
.mockResolvedValue('{"code":0,"commentid":987}');
|
||||
const richval = 'https://example.qzone.qq.com/a.jpg\thttps://example.qzone.qq.com/b.jpg';
|
||||
|
||||
await commentQzoneMsg(cookies, '10000', '20002', 'TIDX', 'pics', 1, richval);
|
||||
|
||||
const form = new URLSearchParams(spy.mock.calls[0]![2] as string);
|
||||
expect(form.get('richtype')).toBe('1');
|
||||
expect(form.get('richval')).toBe(richval);
|
||||
});
|
||||
|
||||
it('resolves with empty comment_id on success when the response carries no id (field name varies)', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue('{"code":0}');
|
||||
await expect(commentQzoneMsg(cookies, '10000', '20002', 'TIDX', 'hi')).resolves.toEqual({
|
||||
comment_id: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the commentId (camelCase) fallback field', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue('{"code":0,"commentId":"abc"}');
|
||||
await expect(commentQzoneMsg(cookies, '10000', '20002', 'TIDX', 'hi')).resolves.toEqual({
|
||||
comment_id: 'abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects empty tid or content before any request', async () => {
|
||||
const spy = vi.spyOn(RequestUtil, 'HttpGetText');
|
||||
await expect(commentQzoneMsg(cookies, '10000', '20002', '', 'hi')).rejects.toThrow(
|
||||
'tid is required',
|
||||
);
|
||||
await expect(commentQzoneMsg(cookies, '10000', '20002', 'TIDX', '')).rejects.toThrow(
|
||||
'content is required',
|
||||
);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects incomplete comment rich params before any request', async () => {
|
||||
const spy = vi.spyOn(RequestUtil, 'HttpGetText');
|
||||
await expect(commentQzoneMsg(cookies, '10000', '20002', 'TIDX', 'hi', 1)).rejects.toThrow(
|
||||
'richType and richval',
|
||||
);
|
||||
await expect(
|
||||
commentQzoneMsg(
|
||||
cookies,
|
||||
'10000',
|
||||
'20002',
|
||||
'TIDX',
|
||||
'hi',
|
||||
undefined,
|
||||
'https://example.com/img.jpg',
|
||||
),
|
||||
).rejects.toThrow('richType and richval');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws on a non-zero code (comments disabled / no permission)', async () => {
|
||||
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(
|
||||
'{"code":-3000,"message":"forbidden"}',
|
||||
);
|
||||
await expect(commentQzoneMsg(cookies, '10000', '20002', 'TIDX', 'hi')).rejects.toThrow(
|
||||
'comment failed: code=-3000',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,708 @@
|
||||
import http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RequestUtil } from '@snowluma/protocol/web/request-util';
|
||||
|
||||
const previousLogLevel = getLogLevel();
|
||||
|
||||
const servers = new Set<http.Server>();
|
||||
|
||||
async function serve(handler: http.RequestListener): Promise<{ server: http.Server; url: string }> {
|
||||
const server = http.createServer(handler);
|
||||
servers.add(server);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = server.address() as AddressInfo;
|
||||
return { server, url: `http://127.0.0.1:${address.port}` };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
setLogLevel(previousLogLevel);
|
||||
vi.useRealTimers();
|
||||
await Promise.all(
|
||||
[...servers].map(async (server) => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
servers.delete(server);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
function httpTrace(entries: LogEntry[]): LogEntry[] {
|
||||
return entries.filter((entry) => entry.level === 'trace' && entry.scope === 'Protocol.Http');
|
||||
}
|
||||
|
||||
describe('RequestUtil TRACE lifecycle', () => {
|
||||
it('records complete request and parsed response data under one request context', async () => {
|
||||
const { url } = await serve(async (req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
|
||||
expect(Buffer.concat(chunks).toString()).toBe('{"message":"request-secret"}');
|
||||
res.setHeader('X-Response-Token', 'response-secret-header');
|
||||
res.end('{"ok":true,"token":"response-secret-body"}');
|
||||
});
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
await expect(
|
||||
RequestUtil.HttpGetJson(
|
||||
`${url}/write?access_token=url-secret`,
|
||||
'POST',
|
||||
{ message: 'request-secret' },
|
||||
{
|
||||
Authorization: 'Bearer request-secret-header',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({ ok: true, token: 'response-secret-body' });
|
||||
|
||||
const trace = httpTrace(entries);
|
||||
expect(trace.length).toBeGreaterThanOrEqual(4);
|
||||
expect(
|
||||
trace.every((entry) => entry.req !== undefined && entry.req === trace[0]!.req),
|
||||
).toBe(true);
|
||||
expect(trace.map((entry) => entry.message)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('http_start method="POST"'),
|
||||
expect.stringContaining('access_token=url-secret'),
|
||||
expect.stringContaining('request-secret-header'),
|
||||
expect.stringContaining('request-secret'),
|
||||
expect.stringContaining('http_response status=200'),
|
||||
expect.stringContaining('response-secret-header'),
|
||||
expect.stringContaining('response-secret-body'),
|
||||
expect.stringContaining('http_branch branch=parse_completed'),
|
||||
expect.stringMatching(
|
||||
/^http_terminal method="POST" .* outcome=completed reason=response_complete elapsedMs=\d+$/,
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
trace.filter((entry) => entry.message.startsWith('http_terminal ')),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('records exact request and response bytes for binary HTTP data', async () => {
|
||||
const requestBytes = Uint8Array.from([0x00, 0xff, 0x80, 0x41]);
|
||||
const responseBytes = Buffer.from([0xde, 0xad, 0xbe, 0xef, 0x00]);
|
||||
const { url } = await serve(async (req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
|
||||
expect(Buffer.concat(chunks)).toEqual(Buffer.from(requestBytes));
|
||||
res.end(responseBytes);
|
||||
});
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
await expect(
|
||||
RequestUtil.HttpGetText(url, 'POST', requestBytes, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
}),
|
||||
).resolves.toBe(responseBytes.toString());
|
||||
|
||||
const messages = httpTrace(entries).map((entry) => entry.message);
|
||||
expect(messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('requestBytes=4 requestHex=00ff8041'),
|
||||
expect.stringContaining('bodyBytes=5 bodyHex=deadbeef00'),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('records redirect input changes and one non-2xx terminal without waiting for the body', async () => {
|
||||
const target = await serve((_req, res) => {
|
||||
res.writeHead(403, { 'X-Diagnostic': 'denied' });
|
||||
res.flushHeaders();
|
||||
res.write('body that never ends');
|
||||
});
|
||||
const source = await serve((_req, res) => {
|
||||
res.writeHead(302, { location: target.url });
|
||||
res.end();
|
||||
});
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
await expect(
|
||||
RequestUtil.HttpGetText(source.url, 'GET', undefined, {
|
||||
Cookie: 'uin=redirect-secret',
|
||||
}),
|
||||
).rejects.toThrow('Unexpected status code: 403');
|
||||
|
||||
const messages = httpTrace(entries).map((entry) => entry.message);
|
||||
expect(messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('http_branch branch=redirect status=302'),
|
||||
expect.stringContaining(`to=${JSON.stringify(`${target.url}/`)}`),
|
||||
expect.stringContaining('http_response status=403'),
|
||||
expect.stringContaining('bodyState=not_read'),
|
||||
expect.stringMatching(
|
||||
/^http_terminal method="GET" .* outcome=failed reason=non_2xx .*elapsedMs=\d+$/,
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(messages.filter((message) => message.startsWith('http_terminal '))).toHaveLength(
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['not a valid URL', 'file:///tmp/not-a-web-request'])(
|
||||
'classifies invalid initial request URL %j without falling back to transport or redirect failure',
|
||||
async (url) => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
await expect(RequestUtil.HttpGetText(url)).rejects.toThrow();
|
||||
const terminals = httpTrace(entries).filter((entry) =>
|
||||
entry.message.startsWith('http_terminal '),
|
||||
);
|
||||
expect(terminals).toHaveLength(1);
|
||||
expect(terminals[0]!.message).toContain('outcome=failed reason=request_invalid');
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['timeoutMs', { timeoutMs: 0 }],
|
||||
['maxResponseBytes', { maxResponseBytes: 0 }],
|
||||
] as const)('traces invalid %s before rejecting the request', async (_name, limits) => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
await expect(
|
||||
RequestUtil.HttpGetText('http://127.0.0.1/', 'GET', undefined, {}, limits),
|
||||
).rejects.toThrow('must be a positive safe integer');
|
||||
const trace = httpTrace(entries);
|
||||
expect(trace.some((entry) => entry.message.startsWith('http_start '))).toBe(true);
|
||||
const terminals = trace.filter((entry) => entry.message.startsWith('http_terminal '));
|
||||
expect(terminals).toHaveLength(1);
|
||||
expect(terminals[0]!.message).toContain('outcome=failed reason=request_invalid');
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'method token',
|
||||
run: () => RequestUtil.HttpGetText('http://127.0.0.1:1/', 'BAD METHOD'),
|
||||
},
|
||||
{
|
||||
name: 'header value',
|
||||
run: () =>
|
||||
RequestUtil.HttpGetText('http://127.0.0.1:1/', 'GET', undefined, {
|
||||
'X-Invalid': 'line one\nline two',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'circular JSON body',
|
||||
run: () => {
|
||||
const body: Record<string, unknown> = {};
|
||||
body.self = body;
|
||||
return RequestUtil.HttpGetJson('http://127.0.0.1:1/', 'POST', body);
|
||||
},
|
||||
},
|
||||
])('classifies an invalid local $name as request_invalid', async ({ run }) => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
await expect(run()).rejects.toThrow();
|
||||
const terminals = httpTrace(entries).filter((entry) =>
|
||||
entry.message.startsWith('http_terminal '),
|
||||
);
|
||||
expect(terminals).toHaveLength(1);
|
||||
expect(terminals[0]!.message).toContain('outcome=failed reason=request_invalid');
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'parse failure',
|
||||
run: async () => {
|
||||
const { url } = await serve((_req, res) => res.end('{invalid-json'));
|
||||
return RequestUtil.HttpGetJson(url);
|
||||
},
|
||||
reason: 'parse_failure',
|
||||
outcome: 'failed',
|
||||
bodyState: undefined,
|
||||
},
|
||||
{
|
||||
name: 'timeout',
|
||||
run: async () => {
|
||||
const { url } = await serve((_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.write('partial');
|
||||
});
|
||||
return RequestUtil.HttpGetText(url, 'GET', undefined, {}, { timeoutMs: 30 });
|
||||
},
|
||||
reason: 'deadline',
|
||||
outcome: 'timeout',
|
||||
bodyState: 'deadline',
|
||||
},
|
||||
{
|
||||
name: 'transport failure',
|
||||
run: async () => {
|
||||
const { url } = await serve((req) => req.socket.destroy());
|
||||
return RequestUtil.HttpGetText(url);
|
||||
},
|
||||
reason: 'transport_failure',
|
||||
outcome: 'failed',
|
||||
bodyState: undefined,
|
||||
},
|
||||
{
|
||||
name: 'response cancellation',
|
||||
run: async () => {
|
||||
const { url } = await serve((_req, res) => {
|
||||
res.writeHead(200, { 'Content-Length': '100' });
|
||||
res.flushHeaders();
|
||||
res.write('partial');
|
||||
setImmediate(() => res.destroy());
|
||||
});
|
||||
return RequestUtil.HttpGetText(url, 'GET', undefined, {}, { timeoutMs: 250 });
|
||||
},
|
||||
reason: 'response_aborted',
|
||||
outcome: 'cancelled',
|
||||
bodyState: 'aborted',
|
||||
},
|
||||
])('records a unique $name terminal', async ({ run, reason, outcome, bodyState }) => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
await expect(run()).rejects.toThrow();
|
||||
const terminals = httpTrace(entries).filter((entry) =>
|
||||
entry.message.startsWith('http_terminal '),
|
||||
);
|
||||
expect(terminals).toHaveLength(1);
|
||||
expect(terminals[0]!.message).toContain(`outcome=${outcome} reason=${reason}`);
|
||||
|
||||
if (bodyState !== undefined) {
|
||||
expect(httpTrace(entries).map((entry) => entry.message)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
`bodyState=${bodyState} bodyBytes=7 bodyHex=7061727469616c body="partial"`,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('RequestUtil cookie redirect TRACE lifecycle', () => {
|
||||
it('records collected cookies and a completed terminal across redirects', async () => {
|
||||
const { url } = await serve((req, res) => {
|
||||
if (req.url === '/start') {
|
||||
res.writeHead(302, {
|
||||
location: '/finish',
|
||||
'Set-Cookie': 'p_skey=first-secret; Path=/',
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Set-Cookie': 'skey=second-secret; Path=/' });
|
||||
res.end('ok');
|
||||
});
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
await expect(RequestUtil.HttpsGetCookies(`${url}/start`)).resolves.toEqual({
|
||||
p_skey: 'first-secret',
|
||||
skey: 'second-secret',
|
||||
});
|
||||
const trace = httpTrace(entries);
|
||||
expect(
|
||||
trace.every((entry) => entry.req !== undefined && entry.req === trace[0]!.req),
|
||||
).toBe(true);
|
||||
expect(trace.map((entry) => entry.message)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('http_cookie_start'),
|
||||
expect.stringContaining('branch=redirect'),
|
||||
expect.stringContaining('first-secret'),
|
||||
expect.stringContaining('second-secret'),
|
||||
expect.stringContaining('bodyBytes=2 bodyHex=6f6b body="ok"'),
|
||||
expect.stringMatching(
|
||||
/^http_cookie_terminal .* outcome=completed reason=response_complete .*elapsedMs=\d+$/,
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
trace.filter((entry) => entry.message.startsWith('http_cookie_terminal ')),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails open when a cookie response aborts after headers', async () => {
|
||||
const { url } = await serve((_req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Length': '100',
|
||||
'Set-Cookie': 'p_skey=partial-secret; Path=/',
|
||||
});
|
||||
res.flushHeaders();
|
||||
res.write('partial');
|
||||
setImmediate(() => res.destroy());
|
||||
});
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
await expect(RequestUtil.HttpsGetCookies(url)).resolves.toEqual({
|
||||
p_skey: 'partial-secret',
|
||||
});
|
||||
const terminals = httpTrace(entries).filter((entry) =>
|
||||
entry.message.startsWith('http_cookie_terminal '),
|
||||
);
|
||||
expect(terminals).toHaveLength(1);
|
||||
expect(terminals[0]!.message).toContain(
|
||||
'outcome=completed reason=transport_failure failOpen=true',
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('traces partial cookie response bytes before a fail-open deadline terminal', async () => {
|
||||
const { url } = await serve((_req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Length': '100',
|
||||
'Set-Cookie': 'p_skey=partial-secret; Path=/',
|
||||
});
|
||||
res.flushHeaders();
|
||||
res.write('partial');
|
||||
});
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
await expect(RequestUtil.HttpsGetCookies(url)).resolves.toEqual({
|
||||
p_skey: 'partial-secret',
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
const messages = httpTrace(entries).map((entry) => entry.message);
|
||||
const bodyIndex = messages.findIndex(
|
||||
(message) =>
|
||||
message.includes('http_cookie_body ') &&
|
||||
message.includes('bodyBytes=7 bodyHex=7061727469616c body="partial"'),
|
||||
);
|
||||
const terminalIndex = messages.findIndex(
|
||||
(message) =>
|
||||
message.includes('http_cookie_terminal ') &&
|
||||
message.includes('reason=deadline failOpen=true'),
|
||||
);
|
||||
expect(bodyIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalIndex).toBeGreaterThan(bodyIndex);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
it('records a fail-open transport terminal while returning cookies collected earlier', async () => {
|
||||
const { url } = await serve((req, res) => {
|
||||
if (req.url === '/start') {
|
||||
res.writeHead(302, {
|
||||
location: '/broken',
|
||||
'Set-Cookie': 'p_skey=collected-secret; Path=/',
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
req.socket.destroy(new Error('fixture cookie transport reset'));
|
||||
});
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
await expect(RequestUtil.HttpsGetCookies(`${url}/start`)).resolves.toEqual({
|
||||
p_skey: 'collected-secret',
|
||||
});
|
||||
const terminals = httpTrace(entries).filter((entry) =>
|
||||
entry.message.startsWith('http_cookie_terminal '),
|
||||
);
|
||||
expect(terminals).toHaveLength(1);
|
||||
expect(terminals[0]!.message).toContain(
|
||||
'outcome=completed reason=transport_failure failOpen=true',
|
||||
);
|
||||
expect(terminals[0]!.message).toContain('collected-secret');
|
||||
expect(terminals[0]!.message).toContain('socket hang up');
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('RequestUtil bounded text responses', () => {
|
||||
it('rejects a response once it exceeds the byte limit and traces every observed byte', async () => {
|
||||
const entries: LogEntry[] = [];
|
||||
const body = 'x'.repeat(65);
|
||||
const { url } = await serve((_req, res) => {
|
||||
res.end(body);
|
||||
});
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
await expect(
|
||||
RequestUtil.HttpGetText(url, 'GET', undefined, {}, { maxResponseBytes: 64 }),
|
||||
).rejects.toThrow('response body exceeds 64 bytes');
|
||||
|
||||
expect(httpTrace(entries).map((entry) => entry.message)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
`bodyState=too_large bodyBytes=65 bodyHex=${'78'.repeat(65)}`,
|
||||
),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a request that does not complete before its deadline', async () => {
|
||||
const { url } = await serve((_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.write('partial');
|
||||
});
|
||||
|
||||
await expect(
|
||||
RequestUtil.HttpGetText(url, 'GET', undefined, {}, { timeoutMs: 30 }),
|
||||
).rejects.toThrow('request timed out after 30 ms');
|
||||
});
|
||||
|
||||
it('measures the response limit in bytes and accepts the exact boundary', async () => {
|
||||
const exact = '界'.repeat(16);
|
||||
const { url } = await serve((_req, res) => {
|
||||
res.end(exact);
|
||||
});
|
||||
|
||||
await expect(
|
||||
RequestUtil.HttpGetText(
|
||||
url,
|
||||
'GET',
|
||||
undefined,
|
||||
{},
|
||||
{ maxResponseBytes: Buffer.byteLength(exact) },
|
||||
),
|
||||
).resolves.toBe(exact);
|
||||
});
|
||||
|
||||
it('rejects a multibyte response that exceeds the byte boundary', async () => {
|
||||
const tooLarge = '界'.repeat(17);
|
||||
const { url } = await serve((_req, res) => {
|
||||
res.end(tooLarge);
|
||||
});
|
||||
|
||||
await expect(
|
||||
RequestUtil.HttpGetText(url, 'GET', undefined, {}, { maxResponseBytes: 48 }),
|
||||
).rejects.toThrow('response body exceeds 48 bytes');
|
||||
});
|
||||
|
||||
it('closes a non-success response without waiting for its body to end', async () => {
|
||||
let markClosed!: () => void;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
markClosed = resolve;
|
||||
});
|
||||
const { url } = await serve((_req, res) => {
|
||||
res.once('close', markClosed);
|
||||
res.writeHead(403);
|
||||
res.flushHeaders();
|
||||
res.write('body that never ends');
|
||||
});
|
||||
|
||||
await expect(RequestUtil.HttpGetText(url)).rejects.toThrow('Unexpected status code: 403');
|
||||
await expect(
|
||||
Promise.race([
|
||||
closed.then(() => true),
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 250)),
|
||||
]),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RequestUtil redirects', () => {
|
||||
it('strips credentials when a redirect crosses origins', async () => {
|
||||
let redirectedHeaders: http.IncomingHttpHeaders | undefined;
|
||||
const target = await serve((req, res) => {
|
||||
redirectedHeaders = req.headers;
|
||||
res.end('ok');
|
||||
});
|
||||
const source = await serve((_req, res) => {
|
||||
res.writeHead(302, { location: target.url });
|
||||
res.end();
|
||||
});
|
||||
|
||||
await expect(
|
||||
RequestUtil.HttpGetText(source.url, 'GET', undefined, {
|
||||
Cookie: 'uin=secret',
|
||||
Authorization: 'Bearer secret',
|
||||
'Proxy-Authorization': 'Basic secret',
|
||||
'X-SnowLuma-Test': 'kept',
|
||||
}),
|
||||
).resolves.toBe('ok');
|
||||
|
||||
expect(redirectedHeaders?.cookie).toBeUndefined();
|
||||
expect(redirectedHeaders?.authorization).toBeUndefined();
|
||||
expect(redirectedHeaders?.['proxy-authorization']).toBeUndefined();
|
||||
expect(redirectedHeaders?.['x-snowluma-test']).toBe('kept');
|
||||
});
|
||||
|
||||
it('never forwards a write request body across origins', async () => {
|
||||
let targetReached = false;
|
||||
const target = await serve((_req, res) => {
|
||||
targetReached = true;
|
||||
res.end('unexpected');
|
||||
});
|
||||
const source = await serve((_req, res) => {
|
||||
res.writeHead(307, { location: target.url });
|
||||
res.end();
|
||||
});
|
||||
|
||||
await expect(
|
||||
RequestUtil.HttpGetText(source.url, 'POST', 'secret-body', {
|
||||
'Content-Type': 'text/plain',
|
||||
}),
|
||||
).rejects.toThrow('cross-origin redirect cannot forward method POST');
|
||||
expect(targetReached).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves credentials for same-origin redirects', async () => {
|
||||
let redirectedCookie: string | undefined;
|
||||
const { url } = await serve((req, res) => {
|
||||
if (req.url === '/start') {
|
||||
res.writeHead(302, { location: '/finish' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
redirectedCookie = req.headers.cookie;
|
||||
res.end('ok');
|
||||
});
|
||||
|
||||
await expect(
|
||||
RequestUtil.HttpGetText(`${url}/start`, 'GET', undefined, {
|
||||
Cookie: 'uin=same-origin',
|
||||
}),
|
||||
).resolves.toBe('ok');
|
||||
expect(redirectedCookie).toBe('uin=same-origin');
|
||||
});
|
||||
|
||||
it('inherits response limits after a redirect', async () => {
|
||||
const target = await serve((_req, res) => {
|
||||
res.end('x'.repeat(65));
|
||||
});
|
||||
const source = await serve((_req, res) => {
|
||||
res.writeHead(302, { location: target.url });
|
||||
res.end();
|
||||
});
|
||||
|
||||
await expect(
|
||||
RequestUtil.HttpGetText(source.url, 'GET', undefined, {}, { maxResponseBytes: 64 }),
|
||||
).rejects.toThrow('response body exceeds 64 bytes');
|
||||
});
|
||||
|
||||
it('shares one wall-clock deadline across the redirect chain', async () => {
|
||||
const target = await serve((_req, res) => {
|
||||
setTimeout(() => res.end('late'), 80);
|
||||
});
|
||||
const source = await serve((_req, res) => {
|
||||
setTimeout(() => {
|
||||
res.writeHead(302, { location: target.url });
|
||||
res.end();
|
||||
}, 80);
|
||||
});
|
||||
|
||||
await expect(
|
||||
RequestUtil.HttpGetText(source.url, 'GET', undefined, {}, { timeoutMs: 100 }),
|
||||
).rejects.toThrow('request timed out after 100 ms');
|
||||
});
|
||||
|
||||
it('rejects malformed redirect locations instead of throwing outside the Promise', async () => {
|
||||
const { url } = await serve((_req, res) => {
|
||||
res.writeHead(302, { location: 'http://[' });
|
||||
res.end();
|
||||
});
|
||||
|
||||
await expect(RequestUtil.HttpGetText(url)).rejects.toThrow('invalid redirect location');
|
||||
});
|
||||
|
||||
it('rejects redirects to non-HTTP protocols', async () => {
|
||||
const { url } = await serve((_req, res) => {
|
||||
res.writeHead(302, { location: 'file:///tmp/not-a-web-response' });
|
||||
res.end();
|
||||
});
|
||||
|
||||
await expect(RequestUtil.HttpGetText(url)).rejects.toThrow(
|
||||
'unsupported redirect protocol: file:',
|
||||
);
|
||||
});
|
||||
|
||||
it('closes the previous response before following a redirect', async () => {
|
||||
const target = await serve((_req, res) => {
|
||||
res.end('ok');
|
||||
});
|
||||
let markClosed!: () => void;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
markClosed = resolve;
|
||||
});
|
||||
const source = await serve((_req, res) => {
|
||||
res.once('close', markClosed);
|
||||
res.writeHead(302, { location: target.url });
|
||||
res.flushHeaders();
|
||||
res.write('body that never ends');
|
||||
});
|
||||
|
||||
await expect(RequestUtil.HttpGetText(source.url)).resolves.toBe('ok');
|
||||
await expect(
|
||||
Promise.race([
|
||||
closed.then(() => true),
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 250)),
|
||||
]),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user