// End-to-end OneBot action tests for the napcat-parity surface added in // Tier 1 + Tier 2: send_packet, bot_exit, nc_*, group-todo, AI voice, // and the rewired ignored-notifies family. We drive everything through // the public `ApiHandler.handle()` entry point so the wiring (including // param coercion, retcode shape) is part of what's under test. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ApiHandler, type ApiActionContext } from '../src/api-handler'; import type { BridgeInterface } from '../../src/bridge/bridge-interface'; import type { GroupRequestInfo } from '@snowluma/protocol/qq-info'; import type { GroupEssenceMessage } from '@snowluma/protocol/web/group-essence'; import type { JsonObject, MessageMeta } from '../src/types'; import { GROUP_MESSAGE_EVENT, hashMessageIdInt32 } from '../src/message-id'; function fakeMeta(overrides: Partial = {}): MessageMeta { return { isGroup: true, targetId: 100, sequence: 555, sequenceAuthoritative: true, eventName: 'group_message', clientSequence: 0, random: 0, timestamp: 0, ...overrides, }; } /** * Build a BridgeInterface stub that throws on any method we haven't * pre-stubbed for the test. Same pattern as contact-actions.test.ts — * keeps tests honest about which surface they actually need. */ // Maps flat method names → [area, newMethodName] on the ApiHub under // the #6 refactor. Auto-promotion lets tests written against the // pre-refactor flat surface (`fetchGroupRequests: vi.fn()`) keep // working without per-test restructure, even when the new method name // drops the redundant `Group`/`File` prefix. const APIS_ROUTING: Record = { fetchFriendList: ['contacts', 'fetchFriendList'], fetchFriendCategories: ['contacts', 'fetchFriendCategories'], setFriendCategory: ['contacts', 'setFriendCategory'], fetchGroupList: ['contacts', 'fetchGroupList'], fetchGroupMemberList: ['contacts', 'fetchGroupMemberList'], fetchUserProfile: ['contacts', 'fetchUserProfile'], fetchGroupRequests: ['contacts', 'fetchGroupRequests'], fetchDownloadRKeys: ['contacts', 'fetchDownloadRKeys'], // GroupFileApi: methods drop `Group`/`File`/`Folder` suffix where // the area name already says it. deleteGroupFileFolder: ['groupFile', 'deleteFolder'], fetchGroupPttUrlByNode: ['groupFile', 'getPttUrl'], // InteractionApi: methods drop the redundant `Group` prefix. sendLike: ['interaction', 'sendLike'], setGroupReaction: ['interaction', 'setReaction'], // ProfileApi: a few methods rename (getProfileLike → getLike). setOnlineStatus: ['profile', 'setOnlineStatus'], setDiyOnlineStatus: ['profile', 'setDiyOnlineStatus'], setProfile: ['profile', 'setProfile'], setSelfLongNick: ['profile', 'setSelfLongNick'], setInputStatus: ['profile', 'setInputStatus'], setAvatar: ['profile', 'setAvatar'], setGroupAvatar: ['profile', 'setGroupAvatar'], fetchCustomFace: ['profile', 'fetchCustomFace'], fetchCustomFaceIds: ['profile', 'fetchCustomFaceIds'], fetchCustomFaceDetails: ['profile', 'fetchCustomFaceDetails'], getProfileLike: ['profile', 'getLike'], getUnidirectionalFriendList: ['profile', 'getUnidirectionalFriendList'], // FriendApi: handleRequest/delete/setRemark. setFriendRemark: ['friend', 'setRemark'], deleteFriend: ['friend', 'delete'], setFriendAddRequest: ['friend', 'handleRequest'], // ExtrasApi: group todo / stranger status / AI voice. setGroupTodo: ['extras', 'setGroupTodo'], completeGroupTodo: ['extras', 'completeGroupTodo'], cancelGroupTodo: ['extras', 'cancelGroupTodo'], getGroupTodoList: ['extras', 'getGroupTodoList'], getStrangerStatus: ['extras', 'getStrangerStatus'], fetchAiVoiceList: ['extras', 'fetchAiVoiceList'], fetchAiVoice: ['extras', 'fetchAiVoice'], sendAiVoice: ['extras', 'sendAiVoice'], }; function fakeBridge(overrides: Record = {}): BridgeInterface { const apisSynth: Record> = {}; for (const [k, v] of Object.entries(overrides)) { const route = APIS_ROUTING[k]; if (route) { const [area, newName] = route; if (!apisSynth[area]) { apisSynth[area] = {}; } apisSynth[area][newName] = v; } } const merged = { ...overrides, apis: { ...apisSynth, ...(overrides.apis ?? {}) } }; return new Proxy(merged as BridgeInterface, { get(target, prop) { if (prop in target) { return (target as any)[prop]; } throw new Error(`fakeBridge: '${String(prop)}' was not stubbed`); }, }); } /** * Build the minimum ApiActionContext needed for the tested actions. * Anything not stubbed throws on access — keeps the dependency surface * explicit. Only fields the tested actions read are populated. */ function fakeCtx( bridge: BridgeInterface, overrides: Partial = {}, ): ApiActionContext { const base = { bridge, getMessageMeta: () => null, getMessage: () => null, cacheMessageMetas: () => undefined, listReadSessions: () => ({ groupIds: [], privateUserIds: [] }), getLoginInfo: () => ({ userId: 1, nickname: '' }), isOnline: () => true, canSendImage: () => true, canSendRecord: () => true, getDownloadRKeys: async () => [], ...overrides, }; return new Proxy(base as ApiActionContext, { get(target, prop) { if (prop in target) { return (target as any)[prop]; } throw new Error(`fakeCtx: '${String(prop)}' was not stubbed`); }, }); } function makeHandler(ctx: ApiActionContext): ApiHandler { return new ApiHandler(ctx); } function fakeEssenceMessage(overrides: Partial = {}): GroupEssenceMessage { return { group_code: '123456789', msg_seq: 31415, msg_random: 271828, sender_uin: '10001', sender_nick: 'Alice', sender_time: 1700000000, add_digest_uin: '10002', add_digest_nick: 'Bob', add_digest_time: 1700000100, msg_content: [{ msg_type: 1, text: 'hello essence' }], can_be_removed: true, ...overrides, }; } describe('extended-actions / send_forward_msg', () => { it('treats group_id 0 as unused', async () => { const sendForwardMsg = vi.fn(async () => ({ forwardId: 'fwd' })); const ctx = fakeCtx(fakeBridge(), { sendForwardMsg }); const response = await makeHandler(ctx).handle('send_forward_msg', { messages: 'hi', group_id: 0, }); expect(response).toMatchObject({ status: 'ok', retcode: 0 }); expect(sendForwardMsg).toHaveBeenCalledOnce(); }); it('rejects a non-numeric group_id instead of treating it as 0', async () => { const sendForwardMsg = vi.fn(); const ctx = fakeCtx(fakeBridge(), { sendForwardMsg }); const response = await makeHandler(ctx).handle('send_forward_msg', { messages: 'hi', group_id: 'nope', }); expect(response).toMatchObject({ status: 'failed', retcode: 1400 }); expect(sendForwardMsg).not.toHaveBeenCalled(); }); }); describe('extended-actions / get_forward_msg', () => { it('uses a string message_id as the forward id', async () => { const getForwardMsg = vi.fn(async () => []); const ctx = fakeCtx(fakeBridge(), { getForwardMsg }); const response = await makeHandler(ctx).handle('get_forward_msg', { message_id: 'resid-1', }); expect(getForwardMsg).toHaveBeenCalledWith('resid-1'); expect(response).toMatchObject({ status: 'ok' }); }); }); describe('extended-actions / fetch_ptt_text', () => { it('accepts a numeric message_id', async () => { const fetchPttText = vi.fn(async () => 'hello'); const ctx = fakeCtx(fakeBridge(), { fetchPttText }); const response = await makeHandler(ctx).handle('fetch_ptt_text', { message_id: 12 }); expect(fetchPttText).toHaveBeenCalledWith(12); expect(response).toMatchObject({ status: 'ok', data: 'hello' }); }); }); describe('extended-actions / set_self_longnick', () => { it('accepts an empty longNick to clear the signature', async () => { const setSelfLongNick = vi.fn(async () => undefined); const bridge = fakeBridge({ setSelfLongNick }); const response = await makeHandler(fakeCtx(bridge)).handle('set_self_longnick', { longNick: '', }); expect(response).toMatchObject({ status: 'ok', retcode: 0 }); expect(setSelfLongNick).toHaveBeenCalledWith(''); }); it('accepts an empty long_nick compatibility alias', async () => { const setSelfLongNick = vi.fn(async () => undefined); const bridge = fakeBridge({ setSelfLongNick }); const response = await makeHandler(fakeCtx(bridge)).handle('set_self_longnick', { long_nick: '', }); expect(response).toMatchObject({ status: 'ok', retcode: 0 }); expect(setSelfLongNick).toHaveBeenCalledWith(''); }); it.each([ ['missing value', {}], ['non-string value', { longNick: 0 }], ])('rejects %s without calling the bridge', async (_case, params) => { const setSelfLongNick = vi.fn(async () => undefined); const bridge = fakeBridge({ setSelfLongNick }); const response = await makeHandler(fakeCtx(bridge)).handle('set_self_longnick', params); expect(response).toMatchObject({ status: 'failed', retcode: 1400 }); expect(setSelfLongNick).not.toHaveBeenCalled(); }); }); describe('extended-actions / set_friend_remark', () => { it('reports a confirmed server rejection instead of returning success', async () => { const setFriendRemark = vi.fn(async () => { throw new Error('friend remark was rejected'); }); const bridge = fakeBridge({ setFriendRemark }); const response = await makeHandler(fakeCtx(bridge)).handle('set_friend_remark', { user_id: 10001, remark: 'best-friend', }); expect(setFriendRemark).toHaveBeenCalledWith(10001, 'best-friend'); expect(response).toMatchObject({ status: 'failed', retcode: 100, wording: 'friend remark was rejected', }); }); }); describe('extended-actions / group notice options', () => { it('coerces and forwards every supported announcement option', async () => { const sendNotice = vi.fn(async () => ({ ec: 0 })); const bridge = fakeBridge({ apis: { web: { sendNotice } } }); const response = await makeHandler(fakeCtx(bridge)).handle('_send_group_notice', { group_id: '941657197', content: 'announcement option test', pinned: '1', type: '20', send_to_new_members: 'true', is_show_edit_card: '0', tip_window_type: '0', confirm_required: '0', }); expect(response).toMatchObject({ status: 'ok', retcode: 0 }); expect(sendNotice).toHaveBeenCalledWith(941657197, 'announcement option test', { image: undefined, pinned: 1, type: 20, sendToNewMembers: true, isShowEditCard: 0, tipWindowType: 0, confirmRequired: 0, }); }); it('rejects a semantic/raw target conflict without calling the bridge', async () => { const sendNotice = vi.fn(); const bridge = fakeBridge({ apis: { web: { sendNotice } } }); const response = await makeHandler(fakeCtx(bridge)).handle('_send_group_notice', { group_id: 941657197, content: 'conflict', type: 1, send_to_new_members: true, }); expect(response).toMatchObject({ status: 'failed', retcode: 1400, wording: 'send_to_new_members conflicts with type', }); expect(sendNotice).not.toHaveBeenCalled(); }); it('rejects an unverified publish type without calling the bridge', async () => { const sendNotice = vi.fn(); const bridge = fakeBridge({ apis: { web: { sendNotice } } }); const response = await makeHandler(fakeCtx(bridge)).handle('_send_group_notice', { group_id: 941657197, content: 'unsupported', type: 6, }); expect(response).toMatchObject({ status: 'failed', retcode: 1400 }); expect(sendNotice).not.toHaveBeenCalled(); }); }); describe('extended-actions / get_essence_msg_list', () => { it('projects QQ digest entries into the OneBot essence contract', async () => { const getEssenceAll = vi.fn(async () => [ { retcode: 0, data: { is_end: true, msg_list: [fakeEssenceMessage()], }, }, ]); const bridge = fakeBridge({ apis: { web: { getEssenceAll } } }); const response = await makeHandler(fakeCtx(bridge)).handle('get_essence_msg_list', { group_id: 123456789, }); expect(response).toMatchObject({ status: 'ok', retcode: 0 }); expect(response.data).toEqual([ { msg_seq: 31415, msg_random: 271828, sender_id: 10001, sender_nick: 'Alice', sender_time: 1700000000, operator_id: 10002, operator_nick: 'Bob', operator_time: 1700000100, message_id: -1566581579, content: [{ type: 'text', data: { text: 'hello essence' } }], }, ]); }); it('converts every documented digest content type into OneBot segments', async () => { const plainFile = { msg_type: 4, file_name: 'notes.txt', file_bus_id: 102, file_id: 'plain-file-id', file_size: '1234', }; const previewableFile = { msg_type: 4, file_name: 'clip.mp4', file_bus_id: 102, file_id: 'video-file-id', file_thumbnail_url: 'https://example.test/video.jpg', file_size: '5678', }; const getEssenceAll = vi.fn(async () => [ { retcode: 0, data: { is_end: true, msg_list: [ fakeEssenceMessage({ msg_content: [ { msg_type: 2, face_index: 66 }, { msg_type: 3, image_url: 'https://example.test/image.jpg' }, plainFile, previewableFile, ], }), ], }, }, ]); const bridge = fakeBridge({ apis: { web: { getEssenceAll } } }); const response = await makeHandler(fakeCtx(bridge)).handle('get_essence_msg_list', { group_id: 123456789, }); expect(response).toMatchObject({ status: 'ok', retcode: 0 }); expect((response.data as JsonObject[])[0]?.content).toEqual([ { type: 'face', data: { id: '66' } }, { type: 'image', data: { file: '', url: 'https://example.test/image.jpg' } }, { type: 'file', data: { file: 'notes.txt', file_id: 'plain-file-id', file_size: 1234, name: 'notes.txt', id: 'plain-file-id', size: 1234, busid: 102, }, }, { type: 'file', data: { file: 'clip.mp4', file_id: 'video-file-id', file_size: 5678, name: 'clip.mp4', id: 'video-file-id', size: 5678, busid: 102, }, }, ]); }); it('makes the returned message_id usable for removing the essence mark', async () => { const getEssenceAll = vi.fn(async () => [ { retcode: 0, data: { is_end: true, msg_list: [fakeEssenceMessage()], }, }, ]); const bridge = fakeBridge({ apis: { web: { getEssenceAll } } }); const cached = new Map(); const removeEssence = vi.fn( async (groupId: number, sequence: number, random: number, enabled: boolean) => { expect({ groupId, sequence, random, enabled }).toEqual({ groupId: 123456789, sequence: 31415, random: 271828, enabled: false, }); }, ); const ctx = fakeCtx(bridge, { getMessageMeta: (messageId) => cached.get(messageId) ?? null, cacheMessageMetas: (entries) => { for (const { messageId, meta } of entries) { cached.set(messageId, meta); } }, deleteEssenceMsg: async (messageId) => { const meta = cached.get(messageId); if (!meta) { throw new Error('message not found'); } await removeEssence(meta.targetId, meta.sequence, meta.random, false); }, }); const handler = makeHandler(ctx); const listResponse = await handler.handle('get_essence_msg_list', { group_id: 123456789, }); const removeResponse = await handler.handle('delete_essence_msg', { message_id: -1566581579, }); expect(listResponse).toMatchObject({ status: 'ok', retcode: 0 }); expect(removeResponse).toMatchObject({ status: 'ok', retcode: 0 }); expect(removeEssence).toHaveBeenCalledOnce(); }); it('ignores the null placeholder returned for an empty digest list', async () => { const getEssenceAll = vi.fn(async () => [ { retcode: 0, data: { is_end: true, msg_list: [null] }, }, ]); const bridge = fakeBridge({ apis: { web: { getEssenceAll } } }); const response = await makeHandler(fakeCtx(bridge)).handle('get_essence_msg_list', { group_id: 123456789, }); expect(response).toMatchObject({ status: 'ok', retcode: 0, data: [] }); }); it('surfaces paginated fetch failures without caching partial data', async () => { const getEssenceAll = vi.fn(async () => { throw new Error('second page failed'); }); const bridge = fakeBridge({ apis: { web: { getEssenceAll } } }); const cacheMessageMetas = vi.fn(); const response = await makeHandler(fakeCtx(bridge, { cacheMessageMetas })).handle( 'get_essence_msg_list', { group_id: 123456789 }, ); expect(response).toMatchObject({ status: 'failed', retcode: 100, wording: expect.stringContaining('second page failed'), }); expect(cacheMessageMetas).not.toHaveBeenCalled(); }); it('degrades unknown digest content types to text instead of failing the list', async () => { const getEssenceAll = vi.fn(async () => [ { retcode: 0, data: { is_end: true, msg_list: [ fakeEssenceMessage(), fakeEssenceMessage({ msg_seq: 31416, msg_content: [ { msg_type: 5, share_title: '茶百道换换乐统计', share_summary: '腾讯文档', share_brief: '[腾讯文档] 茶百道换换乐统计', share_url: 'https://docs.qq.com/sheet/example', }, ], }), fakeEssenceMessage({ msg_seq: 31417, msg_content: [{ msg_type: 99 }], }), ], }, }, ]); const cacheMessageMetas = vi.fn(); const bridge = fakeBridge({ apis: { web: { getEssenceAll } } }); const response = await makeHandler(fakeCtx(bridge, { cacheMessageMetas })).handle( 'get_essence_msg_list', { group_id: 123456789 }, ); expect(response).toMatchObject({ status: 'ok', retcode: 0 }); const rows = response.data as JsonObject[]; expect(rows).toHaveLength(3); expect(rows[0]?.content).toEqual([{ type: 'text', data: { text: 'hello essence' } }]); expect(rows[1]?.content).toEqual([ { type: 'text', data: { text: '[腾讯文档] 茶百道换换乐统计\n茶百道换换乐统计\n腾讯文档\nhttps://docs.qq.com/sheet/example', }, }, ]); expect(rows[2]?.content).toEqual([{ type: 'text', data: { text: '[essence type 99]' } }]); expect(cacheMessageMetas).toHaveBeenCalledOnce(); }); it('does not cache message metadata when projection fails', async () => { const getEssenceAll = vi.fn(async () => [ { retcode: 0, data: { is_end: true, msg_list: [ fakeEssenceMessage({ add_digest_uin: 'not-a-uin', }), ], }, }, ]); const bridge = fakeBridge({ apis: { web: { getEssenceAll } } }); const cacheMessageMetas = vi.fn(); const response = await makeHandler(fakeCtx(bridge, { cacheMessageMetas })).handle( 'get_essence_msg_list', { group_id: 123456789 }, ); expect(response).toMatchObject({ status: 'failed', retcode: 100 }); expect(cacheMessageMetas).not.toHaveBeenCalled(); }); it('does not cache earlier entries when a later projection fails', async () => { const getEssenceAll = vi.fn(async () => [ { retcode: 0, data: { is_end: true, msg_list: [ fakeEssenceMessage(), fakeEssenceMessage({ msg_seq: 31416, add_digest_uin: 'not-a-uin', }), ], }, }, ]); const bridge = fakeBridge({ apis: { web: { getEssenceAll } } }); const cacheMessageMetas = vi.fn(); const response = await makeHandler(fakeCtx(bridge, { cacheMessageMetas })).handle( 'get_essence_msg_list', { group_id: 123456789 }, ); expect(response).toMatchObject({ status: 'failed', retcode: 100 }); expect(cacheMessageMetas).not.toHaveBeenCalled(); }); it('rejects entries returned for a different group', async () => { const getEssenceAll = vi.fn(async () => [ { retcode: 0, data: { is_end: true, msg_list: [fakeEssenceMessage({ group_code: '987654321' })], }, }, ]); const bridge = fakeBridge({ apis: { web: { getEssenceAll } } }); const cacheMessageMetas = vi.fn(); const response = await makeHandler(fakeCtx(bridge, { cacheMessageMetas })).handle( 'get_essence_msg_list', { group_id: 123456789 }, ); expect(response).toMatchObject({ status: 'failed', retcode: 100, wording: expect.stringContaining('group_code does not match requested group'), }); expect(cacheMessageMetas).not.toHaveBeenCalled(); }); it('rejects non-positive message sequences', async () => { const getEssenceAll = vi.fn(async () => [ { retcode: 0, data: { is_end: true, msg_list: [fakeEssenceMessage({ msg_seq: 0 })], }, }, ]); const bridge = fakeBridge({ apis: { web: { getEssenceAll } } }); const cacheMessageMetas = vi.fn(); const response = await makeHandler(fakeCtx(bridge, { cacheMessageMetas })).handle( 'get_essence_msg_list', { group_id: 123456789 }, ); expect(response).toMatchObject({ status: 'failed', retcode: 100, wording: expect.stringContaining('msg_seq must be positive'), }); expect(cacheMessageMetas).not.toHaveBeenCalled(); }); it.each([ [{ msg_type: 1 }, 'msg_content.text'], [{ msg_type: 2 }, 'msg_content.face_index'], [{ msg_type: 3 }, 'msg_content.image_url'], [ { msg_type: 4, file_bus_id: 102, file_id: 'file-id', file_size: '1', }, 'msg_content.file_name', ], [ { msg_type: 4, file_name: 'file.txt', file_bus_id: 102, file_size: '1', }, 'msg_content.file_id', ], [ { msg_type: 4, file_name: 'file.txt', file_bus_id: 102, file_id: 'file-id', }, 'msg_content.file_size', ], [ { msg_type: 4, file_name: 'file.txt', file_id: 'file-id', file_size: '1', }, 'msg_content.file_bus_id', ], ])('rejects digest content missing its required field: %s', async (content, field) => { const getEssenceAll = vi.fn(async () => [ { retcode: 0, data: { is_end: true, msg_list: [ fakeEssenceMessage({ msg_content: [content], }), ], }, }, ]); const bridge = fakeBridge({ apis: { web: { getEssenceAll } } }); const cacheMessageMetas = vi.fn(); const response = await makeHandler(fakeCtx(bridge, { cacheMessageMetas })).handle( 'get_essence_msg_list', { group_id: 123456789 }, ); expect(response).toMatchObject({ status: 'failed', retcode: 100, wording: expect.stringContaining(field), }); expect(cacheMessageMetas).not.toHaveBeenCalled(); }); it.each([ [ { msg_type: 4, file_name: 'file.txt', file_bus_id: 102, file_id: 'file-id', file_size: '-1', }, 'msg_content.file_size', ], [ { msg_type: 4, file_name: 'file.txt', file_bus_id: 'not-a-number', file_id: 'file-id', file_size: '1', }, 'msg_content.file_bus_id', ], ])('rejects digest content with an invalid file field: %s', async (content, field) => { const getEssenceAll = vi.fn(async () => [ { retcode: 0, data: { is_end: true, msg_list: [fakeEssenceMessage({ msg_content: [content] })], }, }, ]); const bridge = fakeBridge({ apis: { web: { getEssenceAll } } }); const cacheMessageMetas = vi.fn(); const response = await makeHandler(fakeCtx(bridge, { cacheMessageMetas })).handle( 'get_essence_msg_list', { group_id: 123456789 }, ); expect(response).toMatchObject({ status: 'failed', retcode: 100, wording: expect.stringContaining(field), }); expect(cacheMessageMetas).not.toHaveBeenCalled(); }); it('rejects blank numeric fields instead of coercing them to zero', async () => { const getEssenceAll = vi.fn(async () => [ { retcode: 0, data: { is_end: true, msg_list: [fakeEssenceMessage({ sender_uin: ' ' })], }, }, ]); const bridge = fakeBridge({ apis: { web: { getEssenceAll } } }); const cacheMessageMetas = vi.fn(); const response = await makeHandler(fakeCtx(bridge, { cacheMessageMetas })).handle( 'get_essence_msg_list', { group_id: 123456789 }, ); expect(response).toMatchObject({ status: 'failed', retcode: 100, wording: expect.stringContaining('sender_uin'), }); expect(cacheMessageMetas).not.toHaveBeenCalled(); }); }); describe('extended-actions / get_friends_with_category', () => { it('forwards explicit enrichment and uses the same public field names', async () => { const fetchFriendCategories = vi.fn(async () => [ { categoryId: 0, categoryName: 'Friends', memberCount: 1, sortId: 0, friends: [ { uin: 10001, uid: 'u1', nickname: 'Name', remark: '', profile: { sign: '', age: 0, vipFlag: false, privilegeIcons: { open: [], closed: [] }, }, }, ], }, ]); const handler = makeHandler(fakeCtx(fakeBridge({ fetchFriendCategories }))); expect( await handler.handle('get_friends_with_category', { with_profile: true }), ).toMatchObject({ status: 'ok', data: [ { buddyList: [ { user_id: 10001, category_id: 0, long_nick: '', age: 0, is_qq_vip: false, privilege_icons: { open: [], closed: [] }, }, ], }, ], }); expect(fetchFriendCategories).toHaveBeenCalledWith({ withProfile: true }); expect( await handler.handle('get_friends_with_category', { with_profile: 'invalid' }), ).toMatchObject({ status: 'failed' }); expect(fetchFriendCategories).toHaveBeenCalledOnce(); }); it('returns the exact categorized friend contract', async () => { const fetchFriendCategories = vi.fn(async () => [ { categoryId: 7, categoryName: 'Work', memberCount: 1, sortId: 3, friends: [{ uin: 10001, uid: 'u1', nickname: 'Alice', remark: 'A' }], }, ]); const bridge = fakeBridge({ fetchFriendCategories }); const response = await makeHandler(fakeCtx(bridge)).handle('get_friends_with_category', {}); expect(fetchFriendCategories).toHaveBeenCalledOnce(); expect(response).toMatchObject({ status: 'ok', retcode: 0 }); expect(response.data).toEqual([ { categoryId: 7, categoryName: 'Work', categoryMbCount: 1, buddyList: [{ user_id: 10001, nickname: 'Alice', remark: 'A', category_id: 7 }], }, ]); }); it('surfaces categorized fetch failures', async () => { const bridge = fakeBridge({ fetchFriendCategories: vi.fn(async () => { throw new Error('repeated friend-list cookie aa'); }), }); const response = await makeHandler(fakeCtx(bridge)).handle('get_friends_with_category', {}); expect(response).toMatchObject({ status: 'failed', retcode: 100, wording: 'repeated friend-list cookie aa', }); }); }); describe('extended-actions / set_friends_category', () => { it('moves a friend by category ID', async () => { const setFriendCategory = vi.fn(async () => undefined); const bridge = fakeBridge({ setFriendCategory }); const response = await makeHandler(fakeCtx(bridge)).handle('set_friends_category', { uin: 10001, categoryId: 7, }); expect(response).toMatchObject({ status: 'ok', retcode: 0, data: null }); expect(setFriendCategory).toHaveBeenCalledWith({ uin: 10001, categoryId: 7, categoryName: undefined, }); }); it('moves a friend by an exact category name', async () => { const setFriendCategory = vi.fn(async () => undefined); const bridge = fakeBridge({ setFriendCategory }); const response = await makeHandler(fakeCtx(bridge)).handle('set_friends_category', { uin: 10001, categoryName: 'Work', }); expect(response).toMatchObject({ status: 'ok', retcode: 0, data: null }); expect(setFriendCategory).toHaveBeenCalledWith({ uin: 10001, categoryId: undefined, categoryName: 'Work', }); }); it('rejects missing or conflicting category selectors before calling QQ', async () => { const setFriendCategory = vi.fn(async () => undefined); const bridge = fakeBridge({ setFriendCategory }); const handler = makeHandler(fakeCtx(bridge)); const missing = await handler.handle('set_friends_category', { uin: 10001 }); const conflicting = await handler.handle('set_friends_category', { uin: 10001, categoryId: 7, categoryName: 'Work', }); expect(missing).toMatchObject({ status: 'failed', retcode: 1400 }); expect(conflicting).toMatchObject({ status: 'failed', retcode: 1400 }); expect(setFriendCategory).not.toHaveBeenCalled(); }); it('surfaces roster or server failures instead of reporting success', async () => { const bridge = fakeBridge({ setFriendCategory: vi.fn(async () => { throw new Error('friend 10001 is not in the live roster'); }), }); const response = await makeHandler(fakeCtx(bridge)).handle('set_friends_category', { uin: 10001, categoryId: 7, }); expect(response).toMatchObject({ status: 'failed', retcode: 100, wording: 'friend 10001 is not in the live roster', }); }); }); // ─── Tier 1: send_packet / .send_packet ─── describe('extended-actions / send_packet', () => { it('hex-decodes data, calls Bridge.sendRawPacket, hex-encodes the response', async () => { const sendRawPacket: BridgeInterface['sendRawPacket'] = vi.fn(async () => ({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from([0xde, 0xad, 0xbe, 0xef]), })); const bridge = fakeBridge({ sendRawPacket }); const h = makeHandler(fakeCtx(bridge)); const res = await h.handle('send_packet', { cmd: 'Some.Cmd', data: 'cafebabe', rsp: true }); const spy = vi.mocked(sendRawPacket); expect(spy).toHaveBeenCalledOnce(); const sentBody = spy.mock.calls[0]![1]; expect(Buffer.from(sentBody).toString('hex')).toBe('cafebabe'); expect(res).toMatchObject({ status: 'ok', retcode: 0, data: 'deadbeef' }); }); it('.send_packet shares the same handler', async () => { const sendRawPacket = vi.fn(async () => ({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.alloc(0), })); const h = makeHandler(fakeCtx(fakeBridge({ sendRawPacket: sendRawPacket as any }))); const res = await h.handle('.send_packet', { cmd: 'C', data: '' }); expect(res.status).toBe('ok'); expect(sendRawPacket).toHaveBeenCalledOnce(); }); it('with rsp=false returns null and ignores responseData', async () => { const bridge = fakeBridge({ sendRawPacket: (async () => ({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from('00ff', 'hex'), })) as any, }); const res = await makeHandler(fakeCtx(bridge)).handle('send_packet', { cmd: 'C', data: '', rsp: false, }); expect(res).toMatchObject({ status: 'ok', data: null }); }); it('rejects missing cmd', async () => { const bridge = fakeBridge({ sendRawPacket: vi.fn() as any }); const res = await makeHandler(fakeCtx(bridge)).handle('send_packet', { cmd: '', data: '' }); expect(res).toMatchObject({ status: 'failed', retcode: 1400 }); }); it('rejects malformed hex', async () => { const sendRawPacket = vi.fn(); const bridge = fakeBridge({ sendRawPacket: sendRawPacket as any }); const res = await makeHandler(fakeCtx(bridge)).handle('send_packet', { cmd: 'C', data: 'ZZZZ', }); expect(res).toMatchObject({ status: 'failed', retcode: 1400 }); expect(sendRawPacket).not.toHaveBeenCalled(); }); it('rejects odd-length hex', async () => { const bridge = fakeBridge({ sendRawPacket: vi.fn() as any }); const res = await makeHandler(fakeCtx(bridge)).handle('send_packet', { cmd: 'C', data: 'abc', }); expect(res).toMatchObject({ status: 'failed', retcode: 1400 }); }); it('propagates wire-level failure as action_failed', async () => { const bridge = fakeBridge({ sendRawPacket: (async () => ({ success: false, gotResponse: false, errorCode: -1, errorMessage: 'no sender', responseData: null, })) as any, }); const res = await makeHandler(fakeCtx(bridge)).handle('send_packet', { cmd: 'C', data: '', }); expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'no sender' }); }); }); // ─── Tier 1: bot_exit ─── describe('extended-actions / bot_exit', () => { let exitSpy: ReturnType; beforeEach(() => { vi.useFakeTimers(); exitSpy = vi .spyOn(process, 'exit') .mockImplementation(((_code?: number) => undefined) as any); }); afterEach(() => { vi.useRealTimers(); exitSpy.mockRestore(); }); it('returns ok immediately, then exits on the deferred timer', async () => { const h = makeHandler(fakeCtx(fakeBridge())); const res = await h.handle('bot_exit', {}); expect(res).toMatchObject({ status: 'ok', retcode: 0 }); expect(exitSpy).not.toHaveBeenCalled(); vi.advanceTimersByTime(100); expect(exitSpy).toHaveBeenCalledWith(0); }); }); // ─── History: message_id is a signed int32 hash and is frequently NEGATIVE ─── // Regression: get_{group,friend}_msg_history declared message_id with a // `{min:0}` validator, so a real (negative) anchor message_id was rejected at // param-validation time (retcode 1400) before the handler ran — see the // `message_id=-497311472 ⇒ failed (0ms)` report. describe('extended-actions / history accepts negative message_id', () => { it('forwards an explicit newer-first direction for group history', async () => { const getGroupMsgHistory = vi.fn( async (_groupId: number, _messageId: number, _count: number, reverseOrder: boolean) => [ { message_id: -1, message_type: 'group', direction: reverseOrder ? 'older' : 'newer', }, ], ); const ctx = fakeCtx(fakeBridge(), { getGroupMsgHistory } as any); const res = await makeHandler(ctx).handle('get_group_msg_history', { group_id: 100, message_id: -497311472, count: 25, reverse_order: false, }); expect(res).toMatchObject({ status: 'ok', retcode: 0, data: { messages: [{ direction: 'newer' }] }, }); }); it('get_group_msg_history forwards a negative anchor to the handler', async () => { const getGroupMsgHistory = vi.fn(async (_groupId: number, messageId: number) => [ { message_id: messageId, message_type: 'group', }, ]); const ctx = fakeCtx(fakeBridge(), { getGroupMsgHistory } as any); const res = await makeHandler(ctx).handle('get_group_msg_history', { group_id: 100, message_id: -497311472, count: 100, }); expect(res).toMatchObject({ status: 'ok', retcode: 0, data: { messages: [{ message_id: -497311472 }] }, }); }); it('get_friend_msg_history forwards a negative anchor to the handler', async () => { const getFriendMsgHistory = vi.fn(async (_userId: number, messageId: number) => [ { message_id: messageId, message_type: 'private', }, ]); const ctx = fakeCtx(fakeBridge(), { getFriendMsgHistory } as any); const res = await makeHandler(ctx).handle('get_friend_msg_history', { user_id: 12345, message_id: -670862300, count: 100, }); expect(res).toMatchObject({ status: 'ok', retcode: 0, data: { messages: [{ message_id: -670862300 }] }, }); }); it('forwards an explicit newer-first direction for friend history', async () => { const getFriendMsgHistory = vi.fn( async (_userId: number, _messageId: number, _count: number, reverseOrder: boolean) => [ { message_id: -1, message_type: 'private', direction: reverseOrder ? 'older' : 'newer', }, ], ); const ctx = fakeCtx(fakeBridge(), { getFriendMsgHistory } as any); const res = await makeHandler(ctx).handle('get_friend_msg_history', { user_id: 12345, message_id: -670862300, count: 25, reverse_order: false, }); expect(res).toMatchObject({ status: 'ok', retcode: 0, data: { messages: [{ direction: 'newer' }] }, }); }); it('still defaults an absent message_id to 0 (fetch-latest semantics)', async () => { const getGroupMsgHistory = vi.fn( async (_groupId: number, messageId: number, _count: number, reverseOrder: boolean) => [ { message_id: messageId, direction: reverseOrder ? 'older' : 'newer', }, ], ); const ctx = fakeCtx(fakeBridge(), { getGroupMsgHistory } as any); const res = await makeHandler(ctx).handle('get_group_msg_history', { group_id: 100 }); expect(res).toMatchObject({ status: 'ok', retcode: 0, data: { messages: [{ message_id: 0, direction: 'older' }] }, }); }); }); describe('extended-actions / mark conversation read', () => { it('uses a group message only to identify the conversation', async () => { const markGroupRead = vi.fn(async () => undefined); const bridge = fakeBridge({ apis: { message: { markGroupRead } } }); const ctx = fakeCtx(bridge, { getMessageMeta: () => fakeMeta({ targetId: 12345, sequence: 42708 }), }); const res = await makeHandler(ctx).handle('mark_group_msg_as_read', { message_id: 1 }); expect(markGroupRead).toHaveBeenCalledWith(12345); expect(res).toMatchObject({ status: 'ok', retcode: 0 }); }); it('routes a private message to the C2C read implementation without forwarding its header sequence', async () => { const markPrivateRead = vi.fn(async () => undefined); const bridge = fakeBridge({ apis: { message: { markPrivateRead } } }); const ctx = fakeCtx(bridge, { getMessageMeta: () => fakeMeta({ isGroup: false, targetId: 1787882683, sequence: 42708 }), }); const res = await makeHandler(ctx).handle('mark_private_msg_as_read', { message_id: 1 }); expect(markPrivateRead).toHaveBeenCalledWith(1787882683); expect(res).toMatchObject({ status: 'ok', retcode: 0 }); }); }); // ─── Tier 1: nc_get_packet_status / nc_get_rkey ─── describe('extended-actions / nc_get_packet_status', () => { it('reports healthy with no dependency on bridge', async () => { const h = makeHandler(fakeCtx(fakeBridge())); const res = await h.handle('nc_get_packet_status', {}); expect(res).toEqual({ status: 'ok', retcode: 0, data: null }); }); }); describe('extended-actions / nc_get_rkey', () => { it('reuses the same data the get_rkey handler returns', async () => { const ctx = fakeCtx(fakeBridge(), { getDownloadRKeys: async () => [{ rkey: 'abc', type: 1, ttl: 60, create_time: 1 }], }); const h = makeHandler(ctx); const a = await h.handle('get_rkey', {}); const b = await h.handle('nc_get_rkey', {}); expect(b).toEqual(a); expect(b.data).toEqual([{ rkey: 'abc', type: 1, ttl: 60, create_time: 1 }]); }); }); // ─── Tier 1: group-request ignored / shut list / ignore-add ─── function fakeFilteredRequest(overrides: Partial = {}): GroupRequestInfo { return { groupId: 999, groupName: 'g', targetUid: 'u_t', targetUin: 5555, targetName: 'target', invitorUid: 'u_i', invitorUin: 7777, invitorName: 'inviter', operatorUid: 'u_o', operatorUin: 8888, operatorName: 'op', sequence: 42, state: 1, eventType: 1, comment: 'pls', filtered: true, ...overrides, }; } describe('extended-actions / get_group_ignored_notifies', () => { it('maps filtered fetchGroupRequests(true) into the napcat shape', async () => { const fetchGroupRequests = vi.fn(async (filtered: boolean) => filtered ? [fakeFilteredRequest(), fakeFilteredRequest({ sequence: 43, eventType: 7 })] : [], ); const bridge = fakeBridge({ fetchGroupRequests: fetchGroupRequests as any }); const h = makeHandler(fakeCtx(bridge)); const res = await h.handle('get_group_ignored_notifies', {}); expect(fetchGroupRequests).toHaveBeenCalledWith(true); expect(res.status).toBe('ok'); expect(res.data).toEqual([ { group_id: 999, group_name: 'g', request_id: 42, requester_uin: 5555, requester_nick: 'target', message: 'pls', checked: false, // state == 1 → un-checked actor: 8888, invitor_uin: 7777, invitor_nick: 'inviter', flag: 'slreq:1:42:999:1:1', }, ]); }); it('surfaces filtered-inbox failures instead of returning a false empty result', async () => { const bridge = fakeBridge({ fetchGroupRequests: (async () => { throw new Error('boom'); }) as any, }); const res = await makeHandler(fakeCtx(bridge)).handle('get_group_ignored_notifies', {}); expect(res).toMatchObject({ status: 'failed' }); expect(res.wording).toContain('boom'); }); }); describe('extended-actions / get_group_ignore_add_request', () => { it("projects the same filtered list into napcat's ignore-add-request shape", async () => { const bridge = fakeBridge({ fetchGroupRequests: (async () => [fakeFilteredRequest({ state: 2 })]) as any, }); const res = await makeHandler(fakeCtx(bridge)).handle('get_group_ignore_add_request', {}); expect(res.status).toBe('ok'); expect(res.data).toEqual([ { request_id: 42, invitor_uin: 7777, invitor_nick: 'inviter', group_id: 999, message: 'pls', group_name: 'g', checked: true, // state == 2 → checked actor: 8888, requester_nick: 'target', }, ]); }); }); // ─── Tier 1: delete_group_folder alias ─── describe('extended-actions / delete_group_folder', () => { it('forwards to bridge.deleteGroupFileFolder', async () => { const deleteGroupFileFolder = vi.fn(async () => {}); const bridge = fakeBridge({ deleteGroupFileFolder: deleteGroupFileFolder as any }); const res = await makeHandler(fakeCtx(bridge)).handle('delete_group_folder', { group_id: 100, folder_id: 'fid-1', }); expect(deleteGroupFileFolder).toHaveBeenCalledWith(100, 'fid-1'); expect(res.status).toBe('ok'); }); it('rejects missing fields', async () => { const bridge = fakeBridge({ deleteGroupFileFolder: vi.fn() as any }); const r1 = await makeHandler(fakeCtx(bridge)).handle('delete_group_folder', { folder_id: 'x', }); const r2 = await makeHandler(fakeCtx(bridge)).handle('delete_group_folder', { group_id: 1, }); expect(r1).toMatchObject({ status: 'failed', retcode: 1400 }); expect(r2).toMatchObject({ status: 'failed', retcode: 1400 }); }); }); // ─── Tier 2: group todo ─── describe('extended-actions / set_/complete_/cancel_group_todo', () => { it.each([ ['set_group_todo', 'setGroupTodo'], ['complete_group_todo', 'completeGroupTodo'], ['cancel_group_todo', 'cancelGroupTodo'], ] as const)( '%s resolves message meta then calls bridge.%s with the sequence', async (action, method) => { const bridgeMethod = vi.fn(async () => {}); const bridge = fakeBridge({ [method]: bridgeMethod } as any); const ctx = fakeCtx(bridge, { getMessageMeta: (id: number) => id === 7 ? fakeMeta({ targetId: 100, sequence: 555 }) : null, }); const res = await makeHandler(ctx).handle(action, { group_id: 100, message_id: 7 }); expect(res.status).toBe('ok'); expect(bridgeMethod).toHaveBeenCalledWith(100, 555n); }, ); it('rejects when message meta is missing', async () => { const ctx = fakeCtx(fakeBridge(), { getMessageMeta: () => null }); const res = await makeHandler(ctx).handle('set_group_todo', { group_id: 1, message_id: 9999, }); expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'message not found' }); }); it('rejects when message belongs to a different chat', async () => { const ctx = fakeCtx(fakeBridge(), { getMessageMeta: () => fakeMeta({ targetId: 222 }), }); const res = await makeHandler(ctx).handle('set_group_todo', { group_id: 100, message_id: 1, }); expect(res).toMatchObject({ status: 'failed', retcode: 100 }); }); it('rejects when message is a private message', async () => { const ctx = fakeCtx(fakeBridge(), { getMessageMeta: () => fakeMeta({ isGroup: false }), }); const res = await makeHandler(ctx).handle('set_group_todo', { group_id: 100, message_id: 1, }); expect(res).toMatchObject({ status: 'failed', retcode: 100 }); }); it('rejects a local-only message id without calling the group todo protocol', async () => { const setGroupTodo = vi.fn(async () => {}); const bridge = fakeBridge({ setGroupTodo }); const ctx = fakeCtx(bridge, { getMessageMeta: () => fakeMeta({ sequence: 0, sequenceAuthoritative: false }), }); const res = await makeHandler(ctx).handle('set_group_todo', { group_id: 100, message_id: 1, }); expect(res).toMatchObject({ status: 'failed', retcode: 100 }); expect(setGroupTodo).not.toHaveBeenCalled(); }); it('rejects missing params', async () => { const r1 = await makeHandler(fakeCtx(fakeBridge())).handle('set_group_todo', { message_id: 1, }); const r2 = await makeHandler(fakeCtx(fakeBridge())).handle('set_group_todo', { group_id: 1, }); expect(r1).toMatchObject({ status: 'failed', retcode: 1400 }); expect(r2).toMatchObject({ status: 'failed', retcode: 1400 }); }); }); describe('extended-actions / get_group_todo_list', () => { it('maps QQ identities to OneBot ids, caches metadata, and includes cached content', async () => { const getGroupTodoList = vi.fn(async () => [ { sourceId: '5631_0', sequence: 5631, random: 0, text: '测试待办', createdAt: 1735000000, updatedAt: 1735000001, }, ]); const bridge = fakeBridge({ getGroupTodoList }); const messageId = hashMessageIdInt32(5631, 100, GROUP_MESSAGE_EVENT); const cacheMessageMetas = vi.fn(); const message = [{ type: 'text', data: { text: '原消息' } }]; const ctx = fakeCtx(bridge, { cacheMessageMetas, getMessage: (id) => (id === messageId ? { message } : null), }); const response = await makeHandler(ctx).handle('get_group_todo_list', { group_id: 100 }); expect(getGroupTodoList).toHaveBeenCalledWith(100); expect(cacheMessageMetas).toHaveBeenCalledWith([ { messageId, meta: { isGroup: true, targetId: 100, sequence: 5631, sequenceAuthoritative: true, eventName: GROUP_MESSAGE_EVENT, clientSequence: 0, random: 0, timestamp: 1735000000, }, }, ]); expect(response).toMatchObject({ status: 'ok', data: [ { message_id: messageId, message_seq: 5631, message_random: 0, message, text: '测试待办', create_time: 1735000000, update_time: 1735000001, }, ], }); }); it('still returns a usable message id when the original body is not cached', async () => { const bridge = fakeBridge({ getGroupTodoList: vi.fn(async () => [ { sourceId: '88_0', sequence: 88, random: 0, text: '', createdAt: 0, updatedAt: 0, }, ]), }); const response = await makeHandler(fakeCtx(bridge)).handle('get_group_todo_list', { group_id: 100, }); expect(response).toMatchObject({ status: 'ok', data: [ { message_id: hashMessageIdInt32(88, 100, GROUP_MESSAGE_EVENT), message_seq: 88, message: null, }, ], }); }); }); describe('extended-actions / set_group_reaction', () => { it('rejects a local-only message id without calling the reaction protocol', async () => { const setGroupReaction = vi.fn(async () => {}); const bridge = fakeBridge({ setGroupReaction }); const ctx = fakeCtx(bridge, { getMessageMeta: () => fakeMeta({ sequence: 0, sequenceAuthoritative: false }), }); const res = await makeHandler(ctx).handle('set_group_reaction', { group_id: 100, message_id: 1, code: '66', is_set: true, }); expect(res).toMatchObject({ status: 'failed', retcode: 100 }); expect(setGroupReaction).not.toHaveBeenCalled(); }); }); // ─── Tier 2: nc_get_user_status ─── describe('extended-actions / nc_get_user_status', () => { it('returns whatever bridge.getStrangerStatus reports', async () => { const getStrangerStatus = vi.fn(async () => ({ status: 10, ext_status: 0x1234 })); const bridge = fakeBridge({ getStrangerStatus: getStrangerStatus as any }); const res = await makeHandler(fakeCtx(bridge)).handle('nc_get_user_status', { user_id: 999, }); expect(getStrangerStatus).toHaveBeenCalledWith(999); expect(res).toMatchObject({ status: 'ok', data: { status: 10, ext_status: 0x1234 } }); }); it('reports action_failed when bridge returns null', async () => { const bridge = fakeBridge({ getStrangerStatus: (async () => null) as any }); const res = await makeHandler(fakeCtx(bridge)).handle('nc_get_user_status', { user_id: 1 }); expect(res).toMatchObject({ status: 'failed', retcode: 100 }); }); it('rejects missing user_id', async () => { const bridge = fakeBridge({ getStrangerStatus: vi.fn() as any }); const res = await makeHandler(fakeCtx(bridge)).handle('nc_get_user_status', {}); expect(res).toMatchObject({ status: 'failed', retcode: 1400 }); }); }); // ─── Tier 2: AI voice trio ─── describe('extended-actions / get_ai_characters', () => { it('flattens server categories into {type, characters[]}', async () => { const fetchAiVoiceList = vi.fn(async () => [ { category: 'cute', voices: [ { voiceId: 'v1', voiceDisplayName: 'V1', voiceExampleUrl: 'http://a' }, { voiceId: 'v2', voiceDisplayName: 'V2', voiceExampleUrl: 'http://b' }, ], }, ]); const bridge = fakeBridge({ fetchAiVoiceList: fetchAiVoiceList as any }); const res = await makeHandler(fakeCtx(bridge)).handle('get_ai_characters', { group_id: 100, chat_type: 1, }); expect(fetchAiVoiceList).toHaveBeenCalledWith(100, 1); expect(res.data).toEqual([ { type: 'cute', characters: [ { character_id: 'v1', character_name: 'V1', preview_url: 'http://a' }, { character_id: 'v2', character_name: 'V2', preview_url: 'http://b' }, ], }, ]); }); it('defaults chat_type to 1 (Sound) when unspecified', async () => { const fetchAiVoiceList = vi.fn(async () => []); const bridge = fakeBridge({ fetchAiVoiceList: fetchAiVoiceList as any }); await makeHandler(fakeCtx(bridge)).handle('get_ai_characters', { group_id: 100 }); expect(fetchAiVoiceList).toHaveBeenCalledWith(100, 1); }); it('surfaces bridge errors as action_failed', async () => { const bridge = fakeBridge({ fetchAiVoiceList: (async () => { throw new Error('rate limited'); }) as any, }); const res = await makeHandler(fakeCtx(bridge)).handle('get_ai_characters', { group_id: 100, }); expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'rate limited' }); }); }); describe('extended-actions / get_ai_record', () => { it('feeds IndexNode from fetchAiVoice into fetchGroupPttUrlByNode and returns the URL', async () => { const node = { fileUuid: 'voice-uuid' }; const fetchAiVoice = vi.fn(async () => node); const fetchGroupPttUrlByNode = vi.fn(async () => 'http://voice.silk'); const bridge = fakeBridge({ fetchAiVoice: fetchAiVoice as any, fetchGroupPttUrlByNode: fetchGroupPttUrlByNode as any, }); const res = await makeHandler(fakeCtx(bridge)).handle('get_ai_record', { group_id: 100, character: 'v1', text: 'hello', }); expect(fetchAiVoice).toHaveBeenCalledWith(100, 'v1', 'hello', 1); expect(fetchGroupPttUrlByNode).toHaveBeenCalledWith(100, node); expect(res).toMatchObject({ status: 'ok', data: 'http://voice.silk' }); }); it('rejects missing fields', async () => { const r1 = await makeHandler(fakeCtx(fakeBridge())).handle('get_ai_record', { character: 'v', text: 't', }); const r2 = await makeHandler(fakeCtx(fakeBridge())).handle('get_ai_record', { group_id: 1, text: 't', }); const r3 = await makeHandler(fakeCtx(fakeBridge())).handle('get_ai_record', { group_id: 1, character: 'v', }); expect(r1).toMatchObject({ status: 'failed', retcode: 1400 }); expect(r2).toMatchObject({ status: 'failed', retcode: 1400 }); expect(r3).toMatchObject({ status: 'failed', retcode: 1400 }); }); it('reports action_failed when synthesis exhausts retries', async () => { const bridge = fakeBridge({ fetchAiVoice: (async () => { throw new Error('AI voice synthesis did not complete'); }) as any, }); const res = await makeHandler(fakeCtx(bridge)).handle('get_ai_record', { group_id: 1, character: 'v', text: 't', }); expect(res).toMatchObject({ status: 'failed', retcode: 100 }); }); }); describe('extended-actions / send_group_ai_record', () => { it('returns the canonical group message id from the matched self-message receipt', async () => { const sendAiVoice = vi.fn(async () => ({ sequence: 319, })); const fetchGroupPttUrlByNode = vi.fn(); const bridge = fakeBridge({ sendAiVoice: sendAiVoice as any, fetchGroupPttUrlByNode: fetchGroupPttUrlByNode as any, }); const res = await makeHandler(fakeCtx(bridge)).handle('send_group_ai_record', { group_id: 100, character: 'v', text: 'hi', }); expect(sendAiVoice).toHaveBeenCalledWith(100, 'v', 'hi', 1); expect(fetchGroupPttUrlByNode).not.toHaveBeenCalled(); expect(res).toMatchObject({ status: 'ok', data: { message_id: hashMessageIdInt32(319, 100, GROUP_MESSAGE_EVENT) }, }); }); it('surfaces a missing canonical receipt instead of returning a fake id', async () => { const bridge = fakeBridge({ sendAiVoice: (async () => { throw new Error( 'AI voice was published but no matching group-message receipt arrived', ); }) as any, }); const res = await makeHandler(fakeCtx(bridge)).handle('send_group_ai_record', { group_id: 100, character: 'v', text: 'hi', }); expect(res).toMatchObject({ status: 'failed', retcode: 100 }); expect(res.wording).toMatch(/group-message receipt/i); }); }); // ─── Tier 3: set_diy_online_status ─── describe('extended-actions / set_diy_online_status', () => { it('coerces face_id / face_type from string-or-number and forwards to bridge.setDiyOnlineStatus', async () => { const setDiyOnlineStatus = vi.fn(async () => {}); const bridge = fakeBridge({ setDiyOnlineStatus: setDiyOnlineStatus as any }); const res = await makeHandler(fakeCtx(bridge)).handle('set_diy_online_status', { face_id: '1234', face_type: '2', wording: '摸鱼中', }); expect(res.status).toBe('ok'); expect(setDiyOnlineStatus).toHaveBeenCalledWith(1234, '摸鱼中', 2); }); it('defaults face_type to 1 when omitted, wording to empty string', async () => { const setDiyOnlineStatus = vi.fn(async () => {}); const bridge = fakeBridge({ setDiyOnlineStatus: setDiyOnlineStatus as any }); await makeHandler(fakeCtx(bridge)).handle('set_diy_online_status', { face_id: 99 }); expect(setDiyOnlineStatus).toHaveBeenCalledWith(99, '', 1); }); it('rejects missing face_id', async () => { const setDiyOnlineStatus = vi.fn(); const bridge = fakeBridge({ setDiyOnlineStatus: setDiyOnlineStatus as any }); const res = await makeHandler(fakeCtx(bridge)).handle('set_diy_online_status', { wording: 'x', }); expect(res).toMatchObject({ status: 'failed', retcode: 1400 }); expect(setDiyOnlineStatus).not.toHaveBeenCalled(); }); it('surfaces bridge errors as action_failed with the original message', async () => { const bridge = fakeBridge({ setDiyOnlineStatus: (async () => { throw new Error('denied'); }) as any, }); const res = await makeHandler(fakeCtx(bridge)).handle('set_diy_online_status', { face_id: 1, wording: 'x', }); expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'denied' }); }); }); // ─── set_group_portrait (Lagrange-protocol highway upload, cmdId 3000) ─── describe('extended-actions / set_group_portrait', () => { it('forwards group_id + file to bridge.setGroupAvatar', async () => { const setGroupAvatar = vi.fn(async () => {}); const bridge = fakeBridge({ setGroupAvatar: setGroupAvatar as any }); const res = await makeHandler(fakeCtx(bridge)).handle('set_group_portrait', { group_id: 12345, file: '/tmp/avatar.png', }); expect(res.status).toBe('ok'); expect(setGroupAvatar).toHaveBeenCalledWith(12345, '/tmp/avatar.png'); }); it('rejects missing group_id or file', async () => { const setGroupAvatar = vi.fn(); const bridge = fakeBridge({ setGroupAvatar: setGroupAvatar as any }); const r1 = await makeHandler(fakeCtx(bridge)).handle('set_group_portrait', { file: 'x' }); const r2 = await makeHandler(fakeCtx(bridge)).handle('set_group_portrait', { group_id: 1 }); expect(r1).toMatchObject({ status: 'failed', retcode: 1400 }); expect(r2).toMatchObject({ status: 'failed', retcode: 1400 }); expect(setGroupAvatar).not.toHaveBeenCalled(); }); it('surfaces highway / decode errors as action_failed', async () => { const bridge = fakeBridge({ setGroupAvatar: (async () => { throw new Error('highway 500'); }) as any, }); const res = await makeHandler(fakeCtx(bridge)).handle('set_group_portrait', { group_id: 1, file: 'x.png', }); expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'highway 500' }); }); }); describe('extended-actions / fetch_custom_face', () => { it('return_type=id uses the id list directly', async () => { const fetchCustomFaceIds = vi.fn(async () => [ '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0', '10001_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0', ]); const fetchCustomFace = vi.fn(); const bridge = fakeBridge({ fetchCustomFaceIds, fetchCustomFace }); const response = await makeHandler(fakeCtx(bridge)).handle('fetch_custom_face', { count: '2', return_type: 'id', }); expect(fetchCustomFaceIds).toHaveBeenCalledWith(2); expect(fetchCustomFace).not.toHaveBeenCalled(); expect(response).toMatchObject({ status: 'ok', data: [ '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0', '10001_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0', ], }); }); it('default return_type uses image urls', async () => { const url = 'https://p.qpic.cn/qq_expression/10001/id/0'; const fetchCustomFace = vi.fn(async () => [url]); const fetchCustomFaceIds = vi.fn(); const bridge = fakeBridge({ fetchCustomFace, fetchCustomFaceIds }); const response = await makeHandler(fakeCtx(bridge)).handle('fetch_custom_face', { count: '1', }); expect(fetchCustomFace).toHaveBeenCalledWith(1); expect(fetchCustomFaceIds).not.toHaveBeenCalled(); expect(response).toMatchObject({ status: 'ok', data: [url] }); }); }); describe('extended-actions / fetch_custom_face_detail', () => { it('returns real packet-backed fields with SnowLuma and NapCat resource aliases', async () => { const fetchCustomFaceDetails = vi.fn(async () => [ { emojiId: '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0', url: 'https://p.qpic.cn/qq_expression/10001/id/0', md5: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', desc: '开心', }, ]); const bridge = fakeBridge({ fetchCustomFaceDetails }); const response = await makeHandler(fakeCtx(bridge)).handle('fetch_custom_face_detail', { count: '1', }); expect(fetchCustomFaceDetails).toHaveBeenCalledWith(1); expect(response).toMatchObject({ status: 'ok', data: [ { emoji_id: '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0', resId: '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0', url: 'https://p.qpic.cn/qq_expression/10001/id/0', md5: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', desc: '开心', }, ], }); }); it('defaults to the compatibility page size of 48', async () => { const fetchCustomFaceDetails = vi.fn(async () => []); const bridge = fakeBridge({ fetchCustomFaceDetails }); await makeHandler(fakeCtx(bridge)).handle('fetch_custom_face_detail', {}); expect(fetchCustomFaceDetails).toHaveBeenCalledWith(48); }); it('rejects a negative count before calling the bridge', async () => { const fetchCustomFaceDetails = vi.fn(); const bridge = fakeBridge({ fetchCustomFaceDetails }); const response = await makeHandler(fakeCtx(bridge)).handle('fetch_custom_face_detail', { count: -1, }); expect(response).toMatchObject({ status: 'failed', retcode: 1400 }); expect(fetchCustomFaceDetails).not.toHaveBeenCalled(); }); }); describe('extended-actions / set_group_member_invite_policy', () => { it('forwards the validated policy to the group-admin API', async () => { const setMemberInvitePolicy = vi.fn(async () => undefined); const bridge = fakeBridge({ apis: { groupAdmin: { setMemberInvitePolicy } } }); const response = await makeHandler(fakeCtx(bridge)).handle( 'set_group_member_invite_policy', { group_id: '12345', policy: 'no_approval_under_100', }, ); expect(response).toMatchObject({ status: 'ok', retcode: 0 }); expect(setMemberInvitePolicy).toHaveBeenCalledWith(12345, 'no_approval_under_100'); }); it('rejects an unknown policy before calling the bridge', async () => { const setMemberInvitePolicy = vi.fn(); const bridge = fakeBridge({ apis: { groupAdmin: { setMemberInvitePolicy } } }); const response = await makeHandler(fakeCtx(bridge)).handle( 'set_group_member_invite_policy', { group_id: 12345, policy: 'anything', }, ); expect(response).toMatchObject({ status: 'failed', retcode: 1400 }); expect(setMemberInvitePolicy).not.toHaveBeenCalled(); }); }); describe('extended-actions / set_group_new_member_history_visibility', () => { it('forwards the normalized group id and visibility to the group-admin API', async () => { const setNewMemberHistoryVisibility = vi.fn(async () => undefined); const bridge = fakeBridge({ apis: { groupAdmin: { setNewMemberHistoryVisibility } } }); const response = await makeHandler(fakeCtx(bridge)).handle( 'set_group_new_member_history_visibility', { group_id: '12345', visible: false }, ); expect(response).toMatchObject({ status: 'ok', retcode: 0 }); expect(setNewMemberHistoryVisibility).toHaveBeenCalledWith(12345, false); }); it('rejects a missing visibility value before calling the bridge', async () => { const setNewMemberHistoryVisibility = vi.fn(); const bridge = fakeBridge({ apis: { groupAdmin: { setNewMemberHistoryVisibility } } }); const response = await makeHandler(fakeCtx(bridge)).handle( 'set_group_new_member_history_visibility', { group_id: 12345 }, ); expect(response).toMatchObject({ status: 'failed', retcode: 1400 }); expect(setNewMemberHistoryVisibility).not.toHaveBeenCalled(); }); }); describe('extended-actions / set_group_member_permissions', () => { it('forwards all supplied capability switches to the group-admin API', async () => { const setMemberPermissions = vi.fn(async () => undefined); const bridge = fakeBridge({ apis: { groupAdmin: { setMemberPermissions } } }); const response = await makeHandler(fakeCtx(bridge)).handle('set_group_member_permissions', { group_id: '12345', allow_member_upload_album: 'true', allow_member_temporary_session: false, allow_member_create_group: 1, }); expect(response).toMatchObject({ status: 'ok', retcode: 0 }); expect(setMemberPermissions).toHaveBeenCalledWith(12345, { allowMemberUploadAlbum: true, allowMemberTemporarySession: false, allowMemberCreateGroup: true, }); }); it('accepts a single supplied capability switch', async () => { const setMemberPermissions = vi.fn(async () => undefined); const bridge = fakeBridge({ apis: { groupAdmin: { setMemberPermissions } } }); const response = await makeHandler(fakeCtx(bridge)).handle('set_group_member_permissions', { group_id: 12345, allow_member_upload_album: false, }); expect(response).toMatchObject({ status: 'ok', retcode: 0 }); expect(setMemberPermissions).toHaveBeenCalledWith(12345, { allowMemberUploadAlbum: false, allowMemberTemporarySession: undefined, allowMemberCreateGroup: undefined, }); }); it('rejects an empty update before calling the bridge', async () => { const setMemberPermissions = vi.fn(); const bridge = fakeBridge({ apis: { groupAdmin: { setMemberPermissions } } }); const response = await makeHandler(fakeCtx(bridge)).handle('set_group_member_permissions', { group_id: 12345, }); expect(response).toMatchObject({ status: 'failed', retcode: 1400 }); expect(setMemberPermissions).not.toHaveBeenCalled(); }); }); // ─── Wave 1: get_group_shut_list ─── describe('extended-actions / get_group_shut_list', () => { it('returns only currently-muted members in NapCat shape', async () => { const nowSec = Math.floor(Date.now() / 1000); const fetchGroupMemberList = vi.fn(async () => [ { uin: 111, uid: 'u1', nickname: 'muted', card: '', role: 'member', level: 1, title: '', joinTime: 0, lastSentTime: 0, shutUpTime: nowSec + 3600, }, { uin: 222, uid: 'u2', nickname: 'free', card: '', role: 'member', level: 1, title: '', joinTime: 0, lastSentTime: 0, shutUpTime: 0, }, { uin: 333, uid: 'u3', nickname: 'expired', card: '', role: 'member', level: 1, title: '', joinTime: 0, lastSentTime: 0, shutUpTime: nowSec - 3600, }, ]); const bridge = fakeBridge({ fetchGroupMemberList: fetchGroupMemberList as any }); const res = await makeHandler(fakeCtx(bridge)).handle('get_group_shut_list', { group_id: 12345, }); expect(res.status).toBe('ok'); expect(fetchGroupMemberList).toHaveBeenCalledWith(12345); expect(res.data).toEqual([ { user_id: 111, nickname: 'muted', shut_up_time: nowSec + 3600 }, ]); }); it('rejects missing group_id', async () => { const bridge = fakeBridge({ fetchGroupMemberList: vi.fn() as any }); const res = await makeHandler(fakeCtx(bridge)).handle('get_group_shut_list', {}); expect(res).toMatchObject({ status: 'failed', retcode: 1400 }); }); }); // ─── Wave 1: trans_group_file (0x6D9_0) ─── describe('extended-actions / trans_group_file', () => { it('forwards group_id + file_id to apis.groupFile.trans and returns ok:true', async () => { const trans = vi.fn(async () => ({ saveBusId: 102, saveFilePath: '/saved/path' })); const bridge = fakeBridge({ apis: { groupFile: { trans } } }); const res = await makeHandler(fakeCtx(bridge)).handle('trans_group_file', { group_id: 12345, file_id: 'fid-trans', }); expect(trans).toHaveBeenCalledWith(12345, 'fid-trans'); expect(res).toMatchObject({ status: 'ok', retcode: 0, data: { ok: true } }); }); it('rejects missing required params', async () => { const trans = vi.fn(); const bridge = fakeBridge({ apis: { groupFile: { trans } } }); const r1 = await makeHandler(fakeCtx(bridge)).handle('trans_group_file', { file_id: 'fid', }); const r2 = await makeHandler(fakeCtx(bridge)).handle('trans_group_file', { group_id: 12345, }); expect(r1).toMatchObject({ status: 'failed', retcode: 1400 }); expect(r2).toMatchObject({ status: 'failed', retcode: 1400 }); expect(trans).not.toHaveBeenCalled(); }); }); // ─── Wave 1: get_file (compose image→record cache) ─── describe('extended-actions / get_file', () => { const imageInfo = { file: 'a.jpg', url: 'http://x/a.jpg', file_size: '12', file_name: 'a.jpg' }; const recordInfo = { file: 'b.amr', url: 'http://x/b.amr', file_size: '34', file_name: 'b.amr', }; it('resolves an image file_id via the image cache', async () => { const getImageInfo = vi.fn(async () => imageInfo); const getRecordInfo = vi.fn(async () => null); const res = await makeHandler( fakeCtx(fakeBridge(), { getImageInfo, getRecordInfo }), ).handle('get_file', { file_id: 'a.jpg' }); expect(res).toMatchObject({ status: 'ok', data: imageInfo }); expect(getRecordInfo).not.toHaveBeenCalled(); }); it('falls back to the record cache when not an image', async () => { const getImageInfo = vi.fn(async () => null); const getRecordInfo = vi.fn(async () => recordInfo); const res = await makeHandler( fakeCtx(fakeBridge(), { getImageInfo, getRecordInfo }), ).handle('get_file', { file: 'b.amr' }); expect(res).toMatchObject({ status: 'ok', data: recordInfo }); }); it('fails with a neutral cache-miss message that points to the group-file path', async () => { // A double cache miss is runtime-indistinguishable between "a group-file // file_id was passed (unsupported here)" and "the image/voice really isn't // cached" — run() does not parse the id's shape. So the error must stay // neutral: state the cache miss, then offer the group-file path as // guidance, without asserting "unsupported". const getImageInfo = vi.fn(async () => null); const getRecordInfo = vi.fn(async () => null); const res = await makeHandler( fakeCtx(fakeBridge(), { getImageInfo, getRecordInfo }), ).handle('get_file', { file_id: 'nope' }); expect(res).toMatchObject({ status: 'failed', retcode: 100 }); const wording = (res as { wording?: string }).wording ?? ''; expect(wording).toMatch(/not found in the image\/voice cache/); expect(wording).toMatch(/get_group_file_url/); expect(wording).not.toMatch(/unsupported/); }); it('rejects when neither file nor file_id is given', async () => { const res = await makeHandler(fakeCtx(fakeBridge())).handle('get_file', {}); expect(res).toMatchObject({ status: 'failed', retcode: 1400 }); }); }); // ─── Wave 2: rename_group_file (0x6D6_4) ─── describe('extended-actions / rename_group_file', () => { it('renames a group file via apis.groupFile.rename', async () => { const rename = vi.fn(async () => undefined); const bridge = fakeBridge({ apis: { groupFile: { rename } } }); const res = await makeHandler(fakeCtx(bridge)).handle('rename_group_file', { group_id: 12345, file_id: '/abc', current_parent_directory: '/', new_name: 'new.txt', }); expect(res.status).toBe('ok'); expect(rename).toHaveBeenCalledWith(12345, '/abc', '/', 'new.txt'); }); it('rejects missing required params', async () => { const rename = vi.fn(); const bridge = fakeBridge({ apis: { groupFile: { rename } } }); const res = await makeHandler(fakeCtx(bridge)).handle('rename_group_file', { group_id: 12345, file_id: '/abc', }); expect(res).toMatchObject({ status: 'failed', retcode: 1400 }); expect(rename).not.toHaveBeenCalled(); }); it('surfaces oidb errors — a thrown error maps to the single seam (ACTION_FAILED 100 + message)', async () => { const rename = vi.fn(async () => { throw new Error('rename rejected'); }); const bridge = fakeBridge({ apis: { groupFile: { rename } } }); const res = await makeHandler(fakeCtx(bridge)).handle('rename_group_file', { group_id: 1, file_id: '/a', current_parent_directory: '/', new_name: 'x', }); expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'rename rejected' }); }); }); // ─── Wave 2: get_rkey_server ─── describe('extended-actions / get_rkey_server', () => { it('reshapes download rkeys into the NapCat server shape (private=10, group=20)', async () => { const nowSec = Math.floor(Date.now() / 1000); const getDownloadRKeys = vi.fn(async () => [ { rkey: '&rkey=PRIV', type: 10, ttl: 3600, create_time: 100 }, { rkey: '&rkey=GRP', type: 20, ttl: 7200, create_time: 200 }, ]); const res = await makeHandler(fakeCtx(fakeBridge(), { getDownloadRKeys })).handle( 'get_rkey_server', {}, ); expect(res.status).toBe('ok'); const d = res.data as { private_rkey?: string; group_rkey?: string; expired_time: number; name: string; }; expect(d.private_rkey).toBe('&rkey=PRIV'); expect(d.group_rkey).toBe('&rkey=GRP'); expect(d.name).toBe('SnowLuma'); // expiry = now + min(ttl) = now + 3600 (allow a 1s clock tick) expect(d.expired_time).toBeGreaterThanOrEqual(nowSec + 3600); expect(d.expired_time).toBeLessThanOrEqual(nowSec + 3601); }); it('leaves a missing scope undefined', async () => { const getDownloadRKeys = vi.fn(async () => [ { rkey: '&rkey=PRIV', type: 10, ttl: 3600, create_time: 100 }, ]); const res = await makeHandler(fakeCtx(fakeBridge(), { getDownloadRKeys })).handle( 'get_rkey_server', {}, ); const d = res.data as { private_rkey?: string; group_rkey?: string }; expect(d.private_rkey).toBe('&rkey=PRIV'); expect(d.group_rkey).toBeUndefined(); }); it('fails (not an expired empty shell) when no rkey is available', async () => { const getDownloadRKeys = vi.fn(async () => []); const res = await makeHandler(fakeCtx(fakeBridge(), { getDownloadRKeys })).handle( 'get_rkey_server', {}, ); expect(res).toMatchObject({ status: 'failed', retcode: 100 }); }); }); // ─── Wave 3: ocr_image / .ocr_image (OIDB 0xE07_0) ─── describe('extended-actions / ocr_image', () => { const ocrResult = { texts: [{ text: 'hello', confidence: 99, coordinates: [{ x: 1, y: 2 }] }], language: 'en', }; it('OCRs an http(s) image URL directly', async () => { const ocrImage = vi.fn(async () => ocrResult); const bridge = fakeBridge({ apis: { misc: { ocrImage } } }); const res = await makeHandler(fakeCtx(bridge)).handle('ocr_image', { image: 'https://x/a.jpg', }); expect(res).toMatchObject({ status: 'ok', data: ocrResult }); expect(ocrImage).toHaveBeenCalledWith('https://x/a.jpg'); }); it('resolves a cached image file_id to a url via getImageInfo', async () => { const ocrImage = vi.fn(async () => ocrResult); const getImageInfo = vi.fn(async () => ({ url: 'https://cdn/resolved.jpg' })); const bridge = fakeBridge({ apis: { misc: { ocrImage } } }); const res = await makeHandler(fakeCtx(bridge, { getImageInfo })).handle('ocr_image', { image: 'abc.jpg', }); expect(res.status).toBe('ok'); expect(ocrImage).toHaveBeenCalledWith('https://cdn/resolved.jpg'); }); it('.ocr_image shares the same handler', async () => { const ocrImage = vi.fn(async () => ocrResult); const bridge = fakeBridge({ apis: { misc: { ocrImage } } }); const res = await makeHandler(fakeCtx(bridge)).handle('.ocr_image', { image: 'http://x/a.jpg', }); expect(res.status).toBe('ok'); }); it('fails when the id cannot be resolved to a url', async () => { const ocrImage = vi.fn(); const getImageInfo = vi.fn(async () => null); const bridge = fakeBridge({ apis: { misc: { ocrImage } } }); const res = await makeHandler(fakeCtx(bridge, { getImageInfo })).handle('ocr_image', { image: 'unknown', }); expect(res.status).toBe('failed'); expect(ocrImage).not.toHaveBeenCalled(); }); it('rejects missing image', async () => { const bridge = fakeBridge({ apis: { misc: { ocrImage: vi.fn() } } }); const res = await makeHandler(fakeCtx(bridge)).handle('ocr_image', {}); expect(res).toMatchObject({ status: 'failed', retcode: 1400 }); }); }); // ─── TierB Phase 1: compatibility actions ─── // NOTE two deliberate divergences from NapCat: // • _get_model_show reuses NapCat's array/variants shape but ECHOES the // requested model instead of NapCat's hardcoded 'napcat'. // • get_online_clients returns the OneBot-v11/go-cqhttp { clients } // envelope, NOT NapCat's (non-standard) bare array. Its contents come from // the latest QQ online-device snapshot observed by this Bridge lifecycle. describe('extended-actions / TierB compat stubs', () => { it('_get_model_show returns the napcat-shaped variants array, echoing the model', async () => { const res = await makeHandler(fakeCtx(fakeBridge())).handle('_get_model_show', { model: 'MyPhone', }); expect(res.status).toBe('ok'); // NapCat shape: data = [{ variants: { model_show, need_pay } }] expect(Array.isArray(res.data)).toBe(true); expect(res.data).toHaveLength(1); expect((res.data as any)[0].variants).toMatchObject({ model_show: 'MyPhone', need_pay: false, }); }); it('_get_model_show defaults model_show to snowluma when model is absent or empty', async () => { for (const params of [{}, { model: '' }]) { const res = await makeHandler(fakeCtx(fakeBridge())).handle('_get_model_show', params); expect(res.status).toBe('ok'); expect((res.data as any)[0].variants.model_show).toBe('snowluma'); expect((res.data as any)[0].variants.need_pay).toBe(false); } }); it('_set_model_show is an accepted no-op', async () => { const res = await makeHandler(fakeCtx(fakeBridge())).handle('_set_model_show', { model: 'x', model_show: 'y', }); expect(res).toMatchObject({ status: 'ok', retcode: 0, data: null }); }); it('get_online_clients returns the latest observed QQ device snapshot', async () => { const getOnlineClients = vi.fn(() => [ { appId: 537242075, instanceId: 202, clientType: 1, platform: 3, deviceName: 'DESKTOP-TEST', deviceKind: 'computer', }, ]); const res = await makeHandler(fakeCtx(fakeBridge({ getOnlineClients }))).handle( 'get_online_clients', {}, ); expect(getOnlineClients).toHaveBeenCalledOnce(); expect(res).toMatchObject({ status: 'ok', retcode: 0, data: { clients: [ { app_id: 537242075, device_name: 'DESKTOP-TEST', device_kind: '电脑', }, ], }, }); }); it('get_online_clients reports an unavailable snapshot instead of a false empty list', async () => { const res = await makeHandler(fakeCtx(fakeBridge({ getOnlineClients: () => null }))).handle( 'get_online_clients', {}, ); expect(res).toMatchObject({ status: 'failed', retcode: 100 }); expect(res.wording).toMatch(/snapshot has not been observed/i); }); it('get_online_clients preserves an observed empty snapshot as a successful result', async () => { const res = await makeHandler(fakeCtx(fakeBridge({ getOnlineClients: () => [] }))).handle( 'get_online_clients', {}, ); expect(res).toMatchObject({ status: 'ok', retcode: 0, data: { clients: [] } }); }); it('get_online_clients rejects a forced refresh that QQ does not expose on the packet wire', async () => { const getOnlineClients = vi.fn(() => []); const res = await makeHandler(fakeCtx(fakeBridge({ getOnlineClients }))).handle( 'get_online_clients', { no_cache: true }, ); expect(res).toMatchObject({ status: 'failed', retcode: 100 }); expect(res.wording).toMatch(/fresh refresh is unavailable/i); expect(getOnlineClients).not.toHaveBeenCalled(); }); it('_mark_all_as_read passes every observed session to the batched SSO implementation', async () => { const markAllRead = vi.fn(async () => undefined); const bridge = fakeBridge({ apis: { message: { markAllRead } } }); const res = await makeHandler( fakeCtx(bridge, { listReadSessions: () => ({ groupIds: [101, 102], privateUserIds: [201] }), }), ).handle('_mark_all_as_read', {}); expect(markAllRead).toHaveBeenCalledWith([101, 102], [201]); expect(res).toMatchObject({ status: 'ok', retcode: 0, data: null }); }); }); // ─── TierB ①: get_group_signed_list (qun.qq.com HTTP, real) ─── // Thin wrapper over WebApi.getSignedList; we pin that the action drives // the web api with the numeric group id and passes the mapped list through. describe('extended-actions / get_group_signed_list', () => { it('calls web.getSignedList with the group id and returns the list', async () => { const list = [{ user_id: 10001, nick: 'Alice', time: 1700000000, rank: 1 }]; const getSignedList = vi.fn(async () => list); const bridge = fakeBridge({ apis: { web: { getSignedList } } }); const res = await makeHandler(fakeCtx(bridge)).handle('get_group_signed_list', { group_id: 12345, }); expect(getSignedList).toHaveBeenCalledWith(12345); expect(res).toMatchObject({ status: 'ok', retcode: 0, data: list }); }); it('surfaces a failure as a failed response', async () => { const getSignedList = vi.fn(async () => { throw new Error('no pskey'); }); const bridge = fakeBridge({ apis: { web: { getSignedList } } }); const res = await makeHandler(fakeCtx(bridge)).handle('get_group_signed_list', { group_id: 12345, }); expect(res.status).toBe('failed'); }); }); // ─── TierB ②: get_recent_contact (documented stub) ─── // QQ's recent-contact list is a kernel-local snapshot with rich peer // metadata (peerName/remark/lastestMsg) that SnowLuma can't reproduce — // there's no SSO/packet wire, and the bot's own message store only covers // sessions it observed and lacks those fields. Rather than ship a // divergent approximation under a name implying QQ's native list, we // return an honest empty list and accept the `count` param for compat. describe('extended-actions / get_recent_contact stub', () => { it('returns an empty list and accepts count', async () => { const res = await makeHandler(fakeCtx(fakeBridge())).handle('get_recent_contact', { count: 10, }); expect(res).toMatchObject({ status: 'ok', retcode: 0 }); expect(res.data).toEqual([]); }); it('works with no params', async () => { const res = await makeHandler(fakeCtx(fakeBridge())).handle('get_recent_contact', {}); expect(res).toMatchObject({ status: 'ok', data: [] }); }); }); // ─── TierB ③: RE'd OIDB-backed actions (wiring through handle) ─── describe('extended-actions / TierB ③ share + doubt + robot-option', () => { it('share_peer with user_id calls getBuddyRecommendArk and wraps the ark', async () => { const getBuddyRecommendArk = vi.fn(async () => '{"app":"x"}'); const bridge = fakeBridge({ apis: { contacts: { getBuddyRecommendArk } } }); const res = await makeHandler(fakeCtx(bridge)).handle('share_peer', { user_id: 10000 }); expect(getBuddyRecommendArk).toHaveBeenCalledWith(10000, ''); expect(res).toMatchObject({ status: 'ok', data: { arkMsg: '{"app":"x"}' } }); }); it('share_peer with group_id calls getGroupRecommendArk', async () => { const getGroupRecommendArk = vi.fn(async () => '{"app":"g"}'); const bridge = fakeBridge({ apis: { contacts: { getGroupRecommendArk } } }); const res = await makeHandler(fakeCtx(bridge)).handle('share_peer', { group_id: 555 }); expect(getGroupRecommendArk).toHaveBeenCalledWith(555); expect(res).toMatchObject({ status: 'ok', data: { arkMsg: '{"app":"g"}' } }); }); it('share_peer with neither id fails', async () => { const res = await makeHandler(fakeCtx(fakeBridge())).handle('share_peer', {}); expect(res.status).toBe('failed'); }); it('send_ark_share shares the buddy/group routing', async () => { const getBuddyRecommendArk = vi.fn(async () => 'ARK'); const bridge = fakeBridge({ apis: { contacts: { getBuddyRecommendArk } } }); const res = await makeHandler(fakeCtx(bridge)).handle('send_ark_share', { user_id: 1, phone_number: '99', }); expect(getBuddyRecommendArk).toHaveBeenCalledWith(1, '99'); expect(res).toMatchObject({ status: 'ok', data: { arkMsg: 'ARK' } }); }); it('share_group_ex / send_group_ark_share return the group ark string', async () => { const getGroupRecommendArk = vi.fn(async () => 'GROUP_ARK'); const bridge = fakeBridge({ apis: { contacts: { getGroupRecommendArk } } }); for (const name of ['share_group_ex', 'send_group_ark_share']) { const res = await makeHandler(fakeCtx(bridge)).handle(name, { group_id: 42 }); expect(res).toMatchObject({ status: 'ok', data: 'GROUP_ARK' }); } expect(getGroupRecommendArk).toHaveBeenCalledWith(42); }); it('get_doubt_friends_add_request returns the mapped list', async () => { const list = [ { uid: 'u1', user_id: 10001, nick: 'A', source: 's', reason: '', msg: 'm', group_code: '', reqTime: 123, }, ]; const getDoubtRequests = vi.fn(async () => list); const bridge = fakeBridge({ apis: { friend: { getDoubtRequests } } }); const res = await makeHandler(fakeCtx(bridge)).handle('get_doubt_friends_add_request', { count: 5, }); expect(getDoubtRequests).toHaveBeenCalledWith(5); expect(res).toMatchObject({ status: 'ok', data: list }); }); it('set_doubt_friends_add_request approves by flag (uid)', async () => { const approveDoubtRequest = vi.fn(async () => {}); const bridge = fakeBridge({ apis: { friend: { approveDoubtRequest } } }); const res = await makeHandler(fakeCtx(bridge)).handle('set_doubt_friends_add_request', { flag: 'u_abc', approve: true, }); expect(approveDoubtRequest).toHaveBeenCalledWith('u_abc'); expect(res).toMatchObject({ status: 'ok' }); }); it('set_doubt_friends_add_request with approve:false calls rejectDoubtRequest (not approve)', async () => { const approveDoubtRequest = vi.fn(async () => {}); const rejectDoubtRequest = vi.fn(async () => {}); const bridge = fakeBridge({ apis: { friend: { approveDoubtRequest, rejectDoubtRequest } }, }); const res = await makeHandler(fakeCtx(bridge)).handle('set_doubt_friends_add_request', { flag: 'u_abc', approve: false, }); expect(res).toMatchObject({ status: 'ok' }); expect(rejectDoubtRequest).toHaveBeenCalledWith('u_abc'); expect(approveDoubtRequest).not.toHaveBeenCalled(); }); it('set_doubt_friends_add_request reports an approval error without rejecting the applicant', async () => { const approveDoubtRequest = vi.fn(async () => { throw new Error('approval denied'); }); const rejectDoubtRequest = vi.fn(async () => {}); const bridge = fakeBridge({ apis: { friend: { approveDoubtRequest, rejectDoubtRequest } }, }); const res = await makeHandler(fakeCtx(bridge)).handle('set_doubt_friends_add_request', { flag: 'u_abc', }); expect(res).toMatchObject({ status: 'failed', wording: 'approval denied' }); expect(approveDoubtRequest).toHaveBeenCalledOnce(); expect(rejectDoubtRequest).not.toHaveBeenCalled(); }); it('set_group_robot_add_option forwards group + switch/examine', async () => { const setRobotAddOption = vi.fn(async () => {}); const bridge = fakeBridge({ apis: { groupAdmin: { setRobotAddOption } } }); const res = await makeHandler(fakeCtx(bridge)).handle('set_group_robot_add_option', { group_id: 12345, robot_member_switch: 1, robot_member_examine: 2, }); expect(setRobotAddOption).toHaveBeenCalledWith(12345, 1, 2); expect(res).toMatchObject({ status: 'ok' }); }); }); // ─── TierB ③: send_ark (图文 Ark 卡片, OIDB 0xdc2_34) ─── describe('extended-actions / send_tuwen_ark', () => { const arkParams = { title: '标题', desc: '描述', jump_url: 'https://example.com', }; it('routes to group when group_id is given (peerType=1)', async () => { const sendTuwenArk = vi.fn(async () => {}); const bridge = fakeBridge({ apis: { contacts: { sendTuwenArk } } }); const res = await makeHandler(fakeCtx(bridge)).handle('send_tuwen_ark', { group_id: 12345, ...arkParams, }); expect(res).toMatchObject({ status: 'ok', retcode: 0, data: null }); expect(sendTuwenArk).toHaveBeenCalledWith({ targetId: 12345, peerType: 1, title: '标题', desc: '描述', summary: '[分享]', previewUrl: 'https://tangram-1251316161.file.myqcloud.com/files/20210721/e50a8e37e08f29bf1ffc7466e1950690.png', jumpUrl: 'https://example.com', }); }); it('routes to C2C when user_id is given (peerType=0)', async () => { const sendTuwenArk = vi.fn(async () => {}); const bridge = fakeBridge({ apis: { contacts: { sendTuwenArk } } }); const res = await makeHandler(fakeCtx(bridge)).handle('send_tuwen_ark', { user_id: 10001, ...arkParams, }); expect(res).toMatchObject({ status: 'ok', retcode: 0, data: null }); expect(sendTuwenArk).toHaveBeenCalledWith({ targetId: 10001, peerType: 0, title: '标题', desc: '描述', summary: '[分享]', previewUrl: 'https://tangram-1251316161.file.myqcloud.com/files/20210721/e50a8e37e08f29bf1ffc7466e1950690.png', jumpUrl: 'https://example.com', }); }); it('prefers group_id over user_id when both are supplied', async () => { const sendTuwenArk = vi.fn(async () => {}); const bridge = fakeBridge({ apis: { contacts: { sendTuwenArk } } }); const res = await makeHandler(fakeCtx(bridge)).handle('send_tuwen_ark', { group_id: 12345, user_id: 10001, ...arkParams, }); expect(res).toMatchObject({ status: 'ok' }); expect(sendTuwenArk).toHaveBeenCalledWith( expect.objectContaining({ peerType: 1, targetId: 12345 }), ); }); it('rejects when neither user_id nor group_id is given', async () => { const sendTuwenArk = vi.fn(); const bridge = fakeBridge({ apis: { contacts: { sendTuwenArk } } }); const res = await makeHandler(fakeCtx(bridge)).handle('send_tuwen_ark', { ...arkParams }); expect(res).toMatchObject({ status: 'failed', retcode: 1400 }); expect(sendTuwenArk).not.toHaveBeenCalled(); }); it('surfaces bridge errors as action_failed', async () => { const sendTuwenArk = vi.fn(async () => { throw new Error('oidb 0xdc2 rejected'); }); const bridge = fakeBridge({ apis: { contacts: { sendTuwenArk } } }); const res = await makeHandler(fakeCtx(bridge)).handle('send_tuwen_ark', { group_id: 12345, ...arkParams, }); expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'oidb 0xdc2 rejected', }); }); }); // ─── napcat-parity: get_qun_album_list (QQ NT AlbumService) ─── describe('extended-actions / get_group_album_list', () => { it('returns the cover and last-upload metadata from the core facade', async () => { const albumList = [ { id: 'a1', name: '相册一', picNum: 5, createTime: 1700000000, desc: '', owner: '10001', createuin: '10001', createnickname: '测试用户', last_upload_time: 1700000123, cover: { type: 1, image: null }, }, ]; const list = vi.fn(async () => albumList); const bridge = fakeBridge({ apis: { groupAlbum: { list } } }); const res = await makeHandler(fakeCtx(bridge)).handle('get_group_album_list', { group_id: 12345, }); expect(list).toHaveBeenCalledWith(12345); expect(res).toMatchObject({ status: 'ok', data: albumList }); }); }); describe('extended-actions / get_qun_album_list', () => { it('forwards the pagination cursor and returns the native AlbumService envelope', async () => { const albumList = [ { album_id: 'a1', name: '相册一', create_time: '1700000000', last_upload_time: '1700000123', upload_number: '5', cover: { type: 1, image: null }, creator: { uin: '10001', nick: '😂[em]e328514[/em]' }, }, ]; const listQun = vi.fn(async () => ({ albumList, attachInfo: 'next-cursor', hasMore: true, })); const bridge = fakeBridge({ apis: { groupAlbum: { listQun } } }); const res = await makeHandler(fakeCtx(bridge)).handle('get_qun_album_list', { group_id: 12345, attach_info: 'current-cursor', }); expect(listQun).toHaveBeenCalledWith(12345, 'current-cursor'); expect(res.status).toBe('ok'); expect(res.data).toEqual({ album_list: albumList, attach_info: 'next-cursor', has_more: true, }); }); it('surfaces failure as a failed response', async () => { const listQun = vi.fn(async () => { throw new Error('album service unavailable'); }); const bridge = fakeBridge({ apis: { groupAlbum: { listQun } } }); const res = await makeHandler(fakeCtx(bridge)).handle('get_qun_album_list', { group_id: 1, }); expect(res.status).toBe('failed'); }); }); // ─── flash-transfer: download_fileset (闪传文件集下载到本地) ─── // 接线测试:参数校验 + facade 错误传播。完整下载链路(0x93d3/0x93d4 取链接 → // HTTP GET → 落盘 data/downloads)依赖真实 OIDB 与文件系统,与 download_file // 一样靠端到端验证,此处不重复 mock fetch/fs。 describe('extended-actions / download_fileset', () => { it('rejects missing fileset_id', async () => { const downloadFileset = vi.fn(async () => ({ url: '', fileName: '', fileSize: 0 })); const bridge = fakeBridge({ apis: { flashTransfer: { downloadFileset: downloadFileset as any } }, }); const res = await makeHandler(fakeCtx(bridge)).handle('download_fileset', {}); expect(res).toMatchObject({ status: 'failed', retcode: 1400 }); expect(downloadFileset).not.toHaveBeenCalled(); }); it('surfaces facade errors as action_failed', async () => { const downloadFileset = vi.fn(async () => { throw new Error('no download url available'); }); const bridge = fakeBridge({ apis: { flashTransfer: { downloadFileset: downloadFileset as any } }, }); const res = await makeHandler(fakeCtx(bridge)).handle('download_fileset', { fileset_id: 'abc', }); expect(downloadFileset).toHaveBeenCalledOnce(); expect(downloadFileset.mock.calls[0]![0]).toBe('abc'); expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'no download url available', }); }); }); // ─── flash-transfer: send_flash_msg (0x93d7 发送闪传文件) ─── // 接线测试:参数校验 + 私聊/群聊转发 + 错误传播。完整链路(user_id→uid / group_id → // 0x93d7)依赖真实 identity 与 OIDB,靠 send_packet 端到端验证。 describe('extended-actions / send_flash_msg', () => { it('rejects when neither user_id nor group_id given', async () => { const sendFlashMsg = vi.fn(async () => {}); const bridge = fakeBridge({ apis: { flashTransfer: { sendFlashMsg: sendFlashMsg as any } }, }); const res = await makeHandler(fakeCtx(bridge)).handle('send_flash_msg', { fileset_id: 'abc', }); expect(res).toMatchObject({ status: 'failed', retcode: 1400 }); expect(sendFlashMsg).not.toHaveBeenCalled(); }); it('forwards fileset_id + user_id (private) and returns message_id 0', async () => { const sendFlashMsg = vi.fn(async () => {}); const bridge = fakeBridge({ apis: { flashTransfer: { sendFlashMsg: sendFlashMsg as any } }, }); const res = await makeHandler(fakeCtx(bridge)).handle('send_flash_msg', { fileset_id: 'fs-1', user_id: 12345, }); expect(sendFlashMsg).toHaveBeenCalledWith('fs-1', { userId: 12345, groupId: undefined }); expect(res).toMatchObject({ status: 'ok', data: { message_id: 0 } }); }); it('forwards fileset_id + group_id (group) and returns message_id 0', async () => { const sendFlashMsg = vi.fn(async () => {}); const bridge = fakeBridge({ apis: { flashTransfer: { sendFlashMsg: sendFlashMsg as any } }, }); const res = await makeHandler(fakeCtx(bridge)).handle('send_flash_msg', { fileset_id: 'fs-1', group_id: 1017438661, }); expect(sendFlashMsg).toHaveBeenCalledWith('fs-1', { userId: undefined, groupId: 1017438661, }); expect(res).toMatchObject({ status: 'ok', data: { message_id: 0 } }); }); it('surfaces facade errors (e.g. uid resolve failed) as action_failed', async () => { const sendFlashMsg = vi.fn(async () => { throw new Error('failed to resolve UID for UIN 999'); }); const bridge = fakeBridge({ apis: { flashTransfer: { sendFlashMsg: sendFlashMsg as any } }, }); const res = await makeHandler(fakeCtx(bridge)).handle('send_flash_msg', { fileset_id: 'fs-1', user_id: 999, }); expect(res).toMatchObject({ status: 'failed', retcode: 100 }); }); }); // ─── flash-transfer: get_fileset_id (分享码→fileset_id, HTTP 网页解析) ─── describe('extended-actions / get_fileset_id', () => { it('forwards share_code to facade and returns fileset_id', async () => { const getFilesetIdByCode = vi.fn(async () => '8e40afa1-829d-498b-852f-092394ddb31f'); const bridge = fakeBridge({ apis: { flashTransfer: { getFilesetIdByCode: getFilesetIdByCode as any } }, }); const res = await makeHandler(fakeCtx(bridge)).handle('get_fileset_id', { share_code: 'K0sEqhYria', }); expect(getFilesetIdByCode).toHaveBeenCalledWith('K0sEqhYria'); expect(res).toMatchObject({ status: 'ok', data: { fileset_id: '8e40afa1-829d-498b-852f-092394ddb31f' }, }); }); it('rejects missing share_code', async () => { const getFilesetIdByCode = vi.fn(async () => 'x'); const bridge = fakeBridge({ apis: { flashTransfer: { getFilesetIdByCode: getFilesetIdByCode as any } }, }); const res = await makeHandler(fakeCtx(bridge)).handle('get_fileset_id', {}); expect(res).toMatchObject({ status: 'failed', retcode: 1400 }); expect(getFilesetIdByCode).not.toHaveBeenCalled(); }); it('surfaces facade errors (e.g. not found) as action_failed', async () => { const getFilesetIdByCode = vi.fn(async () => { throw new Error('get_fileset_id: fileset_id not found in share page'); }); const bridge = fakeBridge({ apis: { flashTransfer: { getFilesetIdByCode: getFilesetIdByCode as any } }, }); const res = await makeHandler(fakeCtx(bridge)).handle('get_fileset_id', { share_code: 'invalid', }); expect(res).toMatchObject({ status: 'failed', retcode: 100 }); }); }); describe('extended-actions / create_flash_task (#361)', () => { it('accepts a path string', async () => { const createFlashTask = vi.fn(async () => ({ filesetId: 'fs-1' })); const bridge = fakeBridge({ apis: { flashTransfer: { createFlashTask } } }); const res = await makeHandler(fakeCtx(bridge)).handle('create_flash_task', { files: '/tmp/a.pdf', }); expect(res).toMatchObject({ status: 'ok', data: { fileset_id: 'fs-1', task_id: 'fs-1' } }); expect(createFlashTask).toHaveBeenCalledWith( [{ file: '/tmp/a.pdf' }], undefined, undefined, ); }); it('accepts { file, name } and forwards the per-file name', async () => { const createFlashTask = vi.fn(async () => ({ filesetId: 'fs-2' })); const bridge = fakeBridge({ apis: { flashTransfer: { createFlashTask } } }); const res = await makeHandler(fakeCtx(bridge)).handle('create_flash_task', { files: { file: '/tmp/uuid__a.pdf', name: 'a.pdf' }, name: '卡片', }); expect(res.status).toBe('ok'); expect(createFlashTask).toHaveBeenCalledWith( [{ file: '/tmp/uuid__a.pdf', name: 'a.pdf' }], '卡片', undefined, ); }); it('rejects an empty files value', async () => { const createFlashTask = vi.fn(async () => ({ filesetId: 'fs' })); const bridge = fakeBridge({ apis: { flashTransfer: { createFlashTask } } }); const res = await makeHandler(fakeCtx(bridge)).handle('create_flash_task', { files: [] }); expect(res).toMatchObject({ status: 'failed', retcode: 1400, wording: 'files must not be empty', }); expect(createFlashTask).not.toHaveBeenCalled(); }); it('rejects a non-string per-file name', async () => { const createFlashTask = vi.fn(async () => ({ filesetId: 'fs' })); const bridge = fakeBridge({ apis: { flashTransfer: { createFlashTask } } }); const res = await makeHandler(fakeCtx(bridge)).handle('create_flash_task', { files: { file: '/tmp/a.pdf', name: 1 }, }); expect(res).toMatchObject({ status: 'failed', retcode: 1400, wording: 'files[].name must be a string', }); expect(createFlashTask).not.toHaveBeenCalled(); }); });