NapCatQQ source snapshot
This commit is contained in:
commit
f954252a34
1111 files changed
+139867
No files matched your search
@@ -0,0 +1,305 @@
|
||||
// StreamBasic must be evaluated before OneBotAction because they import each other.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { TypeCompiler } from '@sinclair/typebox/compiler';
|
||||
import {
|
||||
CommentQzonePublicFeed,
|
||||
DeleteQzonePublicComment,
|
||||
DeleteQzonePublicReply,
|
||||
ForwardQzonePublicFeed,
|
||||
GetQzonePublicCapabilities,
|
||||
GetQzonePublicComments,
|
||||
GetQzonePublicFeedDetail,
|
||||
GetQzonePublicFeeds,
|
||||
GetQzonePublicReplies,
|
||||
ReplyQzonePublicFeed,
|
||||
SetQzonePublicCommentLike,
|
||||
SetQzonePublicFollow,
|
||||
SetQzonePublicLike,
|
||||
SetQzonePublicUpvote,
|
||||
} from '../napcat-onebot/action/extends/qzone-public';
|
||||
|
||||
const FEED = {
|
||||
cell_id: { id: 'feed-id', extra_id: 'raw-id' },
|
||||
cell_feed_transfer: { transfer: 'raw-transfer' },
|
||||
extra_feed: { nested: [1, true, null, { opaque: '001' }] },
|
||||
};
|
||||
const COMMENT = {
|
||||
id: 'comment-id',
|
||||
like: { id: 'comment-like-id', extra_like: true },
|
||||
reply_attach_info: 'reply-cursor',
|
||||
extra_comment: { opaque: '002' },
|
||||
};
|
||||
const REPLY = { id: 'reply-id', like: { id: 'reply-like-id' }, extra_reply: [false, 3] };
|
||||
const CONTENT = '保留评论正文';
|
||||
|
||||
const ACTION_CASES = [
|
||||
{ Action: GetQzonePublicCapabilities, name: 'get_qzone_public_capabilities', method: 'getCapabilities', payload: {}, args: [] },
|
||||
{ Action: GetQzonePublicFeeds, name: 'get_qzone_public_feeds', method: 'getFeeds', payload: { cursor: 'feeds-cursor' }, args: ['feeds-cursor'] },
|
||||
{ Action: GetQzonePublicFeedDetail, name: 'get_qzone_public_feed_detail', method: 'getFeedDetail', payload: { feed: FEED }, args: [FEED] },
|
||||
{ Action: GetQzonePublicComments, name: 'get_qzone_public_comments', method: 'getComments', payload: { feed: FEED, cursor: 'detail-cursor' }, args: [FEED, 'detail-cursor'] },
|
||||
{ Action: GetQzonePublicReplies, name: 'get_qzone_public_replies', method: 'getReplies', payload: { feed: FEED, comment: COMMENT, cursor: 'next-replies', page_size: '7' }, args: [FEED, COMMENT, 'next-replies', 7] },
|
||||
{ Action: SetQzonePublicUpvote, name: 'set_qzone_public_upvote', method: 'setUpvote', payload: { feed: FEED, enable: false }, args: [FEED, false] },
|
||||
{ Action: SetQzonePublicLike, name: 'set_qzone_public_like', method: 'setLike', payload: { feed: FEED, enable: false }, args: [FEED, false] },
|
||||
{ Action: SetQzonePublicFollow, name: 'set_qzone_public_follow', method: 'setFollow', payload: { feed: FEED, enable: false }, args: [FEED, false] },
|
||||
{ Action: SetQzonePublicCommentLike, name: 'set_qzone_public_comment_like', method: 'setCommentLike', payload: { feed: FEED, comment: COMMENT, reply: REPLY, enable: false }, args: [FEED, COMMENT, false, REPLY] },
|
||||
{ Action: CommentQzonePublicFeed, name: 'comment_qzone_public_feed', method: 'addComment', payload: { feed: FEED, content: CONTENT }, args: [FEED, CONTENT] },
|
||||
{ Action: ReplyQzonePublicFeed, name: 'reply_qzone_public_feed', method: 'addReply', payload: { feed: FEED, comment: COMMENT, content: CONTENT, reply: REPLY }, args: [FEED, COMMENT, CONTENT, REPLY] },
|
||||
{ Action: DeleteQzonePublicComment, name: 'delete_qzone_public_comment', method: 'deleteComment', payload: { feed: FEED, comment: COMMENT }, args: [FEED, COMMENT] },
|
||||
{ Action: DeleteQzonePublicReply, name: 'delete_qzone_public_reply', method: 'deleteReply', payload: { feed: FEED, comment: COMMENT, reply: REPLY }, args: [FEED, COMMENT, REPLY] },
|
||||
{ Action: ForwardQzonePublicFeed, name: 'forward_qzone_public_feed', method: 'forwardFeed', payload: { feed: FEED, reason: '转发理由' }, args: [FEED, '转发理由'] },
|
||||
] as const;
|
||||
|
||||
type ActionConstructor = typeof ACTION_CASES[number]['Action'];
|
||||
|
||||
function createAction (Action: ActionConstructor) {
|
||||
const nativeResult = {
|
||||
feeds: [FEED],
|
||||
has_more: true,
|
||||
next_cursor: 'next-feeds',
|
||||
extra_result: { untouched: [null, 1, false, '001'] },
|
||||
};
|
||||
const methods = Object.fromEntries(ACTION_CASES.map(({ method }) => [method, vi.fn().mockResolvedValue(nativeResult)]));
|
||||
const action = new Action(undefined as never, {
|
||||
context: { logger: { logError: vi.fn() } },
|
||||
apis: { QzonePublicApi: methods },
|
||||
} as never);
|
||||
return { action, methods, nativeResult };
|
||||
}
|
||||
|
||||
describe('Qzone public OneBot actions', () => {
|
||||
test.each(ACTION_CASES)('$name delegates complete raw objects and returns the original JSON object', async ({ Action, name, method, payload, args }) => {
|
||||
const { action, methods, nativeResult } = createAction(Action);
|
||||
const response = await action.handle(structuredClone(payload) as never, 'test', {} as never);
|
||||
|
||||
expect(action.actionName).toBe(name);
|
||||
expect(response.status).toBe('ok');
|
||||
expect(response.data).toBe(nativeResult);
|
||||
expect(methods[method]).toHaveBeenCalledExactlyOnceWith(...args);
|
||||
for (const [otherMethod, mock] of Object.entries(methods)) {
|
||||
if (otherMethod !== method) expect(mock).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test.each(ACTION_CASES)('$name provides compilable OpenAPI schemas and examples', ({ Action }) => {
|
||||
const { action, nativeResult } = createAction(Action);
|
||||
|
||||
expect(action.actionSummary).not.toBe('');
|
||||
expect(action.actionDescription).not.toBe('');
|
||||
expect(action.actionTags.length).toBeGreaterThan(0);
|
||||
expect(action.payloadSchema).toBeDefined();
|
||||
expect(action.returnSchema).toBeDefined();
|
||||
expect(action.payloadExample).toBeDefined();
|
||||
expect(action.returnExample).toBeDefined();
|
||||
expect(TypeCompiler.Compile(action.payloadSchema!).Check(action.payloadExample)).toBe(true);
|
||||
const checkResult = TypeCompiler.Compile(action.returnSchema!);
|
||||
expect(checkResult.Check(action.returnExample)).toBe(true);
|
||||
expect(checkResult.Check(nativeResult)).toBe(true);
|
||||
});
|
||||
|
||||
test('reply pagination uses the SL empty cursor default for parent-cursor fallback', async () => {
|
||||
const { action, methods } = createAction(GetQzonePublicReplies);
|
||||
|
||||
await action.handle({ feed: FEED, comment: COMMENT } as never, 'test', {} as never);
|
||||
await action.handle({ feed: FEED, comment: COMMENT, cursor: '' } as never, 'test', {} as never);
|
||||
|
||||
expect(methods['getReplies']).toHaveBeenNthCalledWith(1, FEED, COMMENT, '', 20);
|
||||
expect(methods['getReplies']).toHaveBeenNthCalledWith(2, FEED, COMMENT, '', 20);
|
||||
});
|
||||
|
||||
test('comment pagination requires a non-empty detail cursor', async () => {
|
||||
const { action, methods } = createAction(GetQzonePublicComments);
|
||||
|
||||
const missing = await action.handle({ feed: FEED } as never, 'test', {} as never);
|
||||
expect(missing).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
expect(methods['getComments']).not.toHaveBeenCalled();
|
||||
|
||||
const empty = await action.handle({ feed: FEED, cursor: '' } as never, 'test', {} as never);
|
||||
expect(empty).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
expect(methods['getComments']).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ value: true, expected: true },
|
||||
{ value: false, expected: false },
|
||||
{ value: 1, expected: true },
|
||||
{ value: 0, expected: false },
|
||||
{ value: 'true', expected: true },
|
||||
{ value: 'false', expected: false },
|
||||
{ value: '1', expected: true },
|
||||
{ value: '0', expected: false },
|
||||
{ value: ' TRUE ', expected: true },
|
||||
{ value: ' FALSE ', expected: false },
|
||||
{ value: 'yes', expected: true },
|
||||
{ value: 'no', expected: false },
|
||||
{ value: ' ON ', expected: true },
|
||||
{ value: ' OFF ', expected: false },
|
||||
{ value: 2, expected: true },
|
||||
{ value: -1, expected: true },
|
||||
{ value: 0.1, expected: true },
|
||||
])('normalizes enable=$value without merging upvote, like and follow', async ({ value, expected }) => {
|
||||
for (const { Action, method } of ACTION_CASES.filter(({ method }) => ['setUpvote', 'setLike', 'setFollow', 'setCommentLike'].includes(method))) {
|
||||
const { action, methods } = createAction(Action);
|
||||
const response = await action.handle({ feed: FEED, comment: COMMENT, enable: value } as never, 'test', {} as never);
|
||||
|
||||
expect(response.status).toBe('ok');
|
||||
expect(methods[method]).toHaveBeenCalledExactlyOnceWith(...(method === 'setCommentLike' ? [FEED, COMMENT, expected, undefined] : [FEED, expected]));
|
||||
}
|
||||
});
|
||||
|
||||
test('uses documented defaults for optional mutation parameters', async () => {
|
||||
for (const { Action, method } of ACTION_CASES.filter(({ method }) => ['setUpvote', 'setLike', 'setFollow', 'setCommentLike'].includes(method))) {
|
||||
const { action, methods } = createAction(Action);
|
||||
await action.handle({ feed: FEED, comment: COMMENT } as never, 'test', {} as never);
|
||||
expect(methods[method]).toHaveBeenCalledExactlyOnceWith(...(method === 'setCommentLike' ? [FEED, COMMENT, true, undefined] : [FEED, true]));
|
||||
}
|
||||
|
||||
const forward = createAction(ForwardQzonePublicFeed);
|
||||
await forward.action.handle({ feed: FEED } as never, 'test', {} as never);
|
||||
expect(forward.methods['forwardFeed']).toHaveBeenCalledExactlyOnceWith(FEED, '');
|
||||
|
||||
const reply = createAction(ReplyQzonePublicFeed);
|
||||
await reply.action.handle({ feed: FEED, comment: COMMENT, content: CONTENT } as never, 'test', {} as never);
|
||||
expect(reply.methods['addReply']).toHaveBeenCalledExactlyOnceWith(FEED, COMMENT, CONTENT, undefined);
|
||||
});
|
||||
|
||||
test.each(['maybe', '', '2', null, {}, []].map(enable => ({ enable })))('rejects unsupported enable=$enable', async ({ enable }) => {
|
||||
const { action, methods } = createAction(SetQzonePublicLike);
|
||||
const response = await action.handle({ feed: FEED, enable } as never, 'test', {} as never);
|
||||
|
||||
expect(response).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
expect(methods['setLike']).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ pageSize: 1, expected: 1 },
|
||||
{ pageSize: '1', expected: 1 },
|
||||
{ pageSize: 20, expected: 20 },
|
||||
{ pageSize: '37', expected: 37 },
|
||||
{ pageSize: 1.5, expected: 1 },
|
||||
{ pageSize: '1.9', expected: 1 },
|
||||
{ pageSize: ' 2e1 ', expected: 20 },
|
||||
{ pageSize: '0x10', expected: 16 },
|
||||
{ pageSize: Number.MAX_SAFE_INTEGER + 1, expected: Number.MAX_SAFE_INTEGER + 1 },
|
||||
])('normalizes SL-compatible page_size=$pageSize to $expected', async ({ pageSize, expected }) => {
|
||||
const { action, methods } = createAction(GetQzonePublicReplies);
|
||||
const response = await action.handle({ feed: FEED, comment: COMMENT, page_size: pageSize } as never, 'test', {} as never);
|
||||
|
||||
expect(response.status).toBe('ok');
|
||||
expect(methods['getReplies']).toHaveBeenCalledExactlyOnceWith(FEED, COMMENT, '', expected);
|
||||
});
|
||||
|
||||
test.each([0, -1, 0.9, '', ' ', '0', '-1', 'abc', true, null, Infinity, 'Infinity'])('rejects invalid page_size=%j', async (pageSize) => {
|
||||
const { action, methods } = createAction(GetQzonePublicReplies);
|
||||
const response = await action.handle({ feed: FEED, comment: COMMENT, page_size: pageSize } as never, 'test', {} as never);
|
||||
|
||||
expect(response).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
expect(methods['getReplies']).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([CommentQzonePublicFeed, ReplyQzonePublicFeed])('preserves arbitrary rich content objects', async (Action) => {
|
||||
const { action, methods } = createAction(Action);
|
||||
const content = [{ type: 0, content: '正文', unknown: { id: '001', list: [null, false] } }, { type: 1, uid: '123', extra: ['a'] }];
|
||||
const response = await action.handle({ feed: FEED, comment: COMMENT, content } as never, 'test', {} as never);
|
||||
|
||||
expect(response.status).toBe('ok');
|
||||
const method = Action === CommentQzonePublicFeed ? 'addComment' : 'addReply';
|
||||
expect(methods[method]).toHaveBeenCalledExactlyOnceWith(...(method === 'addComment' ? [FEED, content] : [FEED, COMMENT, content, undefined]));
|
||||
});
|
||||
|
||||
test.each(['', '[]', '[1]', 123, false, null, {}, [], ['text'], [1], [null], [[]]].map(content => ({ content })))('rejects invalid rich content=$content before conversion', async ({ content }) => {
|
||||
const { action, methods } = createAction(CommentQzonePublicFeed);
|
||||
const response = await action.handle({ feed: FEED, content } as never, 'test', {} as never);
|
||||
|
||||
expect(response).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
expect(methods['addComment']).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([CommentQzonePublicFeed, ReplyQzonePublicFeed])('normalizes JSON-array content strings using the SL consumer contract', async (Action) => {
|
||||
const { action, methods } = createAction(Action);
|
||||
const content = [{ type: 0, content: '正文', future: { opaque: '001' } }];
|
||||
const method = Action === CommentQzonePublicFeed ? 'addComment' : 'addReply';
|
||||
const response = await action.handle({ feed: FEED, comment: COMMENT, content: ` ${JSON.stringify(content)} ` } as never, 'test', {} as never);
|
||||
|
||||
expect(response.status).toBe('ok');
|
||||
expect(methods[method]).toHaveBeenCalledExactlyOnceWith(...(method === 'addComment' ? [FEED, content] : [FEED, COMMENT, content, undefined]));
|
||||
});
|
||||
|
||||
test.each([' ', '[invalid]'])('preserves non-empty SL text content %j', async (content) => {
|
||||
const { action, methods } = createAction(CommentQzonePublicFeed);
|
||||
const response = await action.handle({ feed: FEED, content } as never, 'test', {} as never);
|
||||
|
||||
expect(response.status).toBe('ok');
|
||||
expect(methods['addComment']).toHaveBeenCalledExactlyOnceWith(FEED, content);
|
||||
});
|
||||
|
||||
test('feed cursors are optional, non-empty and limited to 65536 characters', async () => {
|
||||
const { action, methods } = createAction(GetQzonePublicFeeds);
|
||||
const first = await action.handle({} as never, 'test', {} as never);
|
||||
expect(first.status).toBe('ok');
|
||||
expect(methods['getFeeds']).toHaveBeenCalledExactlyOnceWith(undefined);
|
||||
methods['getFeeds']!.mockClear();
|
||||
|
||||
for (const cursor of ['', 'a'.repeat(65537)]) {
|
||||
const response = await action.handle({ cursor } as never, 'test', {} as never);
|
||||
expect(response).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
}
|
||||
expect(methods['getFeeds']).not.toHaveBeenCalled();
|
||||
const cursor = 'a'.repeat(65536);
|
||||
expect((await action.handle({ cursor } as never, 'test', {} as never)).status).toBe('ok');
|
||||
expect(methods['getFeeds']).toHaveBeenCalledExactlyOnceWith(cursor);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ Action: GetQzonePublicFeeds, method: 'getFeeds', field: 'cursor', payload: {}, args: [] },
|
||||
{ Action: GetQzonePublicComments, method: 'getComments', field: 'cursor', payload: { feed: FEED }, args: [FEED] },
|
||||
{ Action: GetQzonePublicReplies, method: 'getReplies', field: 'cursor', payload: { feed: FEED, comment: COMMENT }, args: [FEED, COMMENT] },
|
||||
{ Action: ForwardQzonePublicFeed, method: 'forwardFeed', field: 'reason', payload: { feed: FEED }, args: [FEED] },
|
||||
])('$method uses SL string coercion for $field', async ({ Action, method, field, payload, args }) => {
|
||||
for (const value of [42, false]) {
|
||||
const { action, methods } = createAction(Action);
|
||||
const response = await action.handle({ ...payload, [field]: value } as never, 'test', {} as never);
|
||||
expect(response.status).toBe('ok');
|
||||
expect(methods[method]).toHaveBeenCalledExactlyOnceWith(...args, String(value), ...(method === 'getReplies' ? [20] : []));
|
||||
}
|
||||
for (const value of [null, {}, []]) {
|
||||
const { action, methods } = createAction(Action);
|
||||
const response = await action.handle({ ...payload, [field]: value } as never, 'test', {} as never);
|
||||
expect(response).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
expect(methods[method]).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ feed: null, comment: COMMENT, reply: REPLY },
|
||||
{ feed: [], comment: COMMENT, reply: REPLY },
|
||||
{ feed: 'feed-id', comment: COMMENT, reply: REPLY },
|
||||
{ feed: FEED, comment: null, reply: REPLY },
|
||||
{ feed: FEED, comment: 'comment-id', reply: REPLY },
|
||||
{ feed: FEED, comment: COMMENT, reply: [] },
|
||||
{ feed: FEED, comment: COMMENT, reply: 'reply-id' },
|
||||
])('requires raw objects for feed, comment and reply: %j', async (payload) => {
|
||||
const { action, methods } = createAction(DeleteQzonePublicReply);
|
||||
const response = await action.handle(payload as never, 'test', {} as never);
|
||||
|
||||
expect(response).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
expect(methods['deleteReply']).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('WebSocket validation preserves echo and prevents writes', async () => {
|
||||
const { action, methods } = createAction(DeleteQzonePublicReply);
|
||||
const response = await action.websocketHandle({ feed: FEED, comment: COMMENT } as never, 'public-echo', 'test', {} as never);
|
||||
|
||||
expect(response).toMatchObject({ status: 'failed', retcode: 1400, echo: 'public-echo' });
|
||||
expect(methods['deleteReply']).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('reports core failures through the existing OneBot error response', async () => {
|
||||
const { action, methods } = createAction(SetQzonePublicUpvote);
|
||||
methods['setUpvote']!.mockRejectedValue(new Error('public interaction rejected'));
|
||||
const response = await action.handle({ feed: FEED } as never, 'test', {} as never);
|
||||
|
||||
expect(response).toMatchObject({ status: 'failed', message: 'public interaction rejected' });
|
||||
expect(methods['setUpvote']).toHaveBeenCalledExactlyOnceWith(FEED, true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user