f3-review-settle.mjs 8.54 KB
#!/usr/bin/env node
/**
 * F3 验收与结算的真实链路脚本(Planning 用例 F3-01、02、03、04、05;F3-06 打赏在 W4)。
 * F3-05 需要 voko-worker 在跑(擂台赛到截止由 worker 关闭)。F3-04 的「AI 建议写入」依赖大模型装配,尚未覆盖。
 */
import { runScenario } from './_lib.mjs'

await runScenario('f3', async ({ db, check, call, admin, createOwner, createGuild, createAgent, grantBudget, publish, waitForTask, wallet, submit, claim, review }) => {
  const alice = await createOwner('alice')
  const bob = await createOwner('bob')
  const carol = await createOwner('carol')
  const guild = await createGuild('main', alice.user_id)
  const publisher = await createAgent('publisher', alice, guild)
  const taker = await createAgent('taker', bob, guild)
  const helper = await createAgent('helper', carol, guild)
  await grantBudget(publisher.id, 1000)
  const items = [{ id: 'c1', text: 'Natural Japanese' }, { id: 'c2', text: 'Keeps all links' }]
  const ids = ['c1', 'c2']
  const xpOf = async (agent) => (await call('GET', '/v1/agents/me', { token: agent.key })).json.data.xp

  // F3-01 验收格式。
  const first = (await publish(publisher, { reward: 60, acceptance: items })).json.data
  await claim(taker, first.id)
  const early = await review(publisher, first.id, 'accept', ids)
  check('未提交就验收 → 409', early.status === 409, early)
  await submit(taker, first.id)
  const partial = await review(publisher, first.id, 'accept', ['c1'])
  const contradiction = await review(publisher, first.id, 'accept', ids, ['c2'])
  const allPassReject = await review(publisher, first.id, 'reject', ids)
  check('F3-01 checks 未覆盖全部清单项,或 pass 与 action 矛盾 → 422', partial.status === 422 && contradiction.status === 422 && allPassReject.status === 422 && partial.json.error.code === 'rule_violation', { partial, contradiction, allPassReject })
  const stranger = await review(taker, first.id, 'accept', ids)
  check('非发布方验收 → 403', stranger.status === 403, stranger)

  // F3-02 通过 → 积分入接单方、托管 released、XP 增加、流水可查。
  const accepted = await review(publisher, first.id, 'accept', ids)
  const escrow = await db('vk_escrows').where({ task_id: first.id }).first()
  const payoutLedger = await db('billing_ledger').where({ ref_id: first.id, reason: 'vk_task_payout' })
  check('F3-02 通过 → settled,接单方 +60、托管 released、XP +60、一笔流水', accepted.status === 200 && accepted.json.data.status === 'settled' && (await wallet(taker)).available === 160 && escrow.status === 'released' && (await xpOf(taker)) === 60 && payoutLedger.length === 1, { accepted, escrow, wallet: await wallet(taker) })
  const again = await review(publisher, first.id, 'accept', ids)
  check('已结算再验收 → 409,不重复发放', again.status === 409 && (await wallet(taker)).available === 160, again)
  const takerEvents = await call('GET', '/v1/events?since=0', { token: taker.key })
  check('接单方收到 task.accepted', takerEvents.json.data.events.some((e) => e.type === 'task.accepted' && e.task_id === first.id), takerEvents)

  // F3-03 接力:helper 留检查点后放弃,taker 提交通过 → 平分,余数归 taker。
  const relayTask = (await publish(publisher, { reward: 101, acceptance: items })).json.data
  await claim(helper, relayTask.id)
  await call('POST', `/v1/tasks/${relayTask.id}/checkpoints`, { token: helper.key, body: { progress: 50, note: 'Half done' } })
  await call('POST', `/v1/tasks/${relayTask.id}/release`, { token: helper.key })
  await claim(taker, relayTask.id)
  await submit(taker, relayTask.id)
  const relayAccepted = await review(publisher, relayTask.id, 'accept', ids)
  const payouts = await db('vk_escrow_payouts').where({ task_id: relayTask.id }).orderBy('amount', 'desc')
  check('F3-03 接力结算:最终提交者 51、前任 50', relayAccepted.status === 200 && (await wallet(taker)).available === 211 && (await wallet(helper)).available === 150 && payouts.map((p) => `${p.kind}:${p.amount}`).join() === 'winner:51,relay_share:50', { relayAccepted, payouts, taker: await wallet(taker), helper: await wallet(helper) })

  // F3-04 驳回两次返工,第 3 次进仲裁;运营确认前不动账,确认后按结果结算。
  const hard = (await publish(publisher, { reward: 40, acceptance: items })).json.data
  await claim(taker, hard.id)
  for (const round of [1, 2]) {
    await submit(taker, hard.id)
    const rejected = await review(publisher, hard.id, 'reject', ids, ['c2'], { reason: 'Links are missing.' })
    check(`第 ${round} 次驳回 → rework,返工计数 ${round}`, rejected.status === 200 && rejected.json.data.status === 'rework' && rejected.json.data.rework_count === round, rejected)
  }
  await submit(taker, hard.id)
  const third = await review(publisher, hard.id, 'reject', ids, ['c2'], { reason: 'Still missing.' })
  const dispute = await db('vk_disputes').where({ task_id: hard.id }).first()
  check('F3-04 第 3 次驳回 → disputed,生成争议,未动账', third.status === 200 && third.json.data.status === 'disputed' && dispute.status === 'open' && (await wallet(taker)).available === 211 && (await db('vk_escrows').where({ task_id: hard.id }).first()).status === 'held', { third, dispute })
  const rounds = await db('vk_submissions').where({ task_id: hard.id }).orderBy('round', 'asc').select('round')
  check('三轮提交各记一份', rounds.map((r) => r.round).join() === '1,2,3', rounds)
  const adminToken = await admin()
  const queue = await call('GET', '/api/voko/admin/disputes', { token: adminToken })
  check('运营仲裁队列里有这条争议', queue.status === 200 && queue.json.disputes.some((d) => d.task.id === hard.id), queue)
  const notAdmin = await call('POST', `/api/voko/admin/disputes/${hard.id}/resolve`, { token: alice.token, body: { outcome: 'settled' } })
  check('非运营裁决 → 403', notAdmin.status === 403, notAdmin)
  const resolved = await call('POST', `/api/voko/admin/disputes/${hard.id}/resolve`, { token: adminToken, body: { outcome: 'settled' } })
  check('F3-04 运营裁决支付接单方 → settled,接单方 +40', resolved.status === 200 && resolved.json.task.status === 'settled' && (await wallet(taker)).available === 251 && (await db('vk_disputes').where({ task_id: hard.id }).first()).status === 'resolved', { resolved, wallet: await wallet(taker) })

  // 任一方发起争议 → 运营裁决退款。
  const disputed = (await publish(publisher, { reward: 30, acceptance: items })).json.data
  await claim(taker, disputed.id)
  await submit(taker, disputed.id)
  const publisherBefore = (await wallet(publisher)).available
  const opened = await call('POST', `/v1/tasks/${disputed.id}/dispute`, { token: taker.key })
  const refunded = await call('POST', `/api/voko/admin/disputes/${disputed.id}/resolve`, { token: adminToken, body: { outcome: 'refunded' } })
  check('接单方发起争议 → 运营裁决退款 → refunded,发布方拿回 30', opened.status === 200 && opened.json.data.status === 'disputed' && refunded.json.task.status === 'refunded' && (await wallet(publisher)).available === publisherBefore + 30, { opened, refunded })

  // F3-05 擂台赛选赢家 → 赢家全额,其他人无流水。
  const contest = (await publish(publisher, { reward: 40, template: 'contest', acceptance: items, deadline: new Date(Date.now() + 5000).toISOString() })).json.data
  await call('POST', `/v1/tasks/${contest.id}/enter`, { token: taker.key })
  await call('POST', `/v1/tasks/${contest.id}/enter`, { token: helper.key })
  const takerSubmission = await submit(taker, contest.id)
  await submit(helper, contest.id)
  const closed = await waitForTask(publisher, contest.id, (task) => task.status === 'reviewing')
  check('擂台赛到截止由 worker 关闭 → reviewing,发布方看得到两份提交', closed.status === 'reviewing' && closed.submissions.length === 2, closed)
  const noSubmission = await review(publisher, contest.id, 'select_winner', ids)
  check('select_winner 未指明提交 → 422', noSubmission.status === 422, noSubmission)
  const helperBefore = (await wallet(helper)).available
  const won = await review(publisher, contest.id, 'select_winner', ids, [], { submission_id: takerSubmission.json.data.submission_id })
  const contestPayouts = await db('vk_escrow_payouts').where({ task_id: contest.id })
  check('F3-05 选赢家 → 赢家全额 40,其他人无流水', won.status === 200 && won.json.data.status === 'settled' && (await wallet(taker)).available === 291 && (await wallet(helper)).available === helperBefore && contestPayouts.length === 1, { won, contestPayouts })
})