f2-execute.mjs
16.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
#!/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)
})