/** * 活动模块路由:zod 校验 → 用例 → 结果翻状态码。用例是替身(用例本身另有测试),不挂鉴权中间件, * 身份由测试自己塞进上下文(终止 / 启动要知道是谁按的)。 */ import { Hono } from 'hono'; import { describe, expect, it } from 'vitest'; import type { InstanceDetail, Principal } from '@contracts'; import { positionIdSchema, staffIdSchema } from '@contracts'; import type { AppEnv } from '../http-env.ts'; import { registerActivityRoutes } from '../routes/activities.ts'; import type { ActivityOps } from '../activity-ops/service.ts'; const W05 = 'part-substitution-assessment'; const owner = { id: staffIdSchema.parse('s-1'), name: '柴浩' }; const detail: InstanceDetail = { id: 't-1', no: 'INS-7', state: 'running', owner, output: { files: [] }, startedAt: '2026-08-27T02:00:00.000Z', activityId: W05, llm: 'deepseek-v4-flash', endedAt: null, durationMs: null, reason: null, input: { content: 'x', source: { kind: 'manual', label: '手动启动 · 柴浩' }, submittedAt: '2026-08-27T02:00:00.000Z', files: [] }, steps: [], }; const activity = { id: W05, name: 'W05', biz: '需求分析', status: 'active' as const, version: '1.5.0', owner, llm: 'm', createdAt: 'c', desc: '', view: null }; function fakeOps(over: Partial = {}): ActivityOps { return { listActivities: async () => [{ ...activity, instanceCount: 1 }], getActivity: async (id) => (id === W05 ? activity : undefined), viewHtml: async (id) => (id === W05 ? { kind: 'html', html: '一览' } : { kind: 'no-view' }), listInstances: async (id, q) => (id === W05 ? { stats: { total: 1, byState: { pending: 0, running: 1, completed: 0, rejected: 0, failed: 0 } }, total: 1, page: q.page, size: q.size, items: [] } : undefined), launchParams: async (id) => (id === W05 ? { activityId: id, version: '1.5.0', params: [] } : undefined), launch: async (id, input) => { if (id !== W05) return { kind: 'not-found' }; if (input.idempotencyKey === 'busy') return { kind: 'busy', message: '正在创建' }; if (input.idempotencyKey === 'bad') return { kind: 'invalid', issues: [{ key: 'customerCode', message: '必填' }] }; if (input.idempotencyKey === 'off') return { kind: 'disabled' }; if (input.idempotencyKey === 'owner') return { kind: 'owner-invalid', message: '责任人不在名册里' }; return { kind: 'ok', detail, created: true }; }, instanceDetail: async (id, iid) => (id === W05 && iid === 't-1' ? detail : undefined), instanceFile: async (id, iid, io, index) => id === W05 && iid === 't-1' && io === 'input' && index === 0 ? { kind: 'file', name: '报 告.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2, 3]) } : iid === 't-1' ? { kind: 'no-file' } : { kind: 'no-instance' }, setEnabled: async (id, enabled) => (id === W05 ? { ...activity, status: enabled ? 'active' : 'disabled' } : undefined), terminate: async (id, iid) => (id !== W05 || iid === 'nope' ? { kind: 'not-found' } : iid === 't-done' ? { kind: 'terminal' } : { kind: 'ok', detail: { ...detail, state: 'failed', reason: { kind: 'terminated', text: '由 柴浩 手动终止' } } }), ...over, }; } function harness(opts: { anonymous?: boolean } = {}) { const app = new Hono(); const principal: Principal = { staff: { id: owner.id, account: 'chaihao', name: owner.name, positionId: positionIdSchema.parse('pm'), state: 'active', roles: [], kind: 'person' }, position: { id: positionIdSchema.parse('pm'), code: 'PM', name: '项目经理', note: '' }, roles: [], ends: ['ops'], }; if (!opts.anonymous) { app.use('*', async (c, next) => { c.set('principal', principal); await next(); }); } registerActivityRoutes(app, { activityOps: fakeOps() }); const json = (method: string, path: string, body?: unknown) => app.request(path, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); return { app, json }; } describe('活动路由', () => { it('L1 / L2:清单按 status 过滤入参校验;详情 404;视图回 text/html', async () => { const h = harness(); expect(await (await h.json('GET', '/api/gov/activities')).json()).toMatchObject({ activities: [{ id: W05, instanceCount: 1 }] }); expect((await h.json('GET', '/api/gov/activities?status=paused')).status).toBe(400); expect((await h.json('GET', `/api/gov/activities/${W05}`)).status).toBe(200); expect((await h.json('GET', '/api/gov/activities/nope')).status).toBe(404); const view = await h.json('GET', `/api/gov/activities/${W05}/view`); expect(view.status).toBe(200); expect(view.headers.get('content-type')).toContain('text/html'); expect(view.headers.get('cache-control')).toBe('no-store'); expect(await view.text()).toContain('一览'); expect((await h.json('GET', '/api/gov/activities/nope/view')).status).toBe(404); }); it('实例清单:查询默认值与上限;启动参数表 404', async () => { const h = harness(); expect(await (await h.json('GET', `/api/gov/activities/${W05}/instances`)).json()).toMatchObject({ page: 1, size: 8 }); expect(await (await h.json('GET', `/api/gov/activities/${W05}/instances?page=2&size=20&state=running`)).json()).toMatchObject({ page: 2, size: 20 }); expect((await h.json('GET', `/api/gov/activities/${W05}/instances?size=99`)).status).toBe(400); expect((await h.json('GET', `/api/gov/activities/${W05}/instances?startedFrom=8-1`)).status).toBe(400); expect((await h.json('GET', '/api/gov/activities/nope/instances')).status).toBe(404); expect((await h.json('GET', `/api/gov/activities/${W05}/launch-params`)).status).toBe(200); expect((await h.json('GET', '/api/gov/activities/nope/launch-params')).status).toBe(404); }); it('启动:400 入参 / 412 禁用 / 409 在途 / 404 / 401 没身份 / 200 详情', async () => { const h = harness(); const body = { idempotencyKey: 'k', ownerId: 's-1', params: { customerCode: 'CU-1' } }; expect((await h.json('POST', `/api/gov/activities/${W05}/instances`, { ownerId: 's-1' })).status).toBe(400); const bad = await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'bad' }); expect(bad.status).toBe(400); expect(await bad.json()).toMatchObject({ code: 'validation-failed', detail: [{ key: 'customerCode' }] }); const owner = await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'owner' }); expect(owner.status).toBe(400); expect(await owner.json()).toMatchObject({ detail: [{ key: 'ownerId' }] }); expect((await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'off' })).status).toBe(412); expect((await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'busy' })).status).toBe(409); expect((await h.json('POST', '/api/gov/activities/nope/instances', body)).status).toBe(404); const ok = await h.json('POST', `/api/gov/activities/${W05}/instances`, body); expect(ok.status).toBe(200); expect(await ok.json()).toMatchObject({ id: 't-1', no: 'INS-7' }); expect((await harness({ anonymous: true }).json('POST', `/api/gov/activities/${W05}/instances`, body)).status).toBe(401); }); it('L3:详情 404;文件默认 JSON+base64、raw=1 直出字节带 RFC 5987 文件名;序号不合法 400', async () => { const h = harness(); expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1`)).status).toBe(200); expect((await h.json('GET', `/api/gov/activities/${W05}/instances/nope`)).status).toBe(404); const json = await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/input/0`); expect(await json.json()).toEqual({ name: '报 告.pdf', contentType: 'application/pdf', dataBase64: Buffer.from([1, 2, 3]).toString('base64') }); const raw = await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/input/0?raw=1`); expect(raw.status).toBe(200); expect(raw.headers.get('content-type')).toBe('application/pdf'); expect(raw.headers.get('content-disposition')).toBe(`attachment; filename*=UTF-8''${encodeURIComponent('报 告.pdf')}`); expect(new Uint8Array(await raw.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3])); expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/output/0`)).status).toBe(404); expect((await h.json('GET', `/api/gov/activities/${W05}/instances/nope/files/input/0`)).status).toBe(404); expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/both/0`)).status).toBe(400); expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/input/x`)).status).toBe(400); }); it('禁用 / 激活回活动详情;终止 412 已终态、404、401 没身份', async () => { const h = harness(); expect(await (await h.json('POST', `/api/gov/activities/${W05}/disable`)).json()).toMatchObject({ status: 'disabled' }); expect(await (await h.json('POST', `/api/gov/activities/${W05}/activate`)).json()).toMatchObject({ status: 'active' }); expect((await h.json('POST', '/api/gov/activities/nope/disable')).status).toBe(404); const done = await h.json('POST', `/api/gov/activities/${W05}/instances/t-1/terminate`); expect(done.status).toBe(200); expect(await done.json()).toMatchObject({ state: 'failed', reason: { kind: 'terminated' } }); expect((await h.json('POST', `/api/gov/activities/${W05}/instances/t-done/terminate`)).status).toBe(412); expect((await h.json('POST', `/api/gov/activities/${W05}/instances/nope/terminate`)).status).toBe(404); expect((await harness({ anonymous: true }).json('POST', `/api/gov/activities/${W05}/instances/t-1/terminate`)).status).toBe(401); }); });