Integrate the current development branch, preserve the album comment fixes, and align the changes with repository formatting and dependency tooling.
554 lines
20 KiB
TypeScript
554 lines
20 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import type { ApiActionContext } from '../src/api-handler';
|
|
import { actions } from '../src/actions/message';
|
|
import type { JsonObject, MessageMeta } from '../src/types';
|
|
|
|
function requireAction(name: string) {
|
|
const action = actions.find((entry) => entry.names[0] === name);
|
|
|
|
if (!action) {
|
|
throw new Error(`${name} action missing`);
|
|
}
|
|
|
|
return action;
|
|
}
|
|
|
|
function asCtx(partial: Partial<ApiActionContext>): ApiActionContext {
|
|
return { refreshMessageMedia: vi.fn(async () => {}), ...partial } as ApiActionContext;
|
|
}
|
|
|
|
const sendMsg = requireAction('send_msg');
|
|
const sendPrivateMsg = requireAction('send_private_msg');
|
|
const sendGroupMsg = requireAction('send_group_msg');
|
|
const getMsg = requireAction('get_msg');
|
|
const deleteMsg = requireAction('delete_msg');
|
|
|
|
describe('message actions catalog', () => {
|
|
it('exports the public message actions in source order', () => {
|
|
expect(actions.map((entry) => entry.names[0])).toEqual([
|
|
'send_custom_face',
|
|
'send_msg',
|
|
'send_private_msg',
|
|
'send_group_msg',
|
|
'get_msg',
|
|
'delete_msg',
|
|
]);
|
|
});
|
|
|
|
it('marks only get_msg as read-only', () => {
|
|
expect(sendMsg.describe().readOnly).toBe(false);
|
|
expect(sendPrivateMsg.describe().readOnly).toBe(false);
|
|
expect(sendGroupMsg.describe().readOnly).toBe(false);
|
|
expect(getMsg.describe().readOnly).toBe(true);
|
|
expect(deleteMsg.describe().readOnly).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('send_custom_face', () => {
|
|
const action = requireAction('send_custom_face');
|
|
const url = 'https://p.qpic.cn/qq_expression/10001/saved-sticker/0';
|
|
const image = { type: 'image', data: { file: url, sub_type: 1, summary: '[动画表情]' } };
|
|
|
|
function context() {
|
|
const resolveCustomFace = vi.fn(async () => ({
|
|
emojiId: 'saved-sticker',
|
|
url,
|
|
md5: 'a'.repeat(32),
|
|
}));
|
|
const sendGroupMessage = vi.fn(async () => ({ messageId: 7 }));
|
|
const sendPrivateMessage = vi.fn(async () => ({ messageId: 8 }));
|
|
const ctx = asCtx({
|
|
bridge: {
|
|
apis: { profile: { resolveCustomFace } },
|
|
} as unknown as ApiActionContext['bridge'],
|
|
sendGroupMessage,
|
|
sendPrivateMessage,
|
|
});
|
|
return { ctx, resolveCustomFace, sendGroupMessage, sendPrivateMessage };
|
|
}
|
|
|
|
it('resolves the saved id and sends a sticker with an optional reply', async () => {
|
|
const { ctx, resolveCustomFace, sendGroupMessage } = context();
|
|
expect(
|
|
await action.toHandler(ctx)({
|
|
emoji_id: 'saved-sticker',
|
|
group_id: 123,
|
|
reply_to: -42,
|
|
}),
|
|
).toMatchObject({ status: 'ok', data: { message_id: 7 } });
|
|
expect(resolveCustomFace).toHaveBeenCalledWith('saved-sticker');
|
|
expect(sendGroupMessage).toHaveBeenCalledWith(
|
|
123,
|
|
[{ type: 'reply', data: { id: '-42' } }, image],
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('supports a private destination and propagates lookup failures', async () => {
|
|
const { ctx, resolveCustomFace, sendPrivateMessage } = context();
|
|
expect(
|
|
await action.toHandler(ctx)({ emoji_id: 'a'.repeat(32), user_id: 456 }),
|
|
).toMatchObject({ status: 'ok', data: { message_id: 8 } });
|
|
expect(sendPrivateMessage).toHaveBeenCalledWith(456, [image], false);
|
|
resolveCustomFace.mockRejectedValueOnce(new Error('sticker missing'));
|
|
await expect(action.toHandler(ctx)({ emoji_id: 'missing', user_id: 456 })).rejects.toThrow(
|
|
'sticker missing',
|
|
);
|
|
expect(sendPrivateMessage).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each([{}, { group_id: 123, user_id: 456 }])(
|
|
'rejects an ambiguous destination before lookup',
|
|
async (target) => {
|
|
const { ctx, resolveCustomFace } = context();
|
|
expect(
|
|
await action.toHandler(ctx)({ emoji_id: 'saved-sticker', ...target }),
|
|
).toMatchObject({ retcode: 1400 });
|
|
expect(resolveCustomFace).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('send_msg parse', () => {
|
|
it('defaults auto_escape to false and leaves optional ids unset', () => {
|
|
expect(sendMsg.parse({ message: 'hello route' })).toEqual({
|
|
ok: true,
|
|
value: {
|
|
message: 'hello route',
|
|
message_type: undefined,
|
|
group_id: undefined,
|
|
user_id: undefined,
|
|
auto_escape: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('parses a JSON-array message string and numeric ids', () => {
|
|
expect(
|
|
sendMsg.parse({
|
|
message: '[{"type":"text","data":{"text":"hi"}}]',
|
|
message_type: 'group',
|
|
group_id: '314159',
|
|
user_id: '271828',
|
|
auto_escape: 'on',
|
|
}),
|
|
).toEqual({
|
|
ok: true,
|
|
value: {
|
|
message: [{ type: 'text', data: { text: 'hi' } }],
|
|
message_type: 'group',
|
|
group_id: 314159,
|
|
user_id: 271828,
|
|
auto_escape: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('requires message and rejects group_id 0', () => {
|
|
expect(sendMsg.parse({})).toEqual({
|
|
ok: false,
|
|
field: 'message',
|
|
reason: 'is required',
|
|
});
|
|
expect(sendMsg.parse({ message: 'x', group_id: 0 })).toEqual({
|
|
ok: false,
|
|
field: 'group_id',
|
|
reason: 'must be >= 1',
|
|
});
|
|
});
|
|
|
|
it('treats an empty group_id string as absent', () => {
|
|
expect(
|
|
sendMsg.parse({
|
|
message_type: 'private',
|
|
user_id: 1316864423,
|
|
group_id: '',
|
|
message: 'hi',
|
|
}),
|
|
).toEqual({
|
|
ok: true,
|
|
value: {
|
|
message: 'hi',
|
|
message_type: 'private',
|
|
group_id: undefined,
|
|
user_id: 1316864423,
|
|
auto_escape: false,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('send_msg run', () => {
|
|
it('sends a group message when message_type is group', async () => {
|
|
const sendGroupMessage = vi.fn(async () => ({ messageId: 9001 }));
|
|
const sendPrivateMessage = vi.fn(async () => ({ messageId: 0 }));
|
|
const response = await sendMsg.toHandler(asCtx({ sendGroupMessage, sendPrivateMessage }))({
|
|
message_type: 'group',
|
|
group_id: 314159,
|
|
message: 'group hello',
|
|
});
|
|
|
|
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9001 } });
|
|
expect(sendGroupMessage.mock.calls).toEqual([[314159, 'group hello', false]]);
|
|
expect(sendPrivateMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('sends a group message when only group_id is present', async () => {
|
|
const sendGroupMessage = vi.fn(async () => ({ messageId: 9002 }));
|
|
const sendPrivateMessage = vi.fn(async () => ({ messageId: 0 }));
|
|
const response = await sendMsg.toHandler(asCtx({ sendGroupMessage, sendPrivateMessage }))({
|
|
group_id: 80001,
|
|
user_id: 271828,
|
|
message: [{ type: 'text', data: { text: 'by group_id' } }],
|
|
auto_escape: true,
|
|
});
|
|
|
|
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9002 } });
|
|
expect(sendGroupMessage.mock.calls).toEqual([
|
|
[80001, [{ type: 'text', data: { text: 'by group_id' } }], true],
|
|
]);
|
|
expect(sendPrivateMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('sends a private message when group_id is an empty string', async () => {
|
|
const sendGroupMessage = vi.fn(async () => ({ messageId: 0 }));
|
|
const sendPrivateMessage = vi.fn(async () => ({ messageId: 9006 }));
|
|
const response = await sendMsg.toHandler(asCtx({ sendGroupMessage, sendPrivateMessage }))({
|
|
message_type: 'private',
|
|
user_id: 1316864423,
|
|
group_id: '',
|
|
message: [{ type: 'image', data: { file: 'base64://x' } }],
|
|
});
|
|
|
|
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9006 } });
|
|
expect(sendPrivateMessage.mock.calls).toEqual([
|
|
[1316864423, [{ type: 'image', data: { file: 'base64://x' } }], false, undefined],
|
|
]);
|
|
expect(sendGroupMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('sends a private message when only user_id is present', async () => {
|
|
const sendGroupMessage = vi.fn(async () => ({ messageId: 0 }));
|
|
const sendPrivateMessage = vi.fn(async () => ({ messageId: 9003 }));
|
|
const response = await sendMsg.toHandler(asCtx({ sendGroupMessage, sendPrivateMessage }))({
|
|
user_id: 271828,
|
|
message: 'private hello',
|
|
});
|
|
|
|
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9003 } });
|
|
expect(sendPrivateMessage.mock.calls).toEqual([
|
|
[271828, 'private hello', false, undefined],
|
|
]);
|
|
expect(sendGroupMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('routes private + both ids into a temp-session reply', async () => {
|
|
const sendGroupMessage = vi.fn(async () => ({ messageId: 0 }));
|
|
const sendPrivateMessage = vi.fn(async () => ({ messageId: 9004 }));
|
|
const response = await sendMsg.toHandler(asCtx({ sendGroupMessage, sendPrivateMessage }))({
|
|
message_type: 'private',
|
|
user_id: 13579,
|
|
group_id: 70001,
|
|
message: 'temp hi',
|
|
auto_escape: false,
|
|
});
|
|
|
|
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9004 } });
|
|
expect(sendPrivateMessage.mock.calls).toEqual([[13579, 'temp hi', false, 70001]]);
|
|
expect(sendGroupMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('still sends a group message when message_type is group and user_id is also set', async () => {
|
|
const sendGroupMessage = vi.fn(async () => ({ messageId: 9005 }));
|
|
const sendPrivateMessage = vi.fn(async () => ({ messageId: 0 }));
|
|
const response = await sendMsg.toHandler(asCtx({ sendGroupMessage, sendPrivateMessage }))({
|
|
message_type: 'group',
|
|
group_id: 80002,
|
|
user_id: 30003,
|
|
message: 'not temp',
|
|
});
|
|
|
|
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9005 } });
|
|
expect(sendGroupMessage.mock.calls).toEqual([[80002, 'not temp', false]]);
|
|
expect(sendPrivateMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns BAD_REQUEST when message_type is group without group_id', async () => {
|
|
const sendGroupMessage = vi.fn(async () => ({ messageId: 0 }));
|
|
const sendPrivateMessage = vi.fn(async () => ({ messageId: 0 }));
|
|
const response = await sendMsg.toHandler(asCtx({ sendGroupMessage, sendPrivateMessage }))({
|
|
message_type: 'group',
|
|
message: 'missing group',
|
|
});
|
|
|
|
expect(response).toEqual({
|
|
status: 'failed',
|
|
retcode: 1400,
|
|
data: null,
|
|
wording: 'group_id is required',
|
|
});
|
|
expect(sendGroupMessage).not.toHaveBeenCalled();
|
|
expect(sendPrivateMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns BAD_REQUEST when neither a group route nor user_id is present', async () => {
|
|
const sendGroupMessage = vi.fn(async () => ({ messageId: 0 }));
|
|
const sendPrivateMessage = vi.fn(async () => ({ messageId: 0 }));
|
|
const response = await sendMsg.toHandler(asCtx({ sendGroupMessage, sendPrivateMessage }))({
|
|
message_type: 'private',
|
|
message: 'missing user',
|
|
});
|
|
|
|
expect(response).toEqual({
|
|
status: 'failed',
|
|
retcode: 1400,
|
|
data: null,
|
|
wording: 'user_id is required',
|
|
});
|
|
expect(sendGroupMessage).not.toHaveBeenCalled();
|
|
expect(sendPrivateMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shapes a parse failure as BAD_REQUEST wording', async () => {
|
|
const response = await sendMsg.toHandler(asCtx({}))({});
|
|
|
|
expect(response).toEqual({
|
|
status: 'failed',
|
|
retcode: 1400,
|
|
data: null,
|
|
wording: 'message: is required',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('send_private_msg', () => {
|
|
it('parses group_id 0 and defaults auto_escape', () => {
|
|
expect(
|
|
sendPrivateMsg.parse({
|
|
user_id: 24680,
|
|
message: 'plain private',
|
|
group_id: 0,
|
|
}),
|
|
).toEqual({
|
|
ok: true,
|
|
value: {
|
|
user_id: 24680,
|
|
message: 'plain private',
|
|
group_id: 0,
|
|
auto_escape: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('rejects a negative group_id before sending', () => {
|
|
expect(
|
|
sendPrivateMsg.parse({
|
|
user_id: 24680,
|
|
message: 'x',
|
|
group_id: -1,
|
|
}),
|
|
).toEqual({
|
|
ok: false,
|
|
field: 'group_id',
|
|
reason: 'must be >= 0',
|
|
});
|
|
});
|
|
|
|
it('sends a friend private message when group_id is absent', async () => {
|
|
const sendPrivateMessage = vi.fn(async () => ({ messageId: 9101 }));
|
|
const response = await sendPrivateMsg.toHandler(asCtx({ sendPrivateMessage }))({
|
|
user_id: 24680,
|
|
message: 'friend hi',
|
|
});
|
|
|
|
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9101 } });
|
|
expect(sendPrivateMessage.mock.calls).toEqual([[24680, 'friend hi', false, undefined]]);
|
|
});
|
|
|
|
it('treats group_id 0 as no temp session', async () => {
|
|
const sendPrivateMessage = vi.fn(async () => ({ messageId: 9102 }));
|
|
const response = await sendPrivateMsg.toHandler(asCtx({ sendPrivateMessage }))({
|
|
user_id: 24682,
|
|
group_id: 0,
|
|
message: 'zero group',
|
|
auto_escape: true,
|
|
});
|
|
|
|
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9102 } });
|
|
expect(sendPrivateMessage.mock.calls).toEqual([[24682, 'zero group', true, undefined]]);
|
|
});
|
|
|
|
it('forwards a positive group_id as the temp-session source', async () => {
|
|
const sendPrivateMessage = vi.fn(async () => ({ messageId: 9103 }));
|
|
const response = await sendPrivateMsg.toHandler(asCtx({ sendPrivateMessage }))({
|
|
user_id: 24681,
|
|
group_id: 70002,
|
|
message: 'temp from group',
|
|
});
|
|
|
|
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9103 } });
|
|
expect(sendPrivateMessage.mock.calls).toEqual([[24681, 'temp from group', false, 70002]]);
|
|
});
|
|
});
|
|
|
|
describe('send_group_msg', () => {
|
|
it('requires group_id from the groupAction preset', () => {
|
|
expect(sendGroupMsg.parse({ message: 'need group' })).toEqual({
|
|
ok: false,
|
|
field: 'group_id',
|
|
reason: 'is required',
|
|
});
|
|
expect(sendGroupMsg.parse({ group_id: 888001, message: 'ok' })).toEqual({
|
|
ok: true,
|
|
value: {
|
|
group_id: 888001,
|
|
message: 'ok',
|
|
auto_escape: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('sends the group message and returns the receipt id', async () => {
|
|
const sendGroupMessage = vi.fn(async () => ({ messageId: 9201 }));
|
|
const response = await sendGroupMsg.toHandler(asCtx({ sendGroupMessage }))({
|
|
group_id: 888001,
|
|
message: 'group body',
|
|
auto_escape: true,
|
|
});
|
|
|
|
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9201 } });
|
|
expect(sendGroupMessage.mock.calls).toEqual([[888001, 'group body', true]]);
|
|
});
|
|
});
|
|
|
|
describe('get_msg', () => {
|
|
it('accepts a negative message_id and rejects 0', () => {
|
|
expect(getMsg.parse({ message_id: -2147483648 })).toEqual({
|
|
ok: true,
|
|
value: { message_id: -2147483648 },
|
|
});
|
|
expect(getMsg.parse({ message_id: 0 })).toEqual({
|
|
ok: false,
|
|
field: 'message_id',
|
|
reason: 'must not be 0',
|
|
});
|
|
});
|
|
|
|
it('returns ACTION_FAILED when the store has no event', async () => {
|
|
const getMessage = vi.fn(() => null);
|
|
const response = await getMsg.toHandler(asCtx({ getMessage }))({ message_id: 424243 });
|
|
|
|
expect(response).toEqual({
|
|
status: 'failed',
|
|
retcode: 100,
|
|
data: null,
|
|
wording: 'message not found',
|
|
});
|
|
expect(getMessage.mock.calls).toEqual([[424243]]);
|
|
});
|
|
|
|
it('strips post_type/self_id and sets real_id from the stored message_id', async () => {
|
|
const stored: JsonObject = {
|
|
post_type: 'message',
|
|
self_id: 10001,
|
|
message_id: 424242,
|
|
message_type: 'group',
|
|
group_id: 314159,
|
|
user_id: 271828,
|
|
message: 'plain stored text',
|
|
time: 1700000000,
|
|
};
|
|
const getMessage = vi.fn(() => stored);
|
|
const getImageInfo = vi.fn(async () => ({ url: 'https://unused.example/img' }));
|
|
const response = await getMsg.toHandler(asCtx({ getMessage, getImageInfo }))({
|
|
message_id: 424242,
|
|
});
|
|
|
|
expect(response).toEqual({
|
|
status: 'ok',
|
|
retcode: 0,
|
|
data: {
|
|
message_id: 424242,
|
|
message_type: 'group',
|
|
group_id: 314159,
|
|
user_id: 271828,
|
|
message: 'plain stored text',
|
|
time: 1700000000,
|
|
real_id: 424242,
|
|
},
|
|
});
|
|
expect(stored).toEqual({
|
|
post_type: 'message',
|
|
self_id: 10001,
|
|
message_id: 424242,
|
|
message_type: 'group',
|
|
group_id: 314159,
|
|
user_id: 271828,
|
|
message: 'plain stored text',
|
|
time: 1700000000,
|
|
});
|
|
expect(getImageInfo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back to the request message_id when the stored event has none', async () => {
|
|
const getMessage = vi.fn(() => ({
|
|
message_type: 'private',
|
|
user_id: 13579,
|
|
message: 'no stored id',
|
|
}));
|
|
const response = await getMsg.toHandler(asCtx({ getMessage }))({ message_id: 555001 });
|
|
|
|
expect(response).toEqual({
|
|
status: 'ok',
|
|
retcode: 0,
|
|
data: {
|
|
message_type: 'private',
|
|
user_id: 13579,
|
|
message: 'no stored id',
|
|
real_id: 555001,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('delete_msg', () => {
|
|
it('returns ACTION_FAILED when no retractable meta exists', async () => {
|
|
const getMessageMeta = vi.fn(() => null);
|
|
const deleteMessage = vi.fn(async () => {});
|
|
const response = await deleteMsg.toHandler(asCtx({ getMessageMeta, deleteMessage }))({
|
|
message_id: 424245,
|
|
});
|
|
|
|
expect(response).toEqual({
|
|
status: 'failed',
|
|
retcode: 100,
|
|
data: null,
|
|
wording: 'message not found or not retractable',
|
|
});
|
|
expect(getMessageMeta.mock.calls).toEqual([[424245]]);
|
|
expect(deleteMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('deletes with the stored meta and returns an empty ok payload', async () => {
|
|
const meta: MessageMeta = {
|
|
isGroup: true,
|
|
targetId: 314159,
|
|
sequence: 77,
|
|
sequenceAuthoritative: true,
|
|
eventName: 'group_message',
|
|
clientSequence: 0,
|
|
random: 123,
|
|
timestamp: 1700000000,
|
|
};
|
|
const getMessageMeta = vi.fn(() => meta);
|
|
const deleteMessage = vi.fn(async () => {});
|
|
const response = await deleteMsg.toHandler(asCtx({ getMessageMeta, deleteMessage }))({
|
|
message_id: -424244,
|
|
});
|
|
|
|
expect(response).toEqual({ status: 'ok', retcode: 0, data: null });
|
|
expect(getMessageMeta.mock.calls).toEqual([[-424244]]);
|
|
expect(deleteMessage.mock.calls).toEqual([[-424244, meta]]);
|
|
});
|
|
});
|