SnowLuma v2.4.2 source snapshot
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

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

+410
View File
@@ -0,0 +1,410 @@
import { describe, expect, it, vi } from 'vitest';
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import { ApiHandler, type ApiActionContext } from '../src/api-handler';
import { actions } from '../src/actions/qzone';
function makeHandler(qzone: Record<string, unknown>): ApiHandler {
const bridge = { apis: { qzone } } as unknown as BridgeInterface;
const ctx = { bridge } as ApiActionContext;
return new ApiHandler(ctx);
}
describe('qzone actions', () => {
it.each([undefined, '20002'])(
'get_qzone_profile returns the requested profile and normalizes owner %s',
async (targetUin) => {
const profile = { name: 'My space', description: 'Notes' };
const getProfile = vi.fn().mockResolvedValue(profile);
const params = targetUin === undefined ? {} : { target_uin: targetUin };
expect(
await makeHandler({ getProfile }).handle('get_qzone_profile', params),
).toMatchObject({
status: 'ok',
retcode: 0,
data: profile,
});
expect(getProfile).toHaveBeenCalledWith(targetUin === undefined ? undefined : 20002);
},
);
it.each([0, 'bad'])(
'get_qzone_profile rejects invalid owners before fetching (%s)',
async (targetUin) => {
const getProfile = vi.fn();
expect(
await makeHandler({ getProfile }).handle('get_qzone_profile', {
target_uin: targetUin,
}),
).toMatchObject({
status: 'failed',
retcode: 1400,
});
expect(getProfile).not.toHaveBeenCalled();
},
);
it.each(['get_qzone_profile', 'set_qzone_profile'])(
'%s exposes upstream errors',
async (action) => {
const failure = new Error('qzone profile failed: code=-3000 need login');
const method = vi.fn().mockRejectedValue(failure);
const handler = makeHandler({ getProfile: method, setProfile: method });
const params = action === 'set_qzone_profile' ? { name: 'New name' } : {};
const response = await handler.handle(action, params);
expect(response).toMatchObject({ status: 'failed', retcode: 100 });
expect(response.wording).toContain('code=-3000 need login');
},
);
it.each([{ name: 'New name' }, { description: '' }, { name: '', description: 'New notes' }])(
'set_qzone_profile forwards supplied fields and preserves explicit empty values (%j)',
async (params) => {
const setProfile = vi.fn().mockResolvedValue(undefined);
expect(
await makeHandler({ setProfile }).handle('set_qzone_profile', params),
).toMatchObject({
status: 'ok',
retcode: 0,
data: null,
});
expect(setProfile).toHaveBeenCalledWith({
name: params.name,
description: params.description,
});
},
);
it.each([{}, { target_uin: 20002 }, { name: {} }, { description: [] }])(
'set_qzone_profile rejects missing or invalid updates before writing (%j)',
async (params) => {
const setProfile = vi.fn();
expect(
await makeHandler({ setProfile }).handle('set_qzone_profile', params),
).toMatchObject({
status: 'failed',
retcode: 1400,
});
expect(setProfile).not.toHaveBeenCalled();
},
);
it('set_qzone_profile cannot select a different account', async () => {
const setProfile = vi.fn().mockResolvedValue(undefined);
await makeHandler({ setProfile }).handle('set_qzone_profile', {
name: 'New name',
target_uin: 20002,
});
expect(setProfile).toHaveBeenCalledWith({ name: 'New name', description: undefined });
});
it('classifies profile reads and writes separately', () => {
expect(
actions.find((action) => action.names.includes('get_qzone_profile'))?.describe()
.readOnly,
).toBe(true);
expect(
actions.find((action) => action.names.includes('set_qzone_profile'))?.describe()
.readOnly,
).toBe(false);
});
it('get_qzone_feeds retains defaults and forwards optional continuation without stripping result fields', async () => {
const result = { feeds: [], has_more: true, next_cursor: 'next' };
const getFeeds = vi.fn().mockResolvedValue(result);
const handler = makeHandler({ getFeeds });
expect(await handler.handle('get_qzone_feeds', {})).toMatchObject({
status: 'ok',
data: result,
});
expect(getFeeds).toHaveBeenLastCalledWith(1, 10, undefined);
await handler.handle('get_qzone_feeds', { cursor: 'opaque-cursor', count: '20' });
expect(getFeeds).toHaveBeenLastCalledWith(1, 20, 'opaque-cursor');
expect(
actions.find((action) => action.names.includes('get_qzone_feeds'))?.describe().readOnly,
).toBe(true);
});
it.each([{ cursor: '' }, { cursor: 'a'.repeat(65537) }, { count: 51 }, { page_num: 0 }])(
'get_qzone_feeds rejects invalid pagination before calling the bridge',
async (params) => {
const getFeeds = vi.fn();
expect(await makeHandler({ getFeeds }).handle('get_qzone_feeds', params)).toMatchObject(
{ status: 'failed', retcode: 1400 },
);
expect(getFeeds).not.toHaveBeenCalled();
},
);
it('get_qzone_feeds exposes continuation failures to callers', async () => {
const getFeeds = vi.fn().mockRejectedValue(new Error('pagination did not advance'));
expect(
await makeHandler({ getFeeds }).handle('get_qzone_feeds', { cursor: 'opaque' }),
).toMatchObject({ status: 'failed', wording: 'pagination did not advance' });
});
it('get_qzone_msg_detail returns the comment page with default parameters', async () => {
const detail = {
tid: 'T1',
content: 'Post',
comment_num: 1,
comments: [
{
uin: 20002,
name: 'Reader',
time: 1700000000,
content: 'Hello',
comment_id: '0',
replies: [],
},
],
};
const getMsgDetail = vi.fn().mockResolvedValue(detail);
const res = await makeHandler({ getMsgDetail }).handle('get_qzone_msg_detail', {
tid: 'T1',
});
expect(res).toMatchObject({ status: 'ok', retcode: 0, data: detail });
expect(getMsgDetail).toHaveBeenCalledWith('T1', undefined, 0, 20);
expect(
actions.find((action) => action.names.includes('get_qzone_msg_detail'))?.describe()
.readOnly,
).toBe(true);
});
it('get_qzone_msg_detail accepts HTTP query strings for owner and pagination', async () => {
const getMsgDetail = vi
.fn()
.mockResolvedValue({ tid: 'T1', content: '', comment_num: 0, comments: [] });
const res = await makeHandler({ getMsgDetail }).handle('get_qzone_msg_detail', {
tid: 'T1',
target_uin: '20002',
pos: '5',
num: '10',
});
expect(res).toMatchObject({ status: 'ok', retcode: 0 });
expect(getMsgDetail).toHaveBeenCalledWith('T1', 20002, 5, 10);
});
it.each([
{},
{ tid: '' },
{ tid: 'T1', target_uin: 0 },
{ tid: 'T1', target_uin: 'bad' },
{ tid: 'T1', pos: -1 },
{ tid: 'T1', num: 0 },
{ tid: 'T1', num: 101 },
])('get_qzone_msg_detail rejects invalid params (%j) before fetching', async (params) => {
const getMsgDetail = vi.fn();
const res = await makeHandler({ getMsgDetail }).handle('get_qzone_msg_detail', params);
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
expect(getMsgDetail).not.toHaveBeenCalled();
});
it('get_qzone_msg_detail exposes upstream failures', async () => {
const getMsgDetail = vi
.fn()
.mockRejectedValue(new Error('qzone msg detail failed: code=-3000 need login'));
const res = await makeHandler({ getMsgDetail }).handle('get_qzone_msg_detail', {
tid: 'T1',
});
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
expect(res.wording).toContain('code=-3000 need login');
});
it('send_qzone_msg uploads images and publishes richvals joined with tab', async () => {
const uploadImageFromSource = vi
.fn()
.mockResolvedValueOnce({ richval: 'RV1', url: 'URL1' })
.mockResolvedValueOnce({ richval: 'RV2', url: 'URL2' });
const publish = vi.fn().mockResolvedValue({ tid: 'T1', time: 1 });
const handler = makeHandler({ uploadImageFromSource, publish });
const res = await handler.handle('send_qzone_msg', {
content: 'hello',
images: ['file:///a.jpg', 'file:///b.jpg'],
});
expect(res).toMatchObject({ status: 'ok', retcode: 0, data: { tid: 'T1', time: 1 } });
expect(uploadImageFromSource).toHaveBeenNthCalledWith(1, 'file:///a.jpg');
expect(uploadImageFromSource).toHaveBeenNthCalledWith(2, 'file:///b.jpg');
expect(publish).toHaveBeenCalledWith('hello', 1, 'RV1\tRV2', 1, undefined);
});
it('comment_qzone uploads images and comments direct urls joined with tab', async () => {
const uploadImageFromSource = vi
.fn()
.mockResolvedValueOnce({ richval: 'RV1', url: 'URL1' })
.mockResolvedValueOnce({ richval: 'RV2', url: 'URL2' });
const comment = vi.fn().mockResolvedValue({ comment_id: 'C1' });
const handler = makeHandler({ uploadImageFromSource, comment });
const res = await handler.handle('comment_qzone', {
tid: 'T1',
content: 'nice',
target_uin: 20002,
images: ['file:///a.jpg', 'file:///b.jpg'],
});
expect(res).toMatchObject({ status: 'ok', retcode: 0, data: { comment_id: 'C1' } });
expect(comment).toHaveBeenCalledWith('T1', 'nice', 20002, 1, 'URL1\tURL2');
});
it('comment_qzone replies within the selected parent without changing the post owner', async () => {
const result = { comment_id: '8', parent_comment_id: '0', parent_comment_uin: 30003 };
const comment = vi.fn().mockResolvedValue(result);
const res = await makeHandler({ comment }).handle('comment_qzone', {
tid: 'T1',
content: 'Reply',
target_uin: '20002',
comment_id: '0',
comment_uin: '30003',
reply_uin: '40004',
reply_name: 'Reader',
});
expect(res).toMatchObject({ status: 'ok', retcode: 0, data: result });
expect(comment).toHaveBeenCalledWith('T1', 'Reply', 20002, undefined, undefined, {
commentId: '0',
commentUin: '30003',
replyTo: { uin: '40004', name: 'Reader' },
});
});
it('accepts parent ID zero without a nested recipient and leaves ordinary text comments unchanged', async () => {
const comment = vi.fn().mockResolvedValue({ comment_id: '8' });
const handler = makeHandler({ comment });
await handler.handle('comment_qzone', {
tid: 'T1',
content: 'Reply',
comment_id: 0,
comment_uin: 30003,
images: [],
});
expect(comment).toHaveBeenNthCalledWith(1, 'T1', 'Reply', undefined, undefined, undefined, {
commentId: '0',
commentUin: '30003',
});
await handler.handle('comment_qzone', { tid: 'T1', content: 'Comment' });
expect(comment).toHaveBeenNthCalledWith(2, 'T1', 'Comment', undefined);
});
it('exposes reply failures without falling back to an ordinary comment', async () => {
const comment = vi
.fn()
.mockRejectedValue(new Error('qzone reply failed: code=-3000 denied'));
const res = await makeHandler({ comment }).handle('comment_qzone', {
tid: 'T1',
content: 'Reply',
comment_id: '0',
comment_uin: 30003,
});
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
expect(res.wording).toContain('code=-3000 denied');
expect(comment).toHaveBeenCalledTimes(1);
});
it.each([
{ comment_id: '0' },
{ comment_uin: 30003 },
{ comment_id: '', comment_uin: 30003 },
{ comment_id: ' ', comment_uin: 30003 },
{ comment_id: '0', comment_uin: 0 },
{ comment_id: '0', comment_uin: 30003, reply_uin: 40004 },
{ comment_id: '0', comment_uin: 30003, reply_name: 'Reader' },
{ comment_id: '0', comment_uin: 30003, reply_uin: 40004, reply_name: ' ' },
{ reply_uin: 40004, reply_name: 'Reader' },
{ comment_id: '0', comment_uin: 30003, images: ['file:///a.jpg'] },
])('rejects invalid reply parameters before uploads or comments (%j)', async (params) => {
const comment = vi.fn();
const uploadImageFromSource = vi.fn();
const res = await makeHandler({ comment, uploadImageFromSource }).handle('comment_qzone', {
tid: 'T1',
content: 'Reply',
...params,
});
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
expect(comment).not.toHaveBeenCalled();
expect(uploadImageFromSource).not.toHaveBeenCalled();
});
it('returns a clear error and skips publish when one image upload fails', async () => {
const uploadImageFromSource = vi
.fn()
.mockResolvedValueOnce({ richval: 'RV1', url: 'URL1' })
.mockRejectedValueOnce(new Error('network down'));
const publish = vi.fn();
const handler = makeHandler({ uploadImageFromSource, publish });
const res = await handler.handle('send_qzone_msg', {
content: 'hello',
images: ['file:///a.jpg', 'file:///b.jpg'],
});
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
expect(res.wording).toContain('第 2 张图片上传失败: network down');
expect(publish).not.toHaveBeenCalled();
});
it('set_qzone_msg_right joins target_uins with | and passes through', async () => {
const updateRight = vi.fn().mockResolvedValue({ ugc_right: 16 });
const handler = makeHandler({ updateRight });
const res = await handler.handle('set_qzone_msg_right', {
tid: 'T1',
ugc_right: 16,
target_uins: [10001, 10002],
});
expect(res).toMatchObject({ status: 'ok', retcode: 0, data: { ugc_right: 16 } });
expect(updateRight).toHaveBeenCalledWith('T1', 16, '10001|10002');
});
it('set_qzone_msg_right rejects invalid ugc_right and 16/128 without target_uins', async () => {
const updateRight = vi.fn();
const handler = makeHandler({ updateRight });
const bad = await handler.handle('set_qzone_msg_right', { tid: 'T1', ugc_right: 2 });
expect(bad).toMatchObject({ status: 'failed' });
const missing = await handler.handle('set_qzone_msg_right', { tid: 'T1', ugc_right: 128 });
expect(missing).toMatchObject({ status: 'failed' });
expect(updateRight).not.toHaveBeenCalled();
});
it('set_qzone_ban with enable=true calls setBlack with ban=true', async () => {
const setBlack = vi.fn().mockResolvedValue(undefined);
const handler = makeHandler({ setBlack });
const res = await handler.handle('set_qzone_ban', { user_id: 12345, enable: true });
expect(res).toMatchObject({ status: 'ok', retcode: 0 });
expect(setBlack).toHaveBeenCalledWith(12345, true);
});
it('set_qzone_ban with enable=false calls setBlack with ban=false', async () => {
const setBlack = vi.fn().mockResolvedValue(undefined);
const handler = makeHandler({ setBlack });
const res = await handler.handle('set_qzone_ban', { user_id: 12345, enable: false });
expect(res).toMatchObject({ status: 'ok', retcode: 0 });
expect(setBlack).toHaveBeenCalledWith(12345, false);
});
it('set_qzone_ban defaults enable to true', async () => {
const setBlack = vi.fn().mockResolvedValue(undefined);
const handler = makeHandler({ setBlack });
const res = await handler.handle('set_qzone_ban', { user_id: 12345 });
expect(res).toMatchObject({ status: 'ok', retcode: 0 });
expect(setBlack).toHaveBeenCalledWith(12345, true);
});
});