Files
TanTan f954252a34
Build NapCat Artifacts / Type Check & Test (push) Waiting to run
Release Tag / validate-version (push) Waiting to run
NapCatQQ source snapshot
2026-11-13 09:01:47 +08:00

105 lines
5.3 KiB
TypeScript

// 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 { OB11HttpServerAdapter } from '../napcat-onebot/network/http-server';
import { CommentQzonePublicFeed, DeleteQzonePublicReply } from '../napcat-onebot/action/extends/qzone-public';
const FEED = { cell_common: { union_id: { feed_id: 'opaque' } }, future_feed: [1, false] };
const COMMENT = { id: 'comment-id', like: { id: 'comment-like-id' }, future_comment: true };
const REPLY = { id: 'reply-id', user: { uid: 'recipient' }, future_reply: { keep: true } };
function fixture (Action = DeleteQzonePublicReply as typeof DeleteQzonePublicReply | typeof CommentQzonePublicFeed) {
const result = { upstream_result: { unknown: 'preserved' } };
const methods = { deleteReply: vi.fn().mockResolvedValue(result), addComment: vi.fn().mockResolvedValue(result) };
const action = new Action(undefined as never, {
context: { logger: { logError: vi.fn() } },
apis: { QzonePublicApi: methods },
} as never);
// Exercise the adapter handler without opening a listener or constructing native QQ services.
const adapter = Object.assign(Object.create(OB11HttpServerAdapter.prototype), {
name: 'test-http', config: {}, actions: new Map([[action.actionName, action]]),
}) as OB11HttpServerAdapter;
return { adapter, action, methods, result };
}
async function request (
adapter: OB11HttpServerAdapter,
actionName: string,
method: string,
query: Record<string, unknown>,
body: Record<string, unknown> = {},
contentType = 'application/json'
) {
const json = vi.fn();
await adapter.httpApiRequest({
method,
path: `/${actionName}`,
query,
body,
is: (type: string) => type === contentType,
} as never, { json } as never);
return json.mock.calls[0]?.[0];
}
describe('Qzone public HTTP parameter compatibility', () => {
test.each(['GET', 'FORM'])('%s decodes feed, comment and reply JSON values before action validation', async (transport) => {
const { adapter, action, methods, result } = fixture();
const parameters = { feed: JSON.stringify(FEED), comment: JSON.stringify(COMMENT), reply: JSON.stringify(REPLY) };
const response = transport === 'GET'
? await request(adapter, action.actionName, 'GET', parameters)
: await request(adapter, action.actionName, 'POST', {}, parameters, 'application/x-www-form-urlencoded');
expect(response).toMatchObject({ status: 'ok', data: result });
expect(methods.deleteReply).toHaveBeenCalledExactlyOnceWith(FEED, COMMENT, REPLY);
});
test('POST query parameters receive the same decoding and override body parameters', async () => {
const { adapter, action, methods } = fixture();
const response = await request(adapter, action.actionName, 'POST', {
feed: JSON.stringify(FEED), comment: JSON.stringify(COMMENT), reply: JSON.stringify(REPLY),
}, { feed: {}, comment: {}, reply: {} });
expect(response.status).toBe('ok');
expect(methods.deleteReply).toHaveBeenCalledExactlyOnceWith(FEED, COMMENT, REPLY);
});
test('JSON request bodies and WebSocket parameters retain strict object validation', async () => {
const { adapter, action, methods } = fixture();
const parameters = { feed: JSON.stringify(FEED), comment: JSON.stringify(COMMENT), reply: JSON.stringify(REPLY) };
const http = await request(adapter, action.actionName, 'POST', {}, { ...parameters });
const websocket = await action.websocketHandle({ ...parameters } as never, 'strict-echo', 'test', {} as never);
expect(http).toMatchObject({ status: 'failed', retcode: 400 });
expect(websocket).toMatchObject({ status: 'failed', retcode: 1400, echo: 'strict-echo' });
expect(methods.deleteReply).not.toHaveBeenCalled();
});
test('query content is decoded before rich content validation, including quoted strings', async () => {
const { adapter, action, methods } = fixture(CommentQzonePublicFeed);
const query = { feed: JSON.stringify(FEED) };
const invalid = await request(adapter, action.actionName, 'GET', { ...query, content: '123' });
expect(invalid).toMatchObject({ status: 'failed', retcode: 400 });
expect(methods.addComment).not.toHaveBeenCalled();
const quoted = await request(adapter, action.actionName, 'GET', { ...query, content: '"123"' });
expect(quoted.status).toBe('ok');
expect(methods.addComment).toHaveBeenCalledExactlyOnceWith(FEED, '123');
methods.addComment.mockClear();
const content = [{ type: 0, content: '正文', future: { keep: true } }];
expect((await request(adapter, action.actionName, 'GET', { ...query, content: JSON.stringify(content) })).status).toBe('ok');
expect(methods.addComment).toHaveBeenCalledExactlyOnceWith(FEED, content);
});
test('other OneBot actions retain their existing undecoded HTTP parameters', async () => {
const { adapter } = fixture();
const handle = vi.fn().mockResolvedValue({ status: 'ok', retcode: 0, data: {} });
Object.assign(adapter, { actions: new Map([['other_action', { handle, useStream: false }]]) });
const parameters = { feed: JSON.stringify(FEED), content: '123' };
await request(adapter, 'other_action', 'GET', parameters);
expect(handle.mock.calls[0]?.[0]).toEqual(parameters);
});
});