Files
SnowLuma/packages/onebot/tests/action-registry.test.ts
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

291 lines
11 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { defineAction, defineStreamAction, type RegisteredActionSpec } from '../src/action-kit';
import { ApiHandler, type ApiActionContext } from '../src/api-handler';
import {
ACTION_REGISTRY,
HANDLE_QUICK_OPERATION_ACTION,
HANDLE_QUICK_OPERATION_ASYNC_ACTION,
RAW_ACTION_RESERVATIONS,
compileActionRegistry,
type ActionGroup,
type RawActionReservation,
} from '../src/actions';
import { collectActionDocs } from '../src/action-docs';
import { okResponse } from '../src/types';
type ActionNames = string | readonly [string, ...string[]];
function normal(name: ActionNames): RegisteredActionSpec {
return defineAction({ name, params: {}, run: () => okResponse() });
}
function stream(name: ActionNames): RegisteredActionSpec {
return defineStreamAction({ name, params: {}, run: () => okResponse() });
}
function groups(...actions: RegisteredActionSpec[]): readonly ActionGroup[] {
return [{ category: 'test', actions }];
}
function conflict(
actionGroups: readonly ActionGroup[],
raw: readonly RawActionReservation[] = [],
): string {
try {
compileActionRegistry(actionGroups, raw);
} catch (error) {
if (error instanceof Error) {
return error.message;
}
throw error;
}
throw new Error('expected registry compilation to fail');
}
describe('compileActionRegistry namespace conflicts', () => {
it('rejects canonical ↔ canonical', () => {
const message = conflict(groups(normal('shared'), normal('shared')));
expect(message).toContain('executable name "shared"');
expect(message).toContain(
'canonical "shared" (name "shared", kind normal, role canonical)',
);
});
it.each([
['canonical first', [normal('shared'), normal(['owner', 'shared'])]],
['alias first', [normal(['owner', 'shared']), normal('shared')]],
] as const)('rejects canonical ↔ alias (%s)', (_label, specs) => {
const message = conflict(groups(...specs));
expect(message).toContain(
'canonical "shared" (name "shared", kind normal, role canonical)',
);
expect(message).toContain('canonical "owner" (name "shared", kind normal, role alias)');
});
it('rejects alias ↔ alias', () => {
const message = conflict(groups(normal(['first', 'shared']), normal(['second', 'shared'])));
expect(message).toContain('canonical "first" (name "shared", kind normal, role alias)');
expect(message).toContain('canonical "second" (name "shared", kind normal, role alias)');
});
it('rejects normal ↔ stream and reports both kinds', () => {
const message = conflict(groups(normal('shared'), stream('shared')));
expect(message).toContain(
'canonical "shared" (name "shared", kind normal, role canonical)',
);
expect(message).toContain(
'canonical "shared" (name "shared", kind stream, role canonical)',
);
});
it.each([
['canonical', normal(HANDLE_QUICK_OPERATION_ACTION), 'role canonical'],
['alias', normal(['owner', HANDLE_QUICK_OPERATION_ACTION]), 'role alias'],
] as const)(
'rejects a declarative %s colliding with a reserved raw action',
(_label, spec, role) => {
const message = conflict(groups(spec), [
{ name: HANDLE_QUICK_OPERATION_ACTION, canonical: HANDLE_QUICK_OPERATION_ACTION },
]);
expect(message).toContain(`name "${HANDLE_QUICK_OPERATION_ACTION}"`);
expect(message).toContain(`kind normal, ${role}`);
expect(message).toContain('kind raw, role raw');
},
);
it('reserves the async quick-operation alias against declarative names', () => {
const message = conflict(
groups(normal(HANDLE_QUICK_OPERATION_ASYNC_ACTION)),
RAW_ACTION_RESERVATIONS,
);
expect(message).toContain(`name "${HANDLE_QUICK_OPERATION_ASYNC_ACTION}"`);
expect(message).toContain('kind raw, role raw');
});
it('rejects duplicate raw reservations', () => {
const message = conflict(
[],
[
{ name: 'raw', canonical: 'first-raw' },
{ name: 'raw', canonical: 'second-raw' },
],
);
expect(message).toContain('canonical "first-raw" (name "raw", kind raw, role raw)');
expect(message).toContain('canonical "second-raw" (name "raw", kind raw, role raw)');
});
});
describe('compiled production Action registry', () => {
it('publishes a doc for every compiled action', () => {
const registryNames = ACTION_REGISTRY.actions.map((action) => action.doc.name).sort();
const docNames = collectActionDocs()
.map((doc) => doc.name)
.sort();
expect(docNames).toEqual(registryNames);
expect(docNames.length).toBeGreaterThan(100);
});
it('resolves every executable name to exactly one canonical doc', () => {
const docs = new Map(collectActionDocs().map((doc) => [doc.name, doc]));
for (const executable of ACTION_REGISTRY.executableNames) {
expect(ACTION_REGISTRY.resolve(executable.name)).toBe(executable);
if (executable.kind === 'raw') {
expect(executable.canonical).toBe(HANDLE_QUICK_OPERATION_ACTION);
expect([
HANDLE_QUICK_OPERATION_ACTION,
HANDLE_QUICK_OPERATION_ASYNC_ACTION,
]).toContain(executable.name);
expect(docs.has(executable.canonical)).toBe(false);
continue;
}
const doc = docs.get(executable.canonical);
expect(doc, executable.name).toBeDefined();
expect(executable.action.doc).toEqual(doc);
expect(executable.action.kind).toBe(doc!.stream === true ? 'stream' : 'normal');
expect([doc!.name, ...doc!.aliases]).toContain(executable.name);
}
});
it('binds the exact compiled executable-name/kind projection', () => {
const api = { handle: vi.fn() } as unknown as ApiHandler;
const bound = ACTION_REGISTRY.bind({} as ApiActionContext, api, {
[HANDLE_QUICK_OPERATION_ACTION]: () => async () => okResponse(),
});
expect([...bound.entries()].map(([name, entry]) => [name, entry.kind])).toEqual(
ACTION_REGISTRY.executableNames.map((entry) => [entry.name, entry.kind]),
);
for (const executable of ACTION_REGISTRY.executableNames) {
expect(bound.get(executable.name)?.canonical).toBe(executable.canonical);
}
});
it('resolves the async quick-operation alias to the reserved raw handler', () => {
const syncName = ACTION_REGISTRY.resolve(HANDLE_QUICK_OPERATION_ACTION);
const asyncName = ACTION_REGISTRY.resolve(HANDLE_QUICK_OPERATION_ASYNC_ACTION);
expect(syncName).toMatchObject({
name: HANDLE_QUICK_OPERATION_ACTION,
canonical: HANDLE_QUICK_OPERATION_ACTION,
kind: 'raw',
role: 'raw',
});
expect(asyncName).toMatchObject({
name: HANDLE_QUICK_OPERATION_ASYNC_ACTION,
canonical: HANDLE_QUICK_OPERATION_ACTION,
kind: 'raw',
role: 'raw',
});
});
it.each([HANDLE_QUICK_OPERATION_ACTION, HANDLE_QUICK_OPERATION_ASYNC_ACTION])(
'dispatches %s to set_group_add_request',
async (action) => {
const handleGroupRequest = vi.fn(async () => undefined);
const handler = new ApiHandler({ handleGroupRequest } as unknown as ApiActionContext);
const response = await handler.handle(action, {
context: {
post_type: 'request',
request_type: 'group',
sub_type: 'invite',
group_id: 100000002,
user_id: 10001,
flag: 'invite-flag',
},
operation: { approve: true, remark: '' },
});
expect(response.status).toBe('ok');
expect(handleGroupRequest).toHaveBeenCalledWith('invite-flag', 'invite', true, '');
},
);
it('validates and forwards get_group_system_msg filters', async () => {
const handleGetGroupSystemMsg = vi.fn(async () => []);
const handler = new ApiHandler({ handleGetGroupSystemMsg } as unknown as ApiActionContext);
const response = await handler.handle('get_group_system_msg', {
group_id: '100000002',
only_pending: true,
count: '80',
});
expect(response.status).toBe('ok');
expect(handleGetGroupSystemMsg).toHaveBeenCalledWith({
groupId: 100000002,
onlyPending: true,
count: 80,
});
const doc = ACTION_REGISTRY.resolve('get_group_system_msg');
if (!doc || doc.kind === 'raw') {
throw new Error('get_group_system_msg action missing');
}
expect(doc.action.doc.params.map((param) => param.name)).toEqual([
'group_id',
'only_pending',
'count',
]);
});
});
describe('ActionRegistry.bind', () => {
function testRegistry(...specs: RegisteredActionSpec[]) {
return compileActionRegistry(groups(...specs), RAW_ACTION_RESERVATIONS);
}
it('binds only names declared by the injected complete registry', async () => {
const api = new ApiHandler(
{} as ApiActionContext,
undefined,
testRegistry(normal('__registry_normal__')),
);
expect((await api.handle('__registry_normal__', {})).status).toBe('ok');
expect(api.isStreamAction('__registry_normal__')).toBe(false);
expect((await api.handle('__rogue__', {})).retcode).toBe(1404);
});
it('keeps stream classification bound to the sealed compiled claim', () => {
const api = new ApiHandler(
{} as ApiActionContext,
undefined,
testRegistry(stream('__registry_stream__')),
);
expect(api.isStreamAction('__registry_stream__')).toBe(true);
expect(api.isStreamAction('__registry_normal__')).toBe(false);
});
it('rejects a missing raw factory for a reserved name', () => {
const registry = testRegistry(normal('declared'));
expect(() => registry.bind({} as ApiActionContext, {} as ApiHandler, {})).toThrow(
/raw factory missing.*handle_quick_operation.*kind raw/,
);
});
it('rejects an unexpected raw factory', () => {
const registry = compileActionRegistry(groups(normal('declared')));
expect(() =>
registry.bind({} as ApiActionContext, {} as ApiHandler, {
extra: () => async () => okResponse(),
}),
).toThrow(/unexpected raw factory.*extra.*kind raw/);
});
it('constructs an injected registry that has no raw reservation', async () => {
const api = new ApiHandler(
{} as ApiActionContext,
undefined,
compileActionRegistry(groups(normal('declared'))),
);
expect((await api.handle('declared', {})).status).toBe('ok');
expect((await api.handle(HANDLE_QUICK_OPERATION_ACTION, {})).retcode).toBe(1404);
});
});