dsh-im-ops/test/channels/feishu/slash-command-registry.test.mjs

142 lines
5.5 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
listSlashCommands,
registerSlashCommands,
SLASH_COMMAND_TENANT_SCOPES,
SLASH_COMMAND_MANIFEST,
} from '../../../src/channels/feishu/slash-command-registry.mjs';
function fakeHttpInstance(handlers) {
const requests = [];
return {
requests,
http: {
async request(options) {
requests.push(options);
const { method, url } = options;
const key = method + ' ' + url.split('/open-apis/')[1];
const handler = handlers[key];
if (typeof handler !== 'function') {
throw new Error(`no fake handler for ${key}`);
}
return handler(options);
},
},
};
}
const AUTH_KEY = 'POST auth/v3/tenant_access_token/internal';
const LIST_KEY = 'GET application/v7/app_slash_commands';
test('SLASH_COMMAND_MANIFEST is non-empty and has no leading slash', () => {
assert.deepEqual(SLASH_COMMAND_TENANT_SCOPES, [
'application:app_slash_command:read',
'application:app_slash_command:write',
]);
assert.ok(Array.isArray(SLASH_COMMAND_MANIFEST));
assert.ok(SLASH_COMMAND_MANIFEST.length > 0);
for (const entry of SLASH_COMMAND_MANIFEST) {
assert.equal(typeof entry.command, 'string');
assert.ok(entry.command.length > 0);
assert.equal(entry.command.startsWith('/'), false, `command ${entry.command} must not start with /`);
assert.equal(typeof entry.default, 'string');
}
});
test('listSlashCommands returns the registered command items', async () => {
const { http, requests } = fakeHttpInstance({
[AUTH_KEY]: () => ({ code: 0, tenant_access_token: 'tenant-token' }),
[LIST_KEY]: () => ({ code: 0, data: { items: [{ command: 'menu', command_id: '1' }] } }),
});
const items = await listSlashCommands({ appId: 'a', appSecret: 's', httpInstance: http });
assert.deepEqual(items, [{ command: 'menu', command_id: '1' }]);
assert.equal(requests[1].headers.authorization, 'Bearer tenant-token');
});
test('registerSlashCommands creates missing and skips existing commands', async () => {
const createdBodies = [];
const { http, requests } = fakeHttpInstance({
[AUTH_KEY]: () => ({ code: 0, tenant_access_token: 'tenant-token' }),
[LIST_KEY]: () => ({ code: 0, data: { items: [{ command: 'menu' }, { command: 'help' }] } }),
'POST application/v7/app_slash_commands': (options) => {
createdBodies.push(options.data);
return { code: 0, data: { command_id: `id-${options.data.command}` } };
},
});
const manifest = [
{ command: 'menu', default: '打开菜单', en_us: 'Open menu' },
{ command: 'status', icon: 'ai-functions_outlined', default: '状态', en_us: 'Status' },
{ command: 'watch', icon: 'flag_outlined', default: '关注', en_us: 'Watch' },
];
const result = await registerSlashCommands({
appId: 'a', appSecret: 's', httpInstance: http, manifest,
});
// menu exists -> skipped; status & watch created
assert.equal(result.created.length, 2);
assert.ok(result.created.some((c) => c.command === 'status'));
assert.ok(result.created.some((c) => c.command === 'watch'));
assert.ok(result.existing.includes('menu'));
assert.ok(result.existing.includes('help'));
assert.equal(result.failed.length, 0);
assert.equal(createdBodies.length, 2);
assert.equal(requests.filter((request) => request.url.includes('/tenant_access_token/')).length, 1);
assert.deepEqual(createdBodies.map((body) => body.description.icon.icon_key), [
'ai-functions_outlined',
'flag_outlined',
]);
for (const body of createdBodies) {
assert.equal(body.command.startsWith('/'), false);
assert.ok(body.description.default_value);
assert.ok(body.description.i18n.zh_cn);
assert.ok(body.description.icon.icon_key);
}
});
test('registerSlashCommands aborts batch on missing permission', async () => {
const { http, requests } = fakeHttpInstance({
[AUTH_KEY]: () => ({ code: 0, tenant_access_token: 'tenant-token' }),
[LIST_KEY]: () => ({ code: 0, data: { items: [] } }),
'POST application/v7/app_slash_commands': () => {
const error = new Error('Request failed with status code 400');
error.response = {
data: {
code: 99991672,
msg: 'Access denied. One of the following scopes is required: [application:app_slash_command:write]',
},
};
throw error;
},
});
const manifest = [
{ command: 'menu', default: 'x', en_us: 'x' },
{ command: 'status', default: 'x', en_us: 'x' },
];
const result = await registerSlashCommands({
appId: 'a', appSecret: 's', httpInstance: http, manifest,
});
assert.equal(result.created.length, 0);
assert.equal(result.failed.length, 1);
assert.equal(result.failed[0].error.code, '99991672');
assert.equal(
requests.filter((request) => request.url.endsWith('/app_slash_commands')
&& request.method === 'POST').length,
1,
);
});
test('registerSlashCommands treats duplicate-create as already-existing', async () => {
const { http } = fakeHttpInstance({
[AUTH_KEY]: () => ({ code: 0, tenant_access_token: 'tenant-token' }),
[LIST_KEY]: () => ({ code: 0, data: { items: [] } }),
'POST application/v7/app_slash_commands': () => ({ code: 40000000, msg: 'command already exists' }),
});
const result = await registerSlashCommands({
appId: 'a', appSecret: 's', httpInstance: http,
manifest: [{ command: 'menu', default: 'x', en_us: 'x' }],
});
assert.equal(result.created.length, 0);
assert.equal(result.failed.length, 0);
});