This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/app/__tests__/activity-routes.test.ts
T
xcsandClaude Fable 5 0923fbfe4d feat(ops): 运营中心 · 活动模块接口落地 —— L1/L2/L3 十一条(全 ops)+ 设计端活动库两条改名让位
`apps/api/src/app/activity-ops/*`(service / store / instance-state / instance-steps / launch-params /
summaries / view-html / view-scope)+ `routes/activities.ts`;契约 `activity-ops.ts` / `api/activity-ops.ts`
(与队友按文档补的那版 43 个导出同名,多 INSTANCE_REASON_KINDS / STEP_RESULTS / launchOptionSchema);
迁移 0035_activity_ops;《API文档-活动模块》按契约回写。
设计端活动库 GET/POST `/api/gov/activities` 改名 `/api/gov/activity-packages`,`/api/gov/activities/*`
整个让给运营端(前端 import 路由对象,零改动);activity-registry CONTRACT.md 与路由总表同步。
2026-08-27 另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖,以合并提交为验证单位。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-27 23:20:15 +08:00

151 lines
9.7 KiB
TypeScript

/**
* 活动模块路由: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> = {}): ActivityOps {
return {
listActivities: async () => [{ ...activity, instanceCount: 1 }],
getActivity: async (id) => (id === W05 ? activity : undefined),
viewHtml: async (id) => (id === W05 ? { kind: 'html', html: '<html><body>一览<script>x</script></body></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<AppEnv>();
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);
});
});