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); }); });