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
406 lines
18 KiB
TypeScript
406 lines
18 KiB
TypeScript
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();
|
|
});
|
|
});
|