#!/usr/bin/env node /** * F1 发布的真实链路脚本(Planning 用例 F1-01、02、03、05、06、07、08;F1-04 发布前审核在 W3)。 */ import { randomUUID } from 'node:crypto' import { runScenario } from './_lib.mjs' await runScenario('f1', async ({ db, check, call, createOwner, createGuild, createAgent, publish }) => { const alice = await createOwner('alice') const bob = await createOwner('bob') const guild = await createGuild('main', alice.user_id) const otherGuild = await createGuild('other', bob.user_id) const publisher = await createAgent('publisher', alice, guild) const member = await createAgent('member', bob, guild) const outsider = await createAgent('outsider', bob, otherGuild) const wallet = async (agent) => (await call('GET', '/v1/wallet', { token: agent.key })).json.data const taskCount = async () => Number((await db('vk_tasks').where({ publisher_agent_id: publisher.id }).count({ n: '*' }).first()).n) // F1-01 入口校验与余额不足。 const noList = await publish(publisher, { acceptance: [] }) check('F1-01 缺验收清单 → 400', noList.status === 400 && noList.json.error.code === 'invalid_request', noList) const sixItems = await publish(publisher, { acceptance: [1, 2, 3, 4, 5, 6].map((n) => ({ id: `c${n}`, text: `item ${n}` })) }) check('F1-01 清单 6 条 → 400', sixItems.status === 400, sixItems) const noKey = await call('POST', '/v1/tasks', { token: publisher.key, body: { title: 't', spec: 's', template: 'piecework', reward: 10, deadline: new Date(Date.now() + 3600_000).toISOString(), acceptance: [{ id: 'c1', text: 'x' }] } }) check('缺 Idempotency-Key → 400', noKey.status === 400, noKey) const tooRich = await publish(publisher, { reward: 101 }) const ledgerAfterRefusal = await db('billing_ledger').where({ source: 'voko', reason: 'vk_escrow_hold' }).whereIn('user_id', db('vk_agents').where({ id: publisher.id }).select('wallet_user_id')) check('F1-01 余额不足 → 402,无任务、无流水', tooRich.status === 402 && tooRich.json.error.code === 'insufficient_points' && (await taskCount()) === 0 && ledgerAfterRefusal.length === 0, tooRich) // F1-02 / F1-05 员工 Agent 用自己的积分发布:余额立即减少、托管 held、稀有度按区间。 const published = await publish(publisher, { reward: 60 }) const task = published.json.data const escrow = await db('vk_escrows').where({ task_id: task.id }).first() check('F1-02 发布成功 → open、稀有度 2', published.status === 200 && task.status === 'open' && task.rarity === 2 && task.acceptance.length === 1, published) check('F1-02 余额立即减 reward、托管 held', JSON.stringify(await wallet(publisher)) === JSON.stringify({ available: 40, held: 60 }) && escrow.status === 'held' && escrow.amount === 60, { wallet: await wallet(publisher), escrow }) // F1-03 非成员看不到;成员下一次 /home 能看到。 const outsiderGet = await call('GET', `/v1/tasks/${task.id}`, { token: outsider.key }) const outsiderList = await call('GET', '/v1/tasks', { token: outsider.key }) check('F1-03 非成员看不到任务', outsiderGet.status === 403 && outsiderList.json.data.tasks.every((t) => t.id !== task.id), { outsiderGet, outsiderList }) const home = await call('GET', '/v1/home', { token: member.key }) check('F1-03 成员 /home 推荐里有新任务与 task.published 事件', home.status === 200 && home.json.data.recommended_tasks.some((t) => t.id === task.id) && home.json.data.events.some((e) => e.type === 'task.published' && e.task_id === task.id), home) const homeAgain = await call('GET', '/v1/home', { token: member.key }) const eventsSince = await call('GET', '/v1/events?since=0', { token: member.key }) check('事件游标:/home 已读后不再返回,/events?since=0 仍可补取', homeAgain.json.data.events.length === 0 && eventsSince.json.data.events.some((e) => e.task_id === task.id), { homeAgain, eventsSince }) const memberList = await call('GET', '/v1/tasks?status=open&template=piecework', { token: member.key }) check('成员看板按条件筛选能看到', memberList.json.data.tasks.some((t) => t.id === task.id), memberList) // F1-08 评论区。 const comment = await call('POST', `/v1/tasks/${task.id}/comments`, { token: member.key, body: { body: 'Which tone should the translation use?' } }) const outsiderComment = await call('POST', `/v1/tasks/${task.id}/comments`, { token: outsider.key, body: { body: 'hi' } }) const comments = await call('GET', `/v1/tasks/${task.id}/comments`, { token: publisher.key }) const publisherEvents = await call('GET', '/v1/events?since=0', { token: publisher.key }) check('F1-08 成员可评论、非成员 403', comment.status === 200 && outsiderComment.status === 403 && comments.json.data.comments.length === 1, { comment, outsiderComment }) check('F1-08 评论进入发布方的事件', publisherEvents.json.data.events.some((e) => e.type === 'comment.created' && e.task_id === task.id), publisherEvents) // F1-06 open 时加赏金、延长截止;重放不重复扣;被认领后修改 → 409。 const editKey = randomUUID() const later = new Date(Date.now() + 2 * 86400_000).toISOString() const edited = await call('PATCH', `/v1/tasks/${task.id}`, { token: publisher.key, idempotency_key: editKey, body: { add_reward: 20, deadline: later } }) check('F1-06 加赏金(补扣托管)与延长截止', edited.status === 200 && edited.json.data.reward === 80 && edited.json.data.deadline === later && JSON.stringify(await wallet(publisher)) === JSON.stringify({ available: 20, held: 80 }), { edited, wallet: await wallet(publisher) }) const editReplay = await call('PATCH', `/v1/tasks/${task.id}`, { token: publisher.key, idempotency_key: editKey, body: { add_reward: 20, deadline: later } }) check('F1-06 同一 Idempotency-Key 重放不重复补扣', editReplay.status === 200 && editReplay.json.data.reward === 80 && (await wallet(publisher)).available === 20, editReplay) const shorten = await call('PATCH', `/v1/tasks/${task.id}`, { token: publisher.key, idempotency_key: randomUUID(), body: { deadline: new Date(Date.now() + 3600_000).toISOString() } }) check('F1-06 缩短截止 → 422', shorten.status === 422 && shorten.json.error.code === 'rule_violation', shorten) const memberEdit = await call('PATCH', `/v1/tasks/${task.id}`, { token: member.key, idempotency_key: randomUUID(), body: { add_reward: 1 } }) check('非发布方修改 → 403', memberEdit.status === 403, memberEdit) const claimed = await call('POST', `/v1/tasks/${task.id}/claim`, { token: member.key }) check('成员认领 → claimed', claimed.status === 200 && claimed.json.data.status === 'claimed' && claimed.json.data.holder_agent_id === member.id, claimed) const editClaimed = await call('PATCH', `/v1/tasks/${task.id}`, { token: publisher.key, idempotency_key: randomUUID(), body: { add_reward: 1 } }) const cancelClaimed = await call('POST', `/v1/tasks/${task.id}/cancel`, { token: publisher.key }) check('F1-06 / F1-07 被认领后修改或撤回 → 409', editClaimed.status === 409 && cancelClaimed.status === 409, { editClaimed, cancelClaimed }) // F1-07 无人报名撤回全额退款;擂台赛有人报名后撤回 → 409。 const second = (await publish(publisher, { reward: 15 })).json.data const cancelled = await call('POST', `/v1/tasks/${second.id}/cancel`, { token: publisher.key }) const refundedEscrow = await db('vk_escrows').where({ task_id: second.id }).first() check('F1-07 无人报名撤回 → cancelled、全额退款', cancelled.status === 200 && cancelled.json.data.status === 'cancelled' && refundedEscrow.status === 'refunded' && JSON.stringify(await wallet(publisher)) === JSON.stringify({ available: 20, held: 80 }), { cancelled, wallet: await wallet(publisher) }) const contest = (await publish(publisher, { reward: 10, template: 'contest' })).json.data const entered = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: member.key }) const cancelContest = await call('POST', `/v1/tasks/${contest.id}/cancel`, { token: publisher.key }) check('F1-07 有人报名后撤回 → 409', entered.status === 200 && entered.json.data.status === 'in_contest' && cancelContest.status === 409, { entered, cancelContest }) })