// Interface tests for action-kit — the deepened param-validation module. // The interface IS the test surface: we drive `parse()` (pure, no ctx) for // coercion/validation/cross-field, `describe()` for doc metadata, and // `toHandler()` for the BAD_REQUEST shaping + ctx threading. import { describe, it, expect } from 'vitest'; import { f, defineAction, groupUserAction, type CoerceResult } from '../src/action-kit'; import type { ApiActionContext } from '../src/api-handler'; import { RETCODE, okResponse } from '../src/types'; const ctx = {} as unknown as ApiActionContext; function expectErr(r: CoerceResult): { field: string; reason: string } { if (r.ok) { throw new Error(`expected Err, got Ok(${JSON.stringify(r.value)})`); } return { field: r.field, reason: r.reason }; } function expectOk(r: CoerceResult): T { if (!r.ok) { throw new Error(`expected Ok, got Err(${r.field}: ${r.reason})`); } return r.value; } describe('f.uint — positive integer, missing≠0', () => { const spec = defineAction({ name: 't', params: { x: f.uint() }, run: () => okResponse() }); it('accepts number and numeric string, truncates', () => { expect(expectOk(spec.parse({ x: 123 }))).toEqual({ x: 123 }); expect(expectOk(spec.parse({ x: '456' }))).toEqual({ x: 456 }); expect(expectOk(spec.parse({ x: 5.7 }))).toEqual({ x: 5 }); }); it('rejects missing / 0 / negative / garbage, naming the field', () => { expect(expectErr(spec.parse({})).field).toBe('x'); expect(expectErr(spec.parse({})).reason).toBe('is required'); expect(expectErr(spec.parse({ x: 0 })).field).toBe('x'); // 0 is not a valid id expect(expectErr(spec.parse({ x: -3 })).field).toBe('x'); expect(expectErr(spec.parse({ x: 'abc' })).field).toBe('x'); }); }); describe('f.int({min:0}).default — the duration / unmute fix', () => { const spec = defineAction({ name: 't', params: { d: f.int({ min: 0 }).default(1800) }, run: () => okResponse(), }); it('missing ⇒ default; present 0 ⇒ 0 (not the default); negative ⇒ err', () => { expect(expectOk(spec.parse({}))).toEqual({ d: 1800 }); expect(expectOk(spec.parse({ d: 0 }))).toEqual({ d: 0 }); // the bug the old `|| 1800` had expect(expectErr(spec.parse({ d: -1 })).field).toBe('d'); }); it('present garbage is an error, not a silent fallback', () => { expect(expectErr(spec.parse({ d: 'abc' })).field).toBe('d'); }); }); describe('f.messageId — non-zero integer, negatives allowed', () => { const spec = defineAction({ name: 't', params: { id: f.messageId() }, run: () => okResponse(), }); it('accepts negative ids (signed int32 hash), rejects 0 and garbage', () => { expect(expectOk(spec.parse({ id: -2147483648 }))).toEqual({ id: -2147483648 }); expect(expectOk(spec.parse({ id: 123 }))).toEqual({ id: 123 }); expect(expectErr(spec.parse({ id: 0 })).reason).toBe('must not be 0'); expect(expectErr(spec.parse({ id: 'x' })).field).toBe('id'); }); }); describe('f.bool / f.string / f.enum', () => { it('bool synonyms', () => { const s = defineAction({ name: 't', params: { b: f.bool() }, run: () => okResponse() }); expect(expectOk(s.parse({ b: 'on' }))).toEqual({ b: true }); expect(expectOk(s.parse({ b: 0 }))).toEqual({ b: false }); expect(expectErr(s.parse({ b: 'maybe' })).field).toBe('b'); }); it('string allowEmpty toggle', () => { const s = defineAction({ name: 't', params: { c: f.string({ allowEmpty: false }) }, run: () => okResponse(), }); expect(expectErr(s.parse({ c: '' })).field).toBe('c'); expect(expectOk(s.parse({ c: 'x' }))).toEqual({ c: 'x' }); }); it('enum literal set', () => { const s = defineAction({ name: 't', params: { e: f.enum('a', 'b') }, run: () => okResponse(), }); expect(expectOk(s.parse({ e: 'a' }))).toEqual({ e: 'a' }); expect(expectErr(s.parse({ e: 'c' })).field).toBe('e'); }); }); describe('f.array(...).nonEmpty — per-element + non-empty', () => { const spec = defineAction({ name: 't', params: { ids: f.array(f.uint()).nonEmpty() }, run: () => okResponse(), }); it('accepts a non-empty uint array', () => { expect(expectOk(spec.parse({ ids: [1, 2, 3] }))).toEqual({ ids: [1, 2, 3] }); }); it('rejects empty and names the bad element index', () => { expect(expectErr(spec.parse({ ids: [] })).field).toBe('ids'); expect(expectErr(spec.parse({ ids: [1, 'x'] })).field).toBe('ids[1]'); }); }); describe('f.object — open JSON object', () => { const spec = defineAction({ name: 't', params: { value: f.object() }, run: () => okResponse(), }); it('preserves nested and unknown fields', () => { const value = { identity: { id: 'opaque' }, future: [1, null, { enabled: true }] }; expect(expectOk(spec.parse({ value })).value).toBe(value); expect(f.object().toJsonSchema()).toEqual({ type: 'object', additionalProperties: true }); }); it.each([null, [], 'text', 1, false])('rejects non-object %j', (value) => { expect(expectErr(spec.parse({ value })).field).toBe('value'); }); it('uses the normal required and optional field policies', () => { expect(expectErr(spec.parse({})).reason).toBe('is required'); const optional = defineAction({ name: 't', params: { value: f.object().optional() }, run: () => okResponse(), }); expect(expectOk(optional.parse({}))).toEqual({ value: undefined }); expect(expectErr(optional.parse({ value: null })).field).toBe('value'); }); }); describe('f.textOrObjectArray — literal text and upstream rich items', () => { const spec = defineAction({ name: 't', params: { content: f.textOrObjectArray() }, run: () => okResponse(), }); it.each(['[]', '[1,2]', '[{"type":0,"content":"hello"}]', ' \n中文 '])( 'preserves literal string %j without decoding or trimming it', (content) => { expect(expectOk(spec.parse({ content })).content).toBe(content); }, ); it('preserves the original object array and unknown fields', () => { const content = [{ type: 0, content: 'hello', future: { values: [null, 1] } }]; expect(expectOk(spec.parse({ content })).content).toBe(content); }); it.each( ['', [], null, 1, false, {}, [null], [1], [[]], [{ type: 0 }, 'text']].map((content) => ({ content, })), )('rejects invalid rich content $content at the named field', ({ content }) => { expect(expectErr(spec.parse({ content })).field).toBe('content'); }); it('retains required and optional field policies', () => { expect(expectErr(spec.parse({})).reason).toBe('is required'); const optional = defineAction({ name: 't', params: { content: f.textOrObjectArray().optional() }, run: () => okResponse(), }); expect(expectOk(optional.parse({}))).toEqual({ content: undefined }); expect(expectErr(optional.parse({ content: null })).field).toBe('content'); }); it('describes text and object arrays without OneBot message semantics', () => { const field = f.textOrObjectArray(); expect(field.doc.type).toBe('string | object[]'); expect(field.toJsonSchema()).toEqual({ anyOf: [ { type: 'string', minLength: 1 }, { type: 'array', minItems: 1, items: { type: 'object', additionalProperties: true }, }, ], }); }); }); describe('f.message — union passthrough + JSON-array string', () => { const spec = defineAction({ name: 't', params: { m: f.message() }, run: () => okResponse() }); it('parses a JSON-array string, passes plain string / array through', () => { expect(expectOk(spec.parse({ m: '[{"type":"text","data":{"text":"hi"}}]' })).m).toEqual([ { type: 'text', data: { text: 'hi' } }, ]); expect(expectOk(spec.parse({ m: 'hello' })).m).toBe('hello'); expect(expectOk(spec.parse({ m: [{ type: 'face' }] })).m).toEqual([{ type: 'face' }]); }); it('required when missing', () => { expect(expectErr(spec.parse({})).reason).toBe('is required'); }); }); describe('optional fields', () => { const spec = defineAction({ name: 't', params: { x: f.uint().optional() }, run: () => okResponse(), }); it('missing ⇒ undefined value, present-invalid ⇒ err', () => { expect(expectOk(spec.parse({}))).toEqual({ x: undefined }); expect(expectErr(spec.parse({ x: 'abc' })).field).toBe('x'); }); }); describe('cross-field rules', () => { const spec = defineAction({ name: 'set_msg_emoji_like', params: { message_id: f.uint().optional(), group_id: f.uint().optional(), user_id: f.uint().optional(), emoji_id: f.uint(), }, rules: (r) => [r.exactlyOneOf('message_id', ['group_id', 'user_id'])], run: () => okResponse(), }); it('passes when exactly one locator group present', () => { expect(spec.parse({ message_id: 5, emoji_id: 1 }).ok).toBe(true); expect(spec.parse({ group_id: 9, user_id: 8, emoji_id: 1 }).ok).toBe(true); }); it('fails when zero or both groups present', () => { expect(expectErr(spec.parse({ emoji_id: 1 })).reason).toContain('exactly one of'); expect( expectErr(spec.parse({ message_id: 5, group_id: 9, user_id: 8, emoji_id: 1 })).reason, ).toContain('exactly one of'); }); it('field errors take priority over rule errors (per-field first)', () => { expect(expectErr(spec.parse({ emoji_id: 0 })).field).toBe('emoji_id'); }); }); describe('toHandler — BAD_REQUEST shaping + ctx threading', () => { const spec = groupUserAction({ name: 'set_group_ban', params: { duration: f.int({ min: 0 }).default(1800) }, run: (p) => okResponse({ seen: [p.group_id, p.user_id, p.duration] }), }); it('success path returns run output with typed/defaulted params', async () => { const res = await spec.toHandler(ctx)({ group_id: 10, user_id: 20 }); expect(res).toMatchObject({ status: 'ok', retcode: 0, data: { seen: [10, 20, 1800] } }); }); it('bad params ⇒ BAD_REQUEST naming the field (not INTERNAL_ERROR)', async () => { const res = await spec.toHandler(ctx)({ user_id: 20 }); // missing group_id expect(res.retcode).toBe(RETCODE.BAD_REQUEST); expect(res.status).toBe('failed'); expect(res.wording).toContain('group_id'); }); }); describe('run only sees declared fields', () => { it('lets a declared alias through and ignores undeclared keys', async () => { const spec = defineAction({ name: 't', params: { user_id: f.uint(), longNick: f.string().optional() }, run: (p) => okResponse({ extra: (p.longNick ?? null) as never }), }); const res = await spec.toHandler(ctx)({ user_id: 1, longNick: 'hi', undeclared: 1 }); expect(res).toMatchObject({ status: 'ok', data: { extra: 'hi' } }); }); it('rejects garbage on a declared int instead of treating it as 0', async () => { const spec = defineAction({ name: 't', params: { group_id: f.int({ min: 0 }).optional() }, run: (p) => okResponse({ group_id: p.group_id ?? 0 }), }); const res = await spec.toHandler(ctx)({ group_id: 'nope' }); expect(res.retcode).toBe(RETCODE.BAD_REQUEST); expect(res.wording).toContain('group_id'); }); }); describe('toJsonSchema / inputSchema (for the MCP)', () => { it('emits a JSON Schema fragment per coercer', () => { expect(f.uint().toJsonSchema()).toEqual({ type: 'integer', minimum: 1 }); expect(f.messageId().toJsonSchema()).toEqual({ type: 'integer', not: { const: 0 }, 'x-role': 'message_id', }); expect(f.int({ min: 0 }).default(1800).toJsonSchema()).toEqual({ type: 'integer', minimum: 0, default: 1800, }); expect(f.string({ allowEmpty: false }).toJsonSchema()).toEqual({ type: 'string', minLength: 1, }); expect(f.bool().toJsonSchema()).toEqual({ type: 'boolean' }); expect(f.enum('a', 'b').toJsonSchema()).toEqual({ enum: ['a', 'b'] }); expect(f.array(f.uint()).nonEmpty().toJsonSchema()).toEqual({ type: 'array', items: { type: 'integer', minimum: 1 }, minItems: 1, }); expect(f.uint().describe('群号').toJsonSchema()).toEqual({ type: 'integer', minimum: 1, description: '群号', }); }); it('composes an action inputSchema (object + required + defaults)', () => { const spec = groupUserAction({ name: 'set_group_ban', params: { duration: f.int({ min: 0 }).default(1800) }, run: () => okResponse(), }); const { inputSchema } = spec.describe(); expect(inputSchema).toMatchObject({ type: 'object', additionalProperties: true, required: ['group_id', 'user_id'], properties: { group_id: { type: 'integer', minimum: 1 }, user_id: { type: 'integer', minimum: 1 }, duration: { type: 'integer', minimum: 0, default: 1800 }, }, }); }); }); describe('describe — doc metadata for self-generated docs (D4)', () => { const spec = groupUserAction({ name: ['set_group_ban', 'ban'], summary: 'Mute a member', params: { duration: f.int({ min: 0 }).default(1800).describe('seconds; 0 unmutes') }, run: () => okResponse(), }); it('exposes name, aliases, and per-param metadata (incl. preset-injected fields)', () => { const doc = spec.describe(); expect(doc.name).toBe('set_group_ban'); expect(doc.aliases).toEqual(['ban']); expect(doc.summary).toBe('Mute a member'); const names = doc.params.map((p) => p.name); expect(names).toEqual(['group_id', 'user_id', 'duration']); // presets surfaced const duration = doc.params.find((p) => p.name === 'duration'); expect(duration).toMatchObject({ type: 'int', required: false, default: 1800, desc: 'seconds; 0 unmutes', }); }); it('stamps semantic roles on preset-injected fields (group_id / member-like user_id)', () => { const doc = spec.describe(); expect(doc.params.find((p) => p.name === 'group_id')?.role).toBe('group_id'); // In a group action the user_id is a member of that group → member_id role. expect(doc.params.find((p) => p.name === 'user_id')?.role).toBe('member_id'); expect(doc.stream).toBeUndefined(); }); }); describe('FieldRole — semantic constructors and .role()', () => { it('semantic constructors coerce like their primitive but carry a role', () => { expect(f.groupId().doc).toMatchObject({ type: 'uint', role: 'group_id' }); expect(f.userId().doc).toMatchObject({ type: 'uint', role: 'user_id' }); expect(f.memberId().doc).toMatchObject({ type: 'uint', role: 'member_id' }); expect(f.image().doc).toMatchObject({ type: 'string', role: 'image' }); expect(f.duration().doc).toMatchObject({ type: 'int', role: 'duration' }); // Coercion is unchanged: a groupId still validates exactly like a uint. const g = f.groupId().coerce('123', 'group_id'); expect(g.ok && g.value).toBe(123); const bad = f.groupId().coerce('0', 'group_id'); expect(bad.ok).toBe(false); }); it('.role() overrides/stamps a role and survives optional()/default()', () => { expect(f.uint().role('group_id').doc.role).toBe('group_id'); expect(f.uint().role('group_id').optional().doc.role).toBe('group_id'); expect(f.string().role('file').default('').doc.role).toBe('file'); }); it("surfaces role through describe() params and toJsonSchema's x-role", () => { const spec = defineAction({ name: 'demo', params: { gid: f.groupId(), pic: f.image() }, run: () => okResponse(), }); const doc = spec.describe(); expect(doc.params.find((p) => p.name === 'gid')?.role).toBe('group_id'); expect(f.groupId().toJsonSchema()).toMatchObject({ 'x-role': 'group_id' }); }); });