import { describe, it, expect, vi, afterEach } from 'vitest'; import * as qzoneWeb from '@snowluma/protocol/web/qzone'; import { QzoneApi } from '../../src/bridge/apis/qzone'; import { mockApiHub, mockBridge } from './_helpers'; // QzoneApi is the only place the string(identity.uin)/number(action param) // boundary is crossed, so its target_uin defaulting + the `> 0` guard are // worth locking. We stub the protocol-layer getQzoneMsgList and the web // cookie fetch so the test asserts purely what the bridge passes down. describe('apis/qzone', () => { afterEach(() => vi.restoreAllMocks()); function bridgeWithWeb() { const getCookies = vi.fn(async () => ({ p_skey: 'PSK' })); const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never }); const fetchSpy = vi .spyOn(qzoneWeb, 'getQzoneMsgList') .mockResolvedValue({ total: 0, msglist: [] }); return { bridge, getCookies, fetchSpy }; } it.each([undefined, 20002])( 'getProfile reads owner %s with the current account credentials', async (targetUin) => { const { bridge, getCookies } = bridgeWithWeb(); const profile = { name: 'My space', description: 'Notes' }; const getProfile = vi.spyOn(qzoneWeb, 'getQzoneProfile').mockResolvedValue(profile); expect(await new QzoneApi(bridge as never).getProfile(targetUin)).toEqual(profile); expect(getCookies).toHaveBeenCalledWith('qzone.qq.com'); expect(getProfile).toHaveBeenCalledWith( { p_skey: 'PSK' }, '10001', targetUin?.toString(), ); }, ); it('getProfile propagates cookie and protocol failures', async () => { const { bridge, getCookies } = bridgeWithWeb(); const api = new QzoneApi(bridge as never); const getProfile = vi .spyOn(qzoneWeb, 'getQzoneProfile') .mockRejectedValue(new Error('denied')); await expect(api.getProfile(20002)).rejects.toThrow('denied'); getCookies.mockRejectedValueOnce(new Error('cookie failure')); await expect(api.getProfile()).rejects.toThrow('cookie failure'); expect(getProfile).toHaveBeenCalledTimes(1); }); it.each([{ name: 'New name' }, { description: '' }, { name: '', description: 'New notes' }])( 'setProfile writes only the supplied fields to the current account (%j)', async (profile) => { const { bridge, getCookies } = bridgeWithWeb(); const setProfile = vi.spyOn(qzoneWeb, 'setQzoneProfile').mockResolvedValue(); await new QzoneApi(bridge as never).setProfile(profile); expect(getCookies).toHaveBeenCalledWith('qzone.qq.com'); expect(setProfile).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', profile); }, ); it('setProfile propagates cookie and protocol failures', async () => { const { bridge, getCookies } = bridgeWithWeb(); const api = new QzoneApi(bridge as never); const setProfile = vi .spyOn(qzoneWeb, 'setQzoneProfile') .mockRejectedValue(new Error('captcha required')); await expect(api.setProfile({ name: 'New name' })).rejects.toThrow('captcha required'); getCookies.mockRejectedValueOnce(new Error('cookie failure')); await expect(api.setProfile({ description: 'Notes' })).rejects.toThrow('cookie failure'); expect(setProfile).toHaveBeenCalledTimes(1); }); it.each([undefined, 20002])( 'getMsgDetail uses current-account credentials for owner %s', async (targetUin) => { const { bridge, getCookies } = bridgeWithWeb(); const detail = { tid: 'T1', content: 'Post', comment_num: 0, comments: [] }; const fetchDetail = vi.spyOn(qzoneWeb, 'getQzoneMsgDetail').mockResolvedValue(detail); const result = await new QzoneApi(bridge as never).getMsgDetail('T1', targetUin); expect(result).toEqual(detail); expect(getCookies).toHaveBeenCalledWith('qzone.qq.com'); expect(fetchDetail).toHaveBeenCalledWith( { p_skey: 'PSK' }, String(targetUin ?? 10001), 'T1', 0, 20, ); }, ); it('getMsgDetail passes pagination and propagates cookie and protocol failures', async () => { const { bridge, getCookies } = bridgeWithWeb(); const api = new QzoneApi(bridge as never); const fetchDetail = vi .spyOn(qzoneWeb, 'getQzoneMsgDetail') .mockRejectedValue(new Error('denied')); await expect(api.getMsgDetail('T1', 20002, 10, 5)).rejects.toThrow('denied'); expect(fetchDetail).toHaveBeenCalledWith({ p_skey: 'PSK' }, '20002', 'T1', 10, 5); getCookies.mockRejectedValueOnce(new Error('cookie failure')); await expect(api.getMsgDetail('T1')).rejects.toThrow('cookie failure'); expect(fetchDetail).toHaveBeenCalledTimes(1); }); it("defaults target_uin to the bot's own uin and uses default pos/num", async () => { const { bridge, getCookies, fetchSpy } = bridgeWithWeb(); await new QzoneApi(bridge as never).getMsgList(); expect(getCookies).toHaveBeenCalledWith('qzone.qq.com'); // identity.uin is '10001' (a string) — passed straight through. expect(fetchSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', 0, 20); }); it('treats target_uin 0 as absent and falls back to own uin', async () => { const { bridge, fetchSpy } = bridgeWithWeb(); await new QzoneApi(bridge as never).getMsgList(0); expect(fetchSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', 0, 20); }); it('passes a real target_uin (stringified) plus pos/num through', async () => { const { bridge, fetchSpy } = bridgeWithWeb(); await new QzoneApi(bridge as never).getMsgList(20002, 5, 50); expect(fetchSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '20002', 5, 50); }); it("getFeeds always uses the bot's own uin and threads pageNum/count", async () => { const getCookies = vi.fn(async () => ({ p_skey: 'PSK' })); const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never }); const feedsSpy = vi .spyOn(qzoneWeb, 'getQzoneFeeds') .mockResolvedValue({ feeds: [], has_more: false, next_cursor: null }); await new QzoneApi(bridge as never).getFeeds(3, 20); expect(getCookies).toHaveBeenCalledWith('qzone.qq.com'); expect(feedsSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', 3, 20, undefined); await new QzoneApi(bridge as never).getFeeds(1, 20, 'opaque-cursor'); expect(feedsSpy).toHaveBeenLastCalledWith( { p_skey: 'PSK' }, '10001', 1, 20, 'opaque-cursor', ); }); it('uploadImageFromSource uploads from file:// http:// base64:// and returns richval + url', async () => { const getCookies = vi.fn(async () => ({ p_skey: 'PSK', skey: 'SK' })); const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never }); const uploadSpy = vi.spyOn(qzoneWeb, 'uploadQzoneImageFromSource').mockResolvedValue({ richval: ',12345,abc,abc,22,800,600,,800,600', url: 'https://example.com/img.jpg', albumid: '12345', lloc: 'abc', type: 22, width: 600, height: 800, }); const out = await new QzoneApi(bridge as never).uploadImageFromSource( 'file:///path/to/image.jpg', ); expect(getCookies).toHaveBeenCalledWith('qzone.qq.com'); expect(uploadSpy).toHaveBeenCalledWith( { p_skey: 'PSK', skey: 'SK' }, '10001', 'file:///path/to/image.jpg', ); expect(out.richval).toBe(',12345,abc,abc,22,800,600,,800,600'); expect(out.url).toBe('https://example.com/img.jpg'); }); it("publish posts to the bot's own space with the given content (text-only)", async () => { const getCookies = vi.fn(async () => ({ p_skey: 'PSK' })); const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never }); const pubSpy = vi .spyOn(qzoneWeb, 'publishQzoneMsg') .mockResolvedValue({ tid: 'T', time: 1 }); const out = await new QzoneApi(bridge as never).publish('hello'); expect(getCookies).toHaveBeenCalledWith('qzone.qq.com'); expect(pubSpy).toHaveBeenCalledWith( { p_skey: 'PSK' }, '10001', 'hello', undefined, undefined, 1, undefined, ); expect(out).toEqual({ tid: 'T', time: 1 }); }); it('publish posts with richtype and richval for image post', async () => { const getCookies = vi.fn(async () => ({ p_skey: 'PSK' })); const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never }); const pubSpy = vi .spyOn(qzoneWeb, 'publishQzoneMsg') .mockResolvedValue({ tid: 'T2', time: 2 }); const richval = ',12345,abc,abc,22,800,600,,800,600'; const out = await new QzoneApi(bridge as never).publish('look at this', 1, richval); expect(getCookies).toHaveBeenCalledWith('qzone.qq.com'); expect(pubSpy).toHaveBeenCalledWith( { p_skey: 'PSK' }, '10001', 'look at this', 1, richval, 1, undefined, ); expect(out).toEqual({ tid: 'T2', time: 2 }); }); it('publish posts with multiple images (richval joined with tab)', async () => { const getCookies = vi.fn(async () => ({ p_skey: 'PSK' })); const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never }); const pubSpy = vi .spyOn(qzoneWeb, 'publishQzoneMsg') .mockResolvedValue({ tid: 'T3', time: 3 }); const richval = ',12345,a,a,22,800,600,,800,600\t,12346,b,b,22,1024,768,,1024,768'; const out = await new QzoneApi(bridge as never).publish('two images', 1, richval); expect(getCookies).toHaveBeenCalledWith('qzone.qq.com'); expect(pubSpy).toHaveBeenCalledWith( { p_skey: 'PSK' }, '10001', 'two images', 1, richval, 1, undefined, ); expect(out).toEqual({ tid: 'T3', time: 3 }); }); it('publish threads qzone visibility params', async () => { const getCookies = vi.fn(async () => ({ p_skey: 'PSK' })); const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never }); const pubSpy = vi .spyOn(qzoneWeb, 'publishQzoneMsg') .mockResolvedValue({ tid: 'T', time: 1 }); await new QzoneApi(bridge as never).publish( 'hello', undefined, undefined, 16, '10002|10003', ); expect(pubSpy).toHaveBeenCalledWith( { p_skey: 'PSK' }, '10001', 'hello', undefined, undefined, 16, '10002|10003', ); }); it("delete removes a feed by tid from the bot's own space", async () => { const getCookies = vi.fn(async () => ({ p_skey: 'PSK' })); const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never }); const delSpy = vi.spyOn(qzoneWeb, 'deleteQzoneMsg').mockResolvedValue(); await new QzoneApi(bridge as never).delete('TID9'); expect(getCookies).toHaveBeenCalledWith('qzone.qq.com'); expect(delSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', 'TID9'); }); it("updateRight changes a feed's visibility on the bot's own space", async () => { const getCookies = vi.fn(async () => ({ p_skey: 'PSK' })); const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never }); const updSpy = vi .spyOn(qzoneWeb, 'updateQzoneMsgRight') .mockResolvedValue({ ugc_right: 16 }); const out = await new QzoneApi(bridge as never).updateRight('TID9', 16, '10002|10003'); expect(getCookies).toHaveBeenCalledWith('qzone.qq.com'); expect(updSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', 'TID9', 16, '10002|10003'); expect(out).toEqual({ ugc_right: 16 }); }); it('like defaults the feed owner to self and passes opUin=self + like flag', async () => { const getCookies = vi.fn(async () => ({ p_skey: 'PSK' })); const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never }); const likeSpy = vi.spyOn(qzoneWeb, 'setQzoneLike').mockResolvedValue(); await new QzoneApi(bridge as never).like('TIDX', undefined, true, 1700000000); // opUin = self ('10001'), owner defaults to self when target_uin absent; abstime threaded expect(likeSpy).toHaveBeenCalledWith( { p_skey: 'PSK' }, '10001', '10001', 'TIDX', true, 1700000000, ); }); it("like targets a friend's space (owner = target_uin) and threads the unlike flag + abstime default", async () => { const getCookies = vi.fn(async () => ({ p_skey: 'PSK' })); const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never }); const likeSpy = vi.spyOn(qzoneWeb, 'setQzoneLike').mockResolvedValue(); await new QzoneApi(bridge as never).like('TIDX', 20002, false); // abstime defaults to 0 when omitted expect(likeSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', '20002', 'TIDX', false, 0); }); it('comment defaults the feed owner to self and posts as self', async () => { const getCookies = vi.fn(async () => ({ p_skey: 'PSK' })); const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never }); const cmtSpy = vi.spyOn(qzoneWeb, 'commentQzoneMsg').mockResolvedValue({ comment_id: '1' }); const out = await new QzoneApi(bridge as never).comment('TIDX', 'nice', undefined); // selfUin='10001' commenter; owner defaults to self expect(cmtSpy).toHaveBeenCalledWith( { p_skey: 'PSK' }, '10001', '10001', 'TIDX', 'nice', undefined, undefined, ); expect(out).toEqual({ comment_id: '1' }); }); it("comment targets a friend's feed (owner = target_uin), commenter stays self", async () => { const getCookies = vi.fn(async () => ({ p_skey: 'PSK' })); const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never }); const cmtSpy = vi.spyOn(qzoneWeb, 'commentQzoneMsg').mockResolvedValue({ comment_id: '2' }); await new QzoneApi(bridge as never).comment('TIDX', 'nice', 20002); expect(cmtSpy).toHaveBeenCalledWith( { p_skey: 'PSK' }, '10001', '20002', 'TIDX', 'nice', undefined, undefined, ); }); it('comment with images (richType=1, richval=direct URLs joined with tab)', async () => { const getCookies = vi.fn(async () => ({ p_skey: 'PSK' })); const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never }); const cmtSpy = vi.spyOn(qzoneWeb, 'commentQzoneMsg').mockResolvedValue({ comment_id: '3' }); const richval = 'https://example.com/a.jpg\thttps://example.com/b.jpg'; await new QzoneApi(bridge as never).comment('TIDX', 'nice pic', undefined, 1, richval); expect(cmtSpy).toHaveBeenCalledWith( { p_skey: 'PSK' }, '10001', '10001', 'TIDX', 'nice pic', 1, richval, ); }); it.each([undefined, 20002])( 'routes replies with the account cookies and post owner (%s)', async (targetUin) => { const getCookies = vi.fn(async () => ({ p_skey: 'PSK' })); const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never }); const target = { commentId: '0', commentUin: '30003', replyTo: { uin: '40004', name: 'Reader' }, }; const result = { comment_id: '8', parent_comment_id: '0', parent_comment_uin: 30003 }; const replySpy = vi.spyOn(qzoneWeb, 'replyQzoneComment').mockResolvedValue(result); const commentSpy = vi.spyOn(qzoneWeb, 'commentQzoneMsg'); const out = await new QzoneApi(bridge as never).comment( 'TIDX', 'Reply', targetUin, undefined, undefined, target, ); expect(out).toEqual(result); expect(getCookies).toHaveBeenCalledWith('qzone.qq.com'); expect(replySpy).toHaveBeenCalledWith( { p_skey: 'PSK' }, '10001', String(targetUin ?? 10001), 'TIDX', 'Reply', target, ); expect(commentSpy).not.toHaveBeenCalled(); }, ); it('rejects reply images before acquiring cookies or sending', async () => { const getCookies = vi.fn(); const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never }); await expect( new QzoneApi(bridge as never).comment('TIDX', 'Reply', undefined, 1, 'URL', { commentId: '0', commentUin: '30003', }), ).rejects.toThrow('images are not supported'); expect(getCookies).not.toHaveBeenCalled(); }); });