f2-execute.mjs 9.74 KB
#!/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 })
})