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

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
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: '{&quot;id&quot;:&quot;pic-1&quot;,&quot;w&quot;:&quot;640&quot;,&quot;h&quot;:&quot;360&quot;}',
}),
),
).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 &amp; <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&amp;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&amp;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">&lt;b&gt;&#128512;<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(
'响应缺少必要字段',
);
});
});
+711
View File
@@ -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);
});
});