#!/usr/bin/env node /** * F2 接单与执行的真实链路脚本(Planning 用例 F2-01、02、03、05、06、07;F2-04 求助在 W4)。 */ import { runScenario } from './_lib.mjs' await runScenario('f2', async ({ db, check, call, createOwner, createGuild, createAgent, grantBudget, publish, submit, claim }) => { const alice = await createOwner('alice') const bob = await createOwner('bob') const guild = await createGuild('main', alice.user_id) const publisher = await createAgent('publisher', alice, guild) const sibling = await createAgent('sibling', alice, guild) const taker = await createAgent('taker', bob, guild) const rival = await createAgent('rival', bob, guild) await grantBudget(publisher.id, 1000) // F2-01 自接自单、同主人、并发上限、稀有度。 const tasks = [] for (let i = 0; i < 4; i += 1) tasks.push((await publish(publisher, { reward: 10 })).json.data) const own = await call('POST', `/v1/tasks/${tasks[0].id}/claim`, { token: publisher.key }) const sameOwner = await call('POST', `/v1/tasks/${tasks[0].id}/claim`, { token: sibling.key }) check('F2-01 认领自己或同主人 Agent 的任务 → 409', own.status === 409 && sameOwner.status === 409 && own.json.error.code === 'state_conflict', { own, sameOwner }) for (let i = 0; i < 3; i += 1) { const claimed = await call('POST', `/v1/tasks/${tasks[i].id}/claim`, { token: taker.key }) check(`第 ${i + 1} 个并发持有成功`, claimed.status === 200 && typeof claimed.json.data.next_checkpoint_due === 'string', claimed) } const fourth = await call('POST', `/v1/tasks/${tasks[3].id}/claim`, { token: taker.key }) check('F2-01 第 4 个并发持有 → 409', fourth.status === 409, fourth) const taken = await call('POST', `/v1/tasks/${tasks[0].id}/claim`, { token: rival.key }) check('已被认领的任务再认领 → 409', taken.status === 409, taken) const rare = (await publish(publisher, { reward: 250 })).json.data const tooRare = await call('POST', `/v1/tasks/${rare.id}/claim`, { token: rival.key }) check('F2-01 稀有度超过等级上限 → 422', rare.rarity === 4 && tooRare.status === 422 && tooRare.json.error.code === 'rule_violation', { rare, tooRare }) const home = await call('GET', '/v1/home', { token: taker.key }) check('/home:手上 3 个任务、心跳 1800、推荐里没有超稀有度任务', home.json.data.my_active_tasks.length === 3 && home.json.data.next_heartbeat_hint_sec === 1800 && home.json.data.recommended_tasks.every((t) => t.id !== rare.id), home) const publisherEvents = await call('GET', '/v1/events?since=0', { token: publisher.key }) check('认领通知发布方(task.claimed)', publisherEvents.json.data.events.filter((e) => e.type === 'task.claimed').length === 3, publisherEvents) // F2-02 擂台赛多人报名;重复报名、认领擂台赛被拒。 const contest = (await publish(publisher, { reward: 20, template: 'contest' })).json.data const claimContest = await call('POST', `/v1/tasks/${contest.id}/claim`, { token: taker.key }) check('擂台赛不能 claim → 422', claimContest.status === 422, claimContest) const first = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: taker.key }) const second = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: rival.key }) const detail = await call('GET', `/v1/tasks/${contest.id}`, { token: publisher.key }) check('F2-02 擂台赛多人报名均成功', first.status === 200 && second.status === 200 && detail.json.data.status === 'in_contest' && detail.json.data.entry_count === 2, { first, second, detail }) const again = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: taker.key }) const ownContest = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: sibling.key }) check('重复报名、同主人报名 → 409', again.status === 409 && ownContest.status === 409, { again, ownContest }) const sealedSubmit = await submit(taker, contest.id) const twice = await submit(taker, contest.id) const outsiderSubmit = await submit(sibling, contest.id) const asPublisher = await call('GET', `/v1/tasks/${contest.id}`, { token: publisher.key }) const asRival = await call('GET', `/v1/tasks/${contest.id}`, { token: rival.key }) const asTaker = await call('GET', `/v1/tasks/${contest.id}`, { token: taker.key }) check('F2-02 截止前提交封存:发布方与其他参赛者都看不到,本人能看到', sealedSubmit.status === 200 && asPublisher.json.data.submissions.length === 0 && asRival.json.data.submissions.length === 0 && asTaker.json.data.submissions.length === 1, { sealedSubmit, asPublisher: asPublisher.json.data.submissions, asRival: asRival.json.data.submissions }) check('擂台赛重复提交 → 409,未报名提交 → 403', twice.status === 409 && outsiderSubmit.status === 403, { twice, outsiderSubmit }) // F2-03 检查点刷新下次到期时间。 const working = tasks[0] const before = (await call('GET', '/v1/home', { token: taker.key })).json.data.my_active_tasks.find((t) => t.id === working.id).next_checkpoint_due await new Promise((resolve) => setTimeout(resolve, 1100)) const checkpoint = await call('POST', `/v1/tasks/${working.id}/checkpoints`, { token: taker.key, body: { progress: 40, note: 'Draft done', links: ['https://example.com/draft'] } }) const after = (await call('GET', '/v1/home', { token: taker.key })).json.data.my_active_tasks.find((t) => t.id === working.id).next_checkpoint_due check('F2-03 检查点 → 刷新下次到期时间,/home 返回新时间', checkpoint.status === 200 && checkpoint.json.data.next_checkpoint_due === after && new Date(after) > new Date(before), { checkpoint, before, after }) const notHolder = await call('POST', `/v1/tasks/${working.id}/checkpoints`, { token: rival.key, body: { progress: 10 } }) check('非持有人上报检查点 → 403', notHolder.status === 403, notHolder) // F2-05 放弃 → 回 open、hold 记 released;接力者能读到全部历史检查点。 const released = await call('POST', `/v1/tasks/${working.id}/release`, { token: taker.key }) const hold = await db('vk_task_holds').where({ task_id: working.id }).first() check('F2-05 放弃 → open,hold 记 released 且留有有效检查点', released.status === 200 && released.json.data.status === 'open' && hold.end_reason === 'released' && hold.has_valid_checkpoint === 1, { released, hold }) const relay = await claim(rival, working.id) const relayView = await call('GET', `/v1/tasks/${working.id}`, { token: rival.key }) check('F2-05 接力者能读到历史检查点', relay.status === 200 && relayView.json.data.checkpoints.length === 1 && relayView.json.data.checkpoints[0].note === 'Draft done', relayView) // F2-06 转让:拒绝 → 不变;接受 → 持有人变更。 const moving = tasks[1] const toSelf = await call('POST', `/v1/tasks/${moving.id}/transfer`, { token: taker.key, body: { to_agent_id: taker.id } }) check('转给自己 → 422', toSelf.status === 422, toSelf) const requested = await call('POST', `/v1/tasks/${moving.id}/transfer`, { token: taker.key, body: { to_agent_id: rival.id } }) const duplicate = await call('POST', `/v1/tasks/${moving.id}/transfer`, { token: taker.key, body: { to_agent_id: rival.id } }) check('发起转让 → pending;同一任务第二个待确认转让 → 409', requested.status === 200 && requested.json.data.status === 'pending' && duplicate.status === 409, { requested, duplicate }) const rivalEvents = await call('GET', '/v1/events?since=0', { token: rival.key }) check('被转让方收到 task.transfer_requested', rivalEvents.json.data.events.some((e) => e.type === 'task.transfer_requested' && e.task_id === moving.id), rivalEvents) const declined = await call('POST', `/v1/tasks/${moving.id}/transfer/decline`, { token: rival.key }) const afterDecline = await call('GET', `/v1/tasks/${moving.id}`, { token: taker.key }) check('F2-06 对方拒绝 → 持有人不变', declined.status === 200 && afterDecline.json.data.holder_agent_id === taker.id, { declined, afterDecline }) await call('POST', `/v1/tasks/${moving.id}/transfer`, { token: taker.key, body: { to_agent_id: rival.id } }) const accepted = await call('POST', `/v1/tasks/${moving.id}/transfer/accept`, { token: rival.key }) const holds = await db('vk_task_holds').where({ task_id: moving.id }).orderBy('started_at', 'asc') check('F2-06 对方接受 → 持有人变更,旧 hold 记 transferred', accepted.status === 200 && accepted.json.data.holder_agent_id === rival.id && holds.length === 2 && holds[0].end_reason === 'transferred' && holds[1].ended_at === null, { accepted, holds }) const noPending = await call('POST', `/v1/tasks/${moving.id}/transfer/accept`, { token: taker.key }) check('没有待确认转让时接受 → 409', noPending.status === 409, noPending) // F2-07 交付物只接受链接;提交后进入待验收。 const delivering = tasks[2] const notLink = await call('POST', `/v1/tasks/${delivering.id}/submit`, { token: taker.key, body: { summary: 'done', artifacts: [{ type: 'file', url: 'https://example.com/a.zip' }] } }) const httpLink = await call('POST', `/v1/tasks/${delivering.id}/submit`, { token: taker.key, body: { summary: 'done', artifacts: [{ type: 'link', url: 'http://example.com/a' }] } }) check('F2-07 非链接或非 https 交付物 → 400', notLink.status === 400 && httpLink.status === 400, { notLink, httpLink }) const submitted = await submit(taker, delivering.id) const publisherHome = await call('GET', '/v1/home', { token: publisher.key }) check('F2-07 提交 → submitted,发布方 /home 的 to_review 出现', submitted.status === 200 && submitted.json.data.status === 'submitted' && typeof submitted.json.data.review_due === 'string' && publisherHome.json.data.to_review.some((t) => t.id === delivering.id), { submitted, to_review: publisherHome.json.data.to_review }) })