Files
SnowLuma/packages/onebot/tests/qzone-public-actions.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

374 lines
13 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import { ApiHandler, type ApiActionContext } from '../src/api-handler';
import { ACTION_GROUPS } from '../src/actions';
import { actions } from '../src/actions/qzone-public';
import type { JsonObject } from '../src/types';
const feed = {
cell_common: {
union_id: { feed_id: 'opaque-id', ext_ids: [{ future: true }] },
cur_like_key: 'like-key',
},
cell_feed_transfer: { opaque: [1, 'token'] },
cell_future: { kept: true },
};
const comment = {
id: 'comment-id',
user: { uid: 'commenter' },
reply_attach_info: 'comment-cursor',
};
const reply = { id: 'reply-id', user: { uid: 'recipient' }, future: true };
const result = { unknown: { preserved: [1, 2] } };
function makeHandler(qzone: Record<string, unknown>): ApiHandler {
const bridge = { apis: { qzone } } as unknown as BridgeInterface;
return new ApiHandler({ bridge } as ApiActionContext);
}
const cases: {
action: string;
method: string;
params: JsonObject;
args: unknown[];
readOnly: boolean;
}[] = [
{
action: 'get_qzone_home_feeds',
method: 'getHomeFeeds',
params: { target_uin: '10001', cursor: 'cursor' },
args: [10001, 'cursor'],
readOnly: true,
},
{
action: 'get_qzone_feed_detail',
method: 'getFeedDetail',
params: { tid: '1127000000f1536501000000' },
args: ['1127000000f1536501000000'],
readOnly: true,
},
{
action: 'get_qzone_share_ark',
method: 'getShareArk',
params: { feed },
args: [feed],
readOnly: false,
},
{
action: 'get_qzone_public_capabilities',
method: 'getPublicCapabilities',
params: {},
args: [],
readOnly: true,
},
{
action: 'get_qzone_public_feeds',
method: 'getPublicFeeds',
params: {},
args: [undefined],
readOnly: true,
},
{
action: 'get_qzone_public_feed_detail',
method: 'getPublicFeedDetail',
params: { feed },
args: [feed],
readOnly: true,
},
{
action: 'get_qzone_public_comments',
method: 'getPublicComments',
params: { feed, cursor: 'comments-cursor' },
args: [feed, 'comments-cursor'],
readOnly: true,
},
{
action: 'get_qzone_public_replies',
method: 'getPublicReplies',
params: { feed, comment },
args: [feed, comment, '', 20],
readOnly: true,
},
{
action: 'set_qzone_public_upvote',
method: 'setPublicUpvote',
params: { feed },
args: [feed, true],
readOnly: false,
},
{
action: 'set_qzone_public_like',
method: 'setPublicLike',
params: { feed },
args: [feed, true],
readOnly: false,
},
{
action: 'set_qzone_public_comment_like',
method: 'setPublicCommentLike',
params: { feed, comment },
args: [feed, comment, true, undefined],
readOnly: false,
},
{
action: 'comment_qzone_public_feed',
method: 'commentPublicFeed',
params: { feed, content: 'hello' },
args: [feed, 'hello'],
readOnly: false,
},
{
action: 'reply_qzone_public_feed',
method: 'replyPublicFeed',
params: { feed, comment, content: 'reply' },
args: [feed, comment, 'reply', undefined],
readOnly: false,
},
{
action: 'delete_qzone_public_comment',
method: 'deletePublicComment',
params: { feed, comment },
args: [feed, comment],
readOnly: false,
},
{
action: 'delete_qzone_public_reply',
method: 'deletePublicReply',
params: { feed, comment, reply },
args: [feed, comment, reply],
readOnly: false,
},
{
action: 'set_qzone_public_follow',
method: 'setPublicFollow',
params: { feed },
args: [feed, true],
readOnly: false,
},
{
action: 'forward_qzone_public_feed',
method: 'forwardPublicFeed',
params: { feed },
args: [feed, ''],
readOnly: false,
},
];
describe('qzone public actions', () => {
it.each(cases)(
'$action preserves upstream objects and applies documented defaults',
async ({ action, method, params, args }) => {
const invoke = vi.fn().mockResolvedValue(result);
expect(await makeHandler({ [method]: invoke }).handle(action, params)).toMatchObject({
status: 'ok',
retcode: 0,
data: result,
});
expect(invoke).toHaveBeenCalledExactlyOnceWith(...args);
},
);
it('registers all public actions under the existing space category with valid examples and read/write modes', () => {
const registered = ACTION_GROUPS.find((group) => group.category === '空间')!.actions;
expect(actions).toHaveLength(cases.length);
for (const { action, readOnly } of cases) {
const spec = actions.find((entry) => entry.names.includes(action))!;
const doc = spec.describe();
expect(registered).toContain(spec);
expect(doc.readOnly).toBe(readOnly);
expect(doc.returnsSchema).toBeDefined();
expect(doc.examples!.length).toBeGreaterThan(0);
for (const example of doc.examples!) {
expect(spec.parse(example.params)).toMatchObject({ ok: true });
}
}
});
it('keeps list metadata and explicit cursor values intact', async () => {
const page = {
feeds: [feed],
has_more: true,
next_cursor: 'next-cursor',
server_metadata: { future: 1 },
};
const getPublicFeeds = vi.fn().mockResolvedValue(page);
expect(
await makeHandler({ getPublicFeeds }).handle('get_qzone_public_feeds', {
cursor: 'current-cursor',
}),
).toMatchObject({ status: 'ok', data: page });
expect(getPublicFeeds).toHaveBeenCalledWith('current-cursor');
});
it.each(['set_qzone_public_upvote', 'set_qzone_public_like', 'set_qzone_public_follow'])(
'%s accepts false without replacing it with the true default',
async (action) => {
const { method } = cases.find((entry) => entry.action === action)!;
const invoke = vi.fn().mockResolvedValue(result);
await makeHandler({ [method]: invoke }).handle(action, { feed, enable: 'false' });
expect(invoke).toHaveBeenCalledWith(feed, false);
},
);
it('normalizes query-string reply pagination and preserves the parent object', async () => {
const getPublicReplies = vi.fn().mockResolvedValue(result);
await makeHandler({ getPublicReplies }).handle('get_qzone_public_replies', {
feed,
comment,
cursor: 'reply-cursor',
page_size: '5',
});
expect(getPublicReplies).toHaveBeenCalledWith(feed, comment, 'reply-cursor', 5);
});
it('routes a reply unlike with its original target and parent context', async () => {
const setPublicCommentLike = vi.fn().mockResolvedValue(result);
const originalReply = { ...reply, like: { id: 'reply-like-key', future: true } };
await makeHandler({ setPublicCommentLike }).handle('set_qzone_public_comment_like', {
feed,
comment,
reply: originalReply,
enable: 'false',
});
expect(setPublicCommentLike).toHaveBeenCalledExactlyOnceWith(
feed,
comment,
false,
originalReply,
);
});
it.each(['comment_qzone_public_feed', 'reply_qzone_public_feed'])(
'%s preserves every rich item and optional reply recipient',
async (action) => {
const { method } = cases.find((entry) => entry.action === action)!;
const invoke = vi.fn().mockResolvedValue(result);
const rich = [
{ type: 0, content: 'hello', future: { unknown: true } },
{ type: 1, unknown: [1, 2] },
];
const params = { feed, comment, content: rich, reply };
await makeHandler({ [method]: invoke }).handle(action, params);
expect(invoke).toHaveBeenCalledWith(
...(action === 'comment_qzone_public_feed'
? [feed, rich]
: [feed, comment, rich, reply]),
);
},
);
it.each(['comment_qzone_public_feed', 'reply_qzone_public_feed'])(
'%s preserves literal text even when it looks like JSON',
async (action) => {
const { method } = cases.find((entry) => entry.action === action)!;
const invoke = vi.fn().mockResolvedValue(result);
const handler = makeHandler({ [method]: invoke });
for (const content of [
'[]',
'[1,2]',
'[{"type":0,"content":"hello"}]',
' ["中文"]\n',
'{"text":"hello"}',
]) {
expect(
await handler.handle(action, { feed, comment, content, reply }),
).toMatchObject({
status: 'ok',
retcode: 0,
});
expect(invoke).toHaveBeenLastCalledWith(
...(action === 'comment_qzone_public_feed'
? [feed, content]
: [feed, comment, content, reply]),
);
}
},
);
it.each(['comment_qzone_public_feed', 'reply_qzone_public_feed'])(
'%s rejects missing, empty, scalar or mixed content without effects',
async (action) => {
const { method } = cases.find((entry) => entry.action === action)!;
const invoke = vi.fn();
const handler = makeHandler({ [method]: invoke });
const invalid: JsonObject[] = [
{},
{ content: '' },
{ content: [] },
{ content: 1 },
{ content: null },
{ content: {} },
{ content: [{ type: 0 }, null] },
{ content: [{ type: 0 }, 'invalid'] },
];
for (const params of invalid) {
expect(await handler.handle(action, { feed, comment, ...params })).toMatchObject({
status: 'failed',
retcode: 1400,
});
}
expect(invoke).not.toHaveBeenCalled();
},
);
it.each([
{ action: 'get_qzone_feed_detail', params: {} },
{ action: 'get_qzone_feed_detail', params: { tid: '' } },
{ action: 'get_qzone_feed_detail', params: { tid: 'T1' } },
{ action: 'get_qzone_feed_detail', params: { tid: [] } },
{ action: 'get_qzone_feed_detail', params: { tid: 'g'.repeat(24) } },
{ action: 'get_qzone_feed_detail', params: { tid: 'a'.repeat(25) } },
{ action: 'get_qzone_feed_detail', params: { tid: '1127000000f1536501000000\n' } },
{ action: 'get_qzone_public_feeds', params: { cursor: '' } },
{ action: 'get_qzone_public_feeds', params: { cursor: 'a'.repeat(65537) } },
{ action: 'get_qzone_public_feed_detail', params: {} },
{ action: 'get_qzone_public_feed_detail', params: { feed: [] } },
{ action: 'get_qzone_public_comments', params: { feed } },
{ action: 'get_qzone_public_comments', params: { feed, cursor: '' } },
{ action: 'get_qzone_public_replies', params: { feed, comment: null } },
{ action: 'get_qzone_public_replies', params: { feed, comment, page_size: 0 } },
{ action: 'delete_qzone_public_comment', params: { feed } },
{ action: 'set_qzone_public_comment_like', params: { feed } },
{ action: 'set_qzone_public_comment_like', params: { feed, comment, reply: [] } },
{ action: 'delete_qzone_public_reply', params: { feed, comment, reply: 'bad' } },
{
action: 'reply_qzone_public_feed',
params: { feed, comment, content: 'reply', reply: [] },
},
])('$action rejects invalid parameters before dispatch', async ({ action, params }) => {
const { method } = cases.find((entry) => entry.action === action)!;
const invoke = vi.fn();
expect(
await makeHandler({ [method]: invoke }).handle(action, params as JsonObject),
).toMatchObject({ status: 'failed', retcode: 1400 });
expect(invoke).not.toHaveBeenCalled();
});
it.each(['get_qzone_public_feed_detail', 'set_qzone_public_upvote'])(
'%s exposes upstream errors without retry',
async (action) => {
const { method, params } = cases.find((entry) => entry.action === action)!;
const invoke = vi.fn().mockRejectedValue(new Error('public qzone denied'));
expect(await makeHandler({ [method]: invoke }).handle(action, params)).toMatchObject({
status: 'failed',
wording: 'public qzone denied',
});
expect(invoke).toHaveBeenCalledTimes(1);
},
);
});