#!/usr/bin/env node /** * F2 接单与执行的真实链路脚本(Planning 用例 F2-01 – F2-07,以及 TM-06 的安静时段部分)。F2-04 需要 voko-worker 在跑(求助邮件由 job 发出)。 */ import { runScenario } from './_lib.mjs' await runScenario('f2', async ({ db, check, call, waitForEmail, createOwner, createGuild, createAgent, grantBudget, publish, submit, claim, uploadFile, downloadFile }) => { 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 交付物:上传到平台的文件或 https 外链;提交后进入待验收。 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' }] } }) const noArtifacts = await call('POST', `/v1/tasks/${delivering.id}/submit`, { token: taker.key, body: { summary: 'done', artifacts: [] } }) check('F2-07 格式不对的交付物、非 https 链接、没有交付物 → 400,提示先上传文件', notLink.status === 400 && httpLink.status === 400 && noArtifacts.status === 400 && noArtifacts.json.error.hint.includes('/files'), { notLink, httpLink, noArtifacts }) const article = '# How to reset a password\n\n1. Open Settings.\n2. Choose Reset password.\n' const uploaded = await uploadFile(taker, delivering.id, '../../reset password.md', article) check('上传交付文件 → file_id、去掉路径的文件名、大小与哈希', uploaded.status === 200 && uploaded.json.data.file_id.startsWith('f_') && uploaded.json.data.filename === 'reset password.md' && uploaded.json.data.size_bytes === Buffer.byteLength(article) && /^[0-9a-f]{64}$/.test(uploaded.json.data.sha256), uploaded) const strangerUpload = await uploadFile(rival, delivering.id, 'x.md', 'x') const emptyUpload = await uploadFile(taker, delivering.id, 'empty.md', '') check('非持有人上传 → 403;空文件 → 422', strangerUpload.status === 403 && emptyUpload.status === 422, { strangerUpload, emptyUpload }) const rivalFile = await uploadFile(rival, working.id, 'mine.md', 'rival work') const borrowed = await call('POST', `/v1/tasks/${delivering.id}/submit`, { token: taker.key, body: { summary: 'done', artifacts: [{ type: 'file', file_id: rivalFile.json.data.file_id }] } }) check('提交里引用别人或别的任务的文件 → 422', rivalFile.status === 200 && borrowed.status === 422, { borrowed }) const beforeSubmit = await downloadFile(publisher, uploaded.json.data.file_id) check('上传者能下载自己的文件;别的成员取不到', (await downloadFile(taker, uploaded.json.data.file_id)).text === article && (await downloadFile(rival, uploaded.json.data.file_id)).status === 404, beforeSubmit) const submitted = await call('POST', `/v1/tasks/${delivering.id}/submit`, { token: taker.key, body: { summary: 'The how-to article is attached.', artifacts: [{ type: 'file', file_id: uploaded.json.data.file_id, note: 'The article' }] } }) const seen = (await call('GET', `/v1/tasks/${delivering.id}`, { token: publisher.key })).json.data.submissions[0].artifacts[0] const fetched = await downloadFile(publisher, uploaded.json.data.file_id) check('发布方在任务详情里看到文件名与大小,并能下载到原文(以附件形式)', seen.type === 'file' && seen.filename === 'reset password.md' && seen.size_bytes === Buffer.byteLength(article) && fetched.text === article && fetched.disposition.startsWith('attachment'), { seen, fetched }) const lateUpload = await uploadFile(taker, delivering.id, 'late.md', 'late') check('提交后不能再上传 → 409', lateUpload.status === 409, lateUpload) 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 }) // F2-04 求助 → 主人收到邮件;主人网页回复后 Agent 下次 /home 拿到答复。(rival 手上有接力来的 working 任务) // 默认安静时段是 23:00–08:00(UTC);先关掉安静时段,保证这封邮件现在就发。 await call('PUT', '/api/voko/me/settings', { token: bob.token, body: { timezone: 'UTC', quiet_start_hour: 0, quiet_end_hour: 0, email_help: true, email_digest: true } }) const asked = await call('POST', `/v1/tasks/${working.id}/help`, { token: rival.key, body: { question: 'Should the tone be formal or casual?' } }) const notHolderHelp = await call('POST', `/v1/tasks/${working.id}/help`, { token: taker.key, body: { question: 'hi' } }) check('求助 → help_id;非持有人求助 → 403', asked.status === 200 && typeof asked.json.data.help_id === 'string' && notHolderHelp.status === 403, { asked, notHolderHelp }) const helpMail = await waitForEmail({ to_email: bob.email, purpose: 'help_requested', status: 'sent' }) check('F2-04 主人收到求助邮件', helpMail.template === 'voko-notice', helpMail) const inbox = await call('GET', '/api/voko/help', { token: bob.token }) const aliceInbox = await call('GET', '/api/voko/help', { token: alice.token }) check('求助出现在主人的收件箱,别人看不到', inbox.json.help_requests.some((h) => h.id === asked.json.data.help_id && h.status === 'open') && aliceInbox.json.help_requests.length === 0, { inbox, aliceInbox }) const stranger = await call('POST', `/api/voko/help/${asked.json.data.help_id}/answer`, { token: alice.token, body: { answer: 'x' } }) const answered = await call('POST', `/api/voko/help/${asked.json.data.help_id}/answer`, { token: bob.token, body: { answer: 'Formal, please.' } }) const answeredTwice = await call('POST', `/api/voko/help/${asked.json.data.help_id}/answer`, { token: bob.token, body: { answer: 'again' } }) check('主人回复成功;别人回复 → 404;重复回复 → 409', answered.status === 200 && stranger.status === 404 && answeredTwice.status === 409, { answered, stranger, answeredTwice }) const rivalHome = await call('GET', '/v1/home', { token: rival.key }) check('F2-04 Agent 下次 /home 拿到答复', rivalHome.json.data.help_answers.some((h) => h.help_id === asked.json.data.help_id && h.answer === 'Formal, please.') && rivalHome.json.data.events.some((e) => e.type === 'help.answered'), rivalHome) // TM-06 安静时段内的求助邮件推迟到安静时段结束:把主人的安静时段设成从当前小时起的两小时。 const hourNow = new Date().getUTCHours() const quiet = { timezone: 'UTC', quiet_start_hour: hourNow, quiet_end_hour: (hourNow + 2) % 24, email_help: true, email_digest: true } const saved = await call('PUT', '/api/voko/me/settings', { token: bob.token, body: quiet }) const badZone = await call('PUT', '/api/voko/me/settings', { token: bob.token, body: { ...quiet, timezone: 'Mars/Olympus' } }) check('保存设置;非法时区 → 400', saved.status === 200 && (await call('GET', '/api/voko/me/settings', { token: bob.token })).json.settings.quiet_start_hour === hourNow && badZone.status === 400, { saved, badZone }) const quietHelp = await call('POST', `/v1/tasks/${working.id}/help`, { token: rival.key, body: { question: 'One more question during quiet hours.' } }) const quietJob = await db('vk_jobs').where({ kind: 'help_notify', ref_id: quietHelp.json.data.help_id }).first() const expected = new Date(); expected.setUTCHours(hourNow + 2, 0, 0, 0) check('TM-06 安静时段内的求助邮件推迟到安静时段结束的整点', quietJob.status === 'pending' && quietJob.run_at.getTime() === expected.getTime(), { run_at: quietJob.run_at, expected }) await call('PUT', '/api/voko/me/settings', { token: bob.token, body: { ...quiet, email_help: false } }) const mutedHelp = await call('POST', `/v1/tasks/${working.id}/help`, { token: rival.key, body: { question: 'Emails are off now.' } }) check('关闭求助邮件后不再排通知', (await db('vk_jobs').where({ kind: 'help_notify', ref_id: mutedHelp.json.data.help_id }).first()) === undefined, mutedHelp) })