Files
SnowLuma/packages/core/tests/actions/qzone.test.ts
T
TanTan fa9c73363d
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
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

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();
});
});