Files
SnowLuma/packages/protocol/tests/web/qzone.test.ts
H3CoF6andClaude Opus 4.8 0d03b00643 Fix(Qzone) -10000风控修复 && Fix(port-probe) PID探测多pid逻辑修复和偶数端口 (#192)
* fix(protocol): retry qzone msglist via user.qzone route on -10000

emotion_cgi_msglist_v6 through the legacy h5.qzone.qq.com →
taotao.qzone.qq.com gateway can be rejected with `-10000 使用人数过多`
for some accounts (issue #182). Real-device captures show the same CGI
via user.qzone.qq.com → taotao.qq.com is not rate-limited, so retry once
through that route when the legacy route returns -10000.

Behaviour-preserving for accounts the legacy route already serves: the
retry only fires on a -10000 envelope; all other codes, transport
errors, and the missing-msglist case are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(port-probe): fix port probe logic (parse accounts more than 2) and add description for 4302-4310 port.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:33:02 +08:00

516 lines
24 KiB
TypeScript

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).toEqual({
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: { data: [] } })).toEqual({ feeds: [], has_more: false });
});
});
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":{"hasmore":1,"data":[{"uin":12345,"nickname":"Alice","abstime":1700000000,"appid":311,"key":"K1","html":"<div>a</div>"}]}});',
);
const out = await getQzoneFeeds(cookies, '10000', 2, 10);
expect(out).toEqual({
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('2');
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 });
});
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');
});
});