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
712 lines
29 KiB
TypeScript
712 lines
29 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).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',
|
|
);
|
|
});
|
|
});
|