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('nope')).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).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).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: '
a
', }, { uin: '67890', nickname: 'Bob', abstime: '1700000050', appid: '4', feedskey: 'FK2', html: '
b
', }, ], }, }); expect(out).toMatchObject({ has_more: true, feeds: [ { uin: 12345, nickname: 'Alice', time: 1700000000, appid: 311, key: 'K1', html: '
a
', }, { uin: 67890, nickname: 'Bob', time: 1700000050, appid: 4, key: 'FK2', html: '
b
', }, ], }); }); 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":"
a
"}]}});', ); 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: '
a
', }, ], }); const [url, method, body, headers] = spy.mock.calls[0]!; expect(method).toBe('GET'); expect(body).toBe(''); expect((headers as Record).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; 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; 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; 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; 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 _ 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', ); }); });