@mzzsfy/dsh-cron-board 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,361 @@
1
+ // 环境变量路由测试:CRUD / 打码 / 导入导出(设计 §4.4、§7 BDD)。
2
+ // ctx 桩形态对齐 usage-panel route.test.mjs;数据走临时目录。
3
+
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { EventEmitter } from 'node:events'
7
+ import { mkdtemp, rm } from 'node:fs/promises'
8
+ import { tmpdir } from 'node:os'
9
+ import { join } from 'node:path'
10
+
11
+ import { createStore } from '../src/store.mjs'
12
+ import { createLogger } from '../src/logger.mjs'
13
+ import { createExecutor } from '../src/executor.mjs'
14
+ import { createApi } from '../src/api.mjs'
15
+
16
+ async function makeApi(t, overrides = {}) {
17
+ const dir = await mkdtemp(join(tmpdir(), 'cron-board-api-'))
18
+ t.after(() => rm(dir, { recursive: true, force: true }))
19
+ const store = await createStore({ dir: join(dir, 'data') })
20
+ const logger = createLogger({ rootDir: join(dir, 'logs') })
21
+ const executor = createExecutor({ store, logger, readMaxConcurrent: () => 2 })
22
+ const api = createApi({ store, logger, executor, ...overrides })
23
+ return { store, api }
24
+ }
25
+ function makeReq(method, body) {
26
+ const req = new EventEmitter()
27
+ req.method = method
28
+ req.headers = { host: 'localhost:3000' }
29
+ if (body !== undefined) {
30
+ process.nextTick(() => {
31
+ req.emit('data', Buffer.from(JSON.stringify(body)))
32
+ req.emit('end')
33
+ })
34
+ }
35
+ return req
36
+ }
37
+
38
+ async function call(api, method, pathAndQuery, body) {
39
+ const res = { status: null, payload: null }
40
+ res.writeHead = (status) => { res.status = status }
41
+ res.end = (text) => {
42
+ res.raw = text
43
+ try { res.payload = JSON.parse(text) } catch { res.payload = null }
44
+ }
45
+ const req = makeReq(method, body)
46
+ req.url = 'http://localhost' + pathAndQuery
47
+ await api.handle(req, res)
48
+ return res
49
+ }
50
+
51
+ test('envs 路由:新建后列表打码,长度不足四字符全打码', async (t) => {
52
+ // Given 新建变量 value=abcdef123456
53
+ const { store, api } = await makeApi(t)
54
+ await store.envs.create({ name: 'TOKEN', value: 'abcdef123456', remarks: 'r', enabled: true })
55
+ await store.envs.create({ name: 'SHORT', value: 'abc', remarks: '', enabled: true })
56
+ // When 列表
57
+ const res = await call(api, 'GET', '/api/cron-board/envs')
58
+ // Then 打码:前2后2中间***;短值全 ***
59
+ assert.equal(res.status, 200)
60
+ const token = res.payload.items.find((row) => row.name === 'TOKEN')
61
+ assert.equal(token.value, 'ab***56')
62
+ const short = res.payload.items.find((row) => row.name === 'SHORT')
63
+ assert.equal(short.value, '***')
64
+ })
65
+
66
+ test('envs 路由:PATCH 更新与 DELETE 删除', async (t) => {
67
+ const { store, api } = await makeApi(t)
68
+ const row = await store.envs.create({ name: 'K', value: 'v', remarks: '', enabled: true })
69
+ // When 更新值与启停,再删除
70
+ const patched = await call(api, 'PATCH', '/api/cron-board/envs/' + row.id, { value: 'v2', enabled: false })
71
+ // Then 更新生效
72
+ assert.equal(patched.status, 200)
73
+ assert.equal(store.envs.get(row.id).value, 'v2')
74
+ assert.equal(store.envs.get(row.id).enabled, false)
75
+ const deleted = await call(api, 'DELETE', '/api/cron-board/envs/' + row.id)
76
+ assert.equal(deleted.status, 200)
77
+ assert.equal(store.envs.get(row.id), undefined)
78
+ // 未知 id 删除报业务错误
79
+ const missing = await call(api, 'DELETE', '/api/cron-board/envs/no-such-id')
80
+ assert.equal(missing.status, 400)
81
+ assert.match(missing.payload.error, /不存在/)
82
+ })
83
+
84
+ test('envs 路由:导入预览不落盘,确认导入追加(client 契约形态)', async (t) => {
85
+ // Given 已有一条 A=old
86
+ const { store, api } = await makeApi(t)
87
+ await store.envs.create({ name: 'A', value: 'old', remarks: '', enabled: true })
88
+ const text = 'A=1 #备注一\nB=2\n坏行\n'
89
+ // When 预览(body.apply 缺省,契约:parsed 为解析行数组)
90
+ const preview = await call(api, 'POST', '/api/cron-board/envs/import', { text })
91
+ // Then 解析行数组与非法计数正确且未落盘
92
+ assert.equal(preview.status, 200)
93
+ assert.equal(preview.payload.parsed.length, 2)
94
+ assert.deepEqual(preview.payload.parsed.map((row) => row.name), ['A', 'B'])
95
+ assert.equal(preview.payload.invalid, 1)
96
+ assert.equal(store.envs.list().length, 1)
97
+ // When 确认导入(body.apply=true 且 overwrite 缺省 = 追加)
98
+ const committed = await call(api, 'POST', '/api/cron-board/envs/import', { text, apply: true })
99
+ // Then 追加两条,A 有新旧两条(同名多值)
100
+ assert.equal(committed.status, 200)
101
+ const rows = store.envs.list()
102
+ assert.equal(rows.length, 3)
103
+ assert.equal(rows.filter((row) => row.name === 'A').length, 2)
104
+ })
105
+
106
+ test('envs 路由:覆盖导入清空原集合再落盘(client 契约 overwrite 字段)', async (t) => {
107
+ const { store, api } = await makeApi(t)
108
+ await store.envs.create({ name: 'OLD', value: 'x', remarks: '', enabled: true })
109
+ // When 覆盖导入
110
+ const committed = await call(api, 'POST', '/api/cron-board/envs/import', { text: 'N=1\n', apply: true, overwrite: true })
111
+ // Then 原集合清空,仅剩新行
112
+ assert.equal(committed.status, 200)
113
+ assert.deepEqual(store.envs.list().map((row) => row.name), ['N'])
114
+ })
115
+
116
+ test('envs 路由:导出默认仅启用行,备注折叠为行尾注释', async (t) => {
117
+ const { store, api } = await makeApi(t)
118
+ await store.envs.create({ name: 'ON', value: '1', remarks: 'note', enabled: true })
119
+ await store.envs.create({ name: 'OFF', value: '2', remarks: '', enabled: false })
120
+ // When 导出(默认不含禁用)
121
+ const res = await call(api, 'GET', '/api/cron-board/envs/export')
122
+ // Then 行文本形态
123
+ assert.equal(res.payload.text, 'ON=1 #note\n')
124
+ })
125
+
126
+ test('envs 路由:multi 新建折叠 values 数组为换行存储(client 契约)', async (t) => {
127
+ // Given 客户端 multi 提交形态:name+values 数组
128
+ const { store, api } = await makeApi(t)
129
+ // When 新建
130
+ const created = await call(api, 'POST', '/api/cron-board/envs', { name: 'TOKEN', values: ['a', ' b ', '', 'c'], multi: true })
131
+ // Then 单行存储 + multi 标志,值按行折叠,空白行剔除
132
+ assert.equal(created.status, 200)
133
+ const row = store.envs.get(created.payload.id)
134
+ assert.equal(row.multi, true)
135
+ assert.equal(row.value, 'a\nb\nc')
136
+ })
137
+
138
+ test('envs 路由:multi PATCH 折叠 values,非 multi 保留 value 语义', async (t) => {
139
+ const { store, api } = await makeApi(t)
140
+ const multiRow = await store.envs.create({ name: 'M', value: 'x', multi: true, enabled: true })
141
+ const plainRow = await store.envs.create({ name: 'P', value: 'y', enabled: true })
142
+ // When 多值行 PATCH values 数组;普通行 PATCH value 字符串
143
+ const patchedMulti = await call(api, 'PATCH', '/api/cron-board/envs/' + multiRow.id, { values: ['u', 'v'], multi: true })
144
+ const patchedPlain = await call(api, 'PATCH', '/api/cron-board/envs/' + plainRow.id, { value: 'z' })
145
+ // Then 各按各自形态落库
146
+ assert.equal(patchedMulti.status, 200)
147
+ assert.equal(patchedPlain.status, 200)
148
+ assert.equal(store.envs.get(multiRow.id).value, 'u\nv')
149
+ assert.equal(store.envs.get(multiRow.id).multi, true)
150
+ assert.equal(store.envs.get(plainRow.id).value, 'z')
151
+ assert.equal(store.envs.get(plainRow.id).multi, undefined)
152
+ })
153
+
154
+ test('jobs 路由:缺必填字段报中文业务错误', async (t) => {
155
+ const { api } = await makeApi(t)
156
+ // When 缺 name 新建任务
157
+ const res = await call(api, 'POST', '/api/cron-board/jobs', { kind: 'shell', command: 'x', schedule: '* * * * *' })
158
+ // Then 400 且中文文案
159
+ assert.equal(res.status, 400)
160
+ assert.match(res.payload.error, /名称/)
161
+ })
162
+
163
+ test('jobs 路由:concurrency 合法正整数透传落库(client 契约)', async (t) => {
164
+ const { store, api } = await makeApi(t)
165
+ // When 带并发上限新建
166
+ const created = await call(api, 'POST', '/api/cron-board/jobs', {
167
+ name: '限流', kind: 'shell', command: 'x', schedule: '* * * * *', concurrency: 3,
168
+ })
169
+ // Then 落库透传,任务级闸门可读
170
+ assert.equal(created.status, 200)
171
+ assert.equal(created.payload.concurrency, 3)
172
+ // When 非法值(0/负数/非整数)不落库
173
+ const bad = await call(api, 'POST', '/api/cron-board/jobs', {
174
+ name: '不限', kind: 'shell', command: 'x', schedule: '* * * * *', concurrency: 0,
175
+ })
176
+ assert.equal(bad.status, 200)
177
+ assert.equal(bad.payload.concurrency, undefined)
178
+ assert.equal(store.jobs.list().filter((job) => job.name === '限流')[0].concurrency, 3)
179
+ })
180
+
181
+ test('jobs 路由:非法 cron 表达式被拒', async (t) => {
182
+ const { api } = await makeApi(t)
183
+ // When schedule 非法
184
+ const res = await call(api, 'POST', '/api/cron-board/jobs', { name: 'j', kind: 'shell', command: 'x', schedule: 'not-a-cron' })
185
+ // Then 400 且文案含表达式
186
+ assert.equal(res.status, 400)
187
+ assert.match(res.payload.error, /cron/)
188
+ })
189
+
190
+ test('jobs 路由:手动运行端到端——RunRecord 产生、日志可读、状态终态', async (t) => {
191
+ // Given shell 任务(node -e 输出标记)
192
+ const { store, api } = await makeApi(t)
193
+ const job = await store.jobs.create({
194
+ name: 'e2e', kind: 'shell',
195
+ command: `${process.execPath} -e "console.log('e2e-marker')"`,
196
+ schedule: '* * * * *', enabled: true, timeoutMs: 10 * 1000,
197
+ })
198
+ // When 手动运行
199
+ const runRes = await call(api, 'POST', '/api/cron-board/jobs/' + job.id + '/run')
200
+ assert.equal(runRes.status, 200)
201
+ const { runIds } = runRes.payload
202
+ assert.equal(runIds.length, 1)
203
+ // Then 等待终态,记录与日志齐备
204
+ const record = await waitFor(() => {
205
+ const row = store.runs.get(runIds[0])
206
+ return row && row.status !== 'queued' && row.status !== 'running' ? row : null
207
+ })
208
+ assert.equal(record.status, 'success')
209
+ assert.equal(record.trigger, 'manual')
210
+ const logRes = await call(api, 'GET', '/api/cron-board/runs/' + runIds[0] + '/log')
211
+ assert.match(logRes.payload.text, /e2e-marker/)
212
+ })
213
+
214
+ test('jobs 路由:同名多值展开为多条 RunRecord 与多次运行', async (t) => {
215
+ // Given 启用变量 A=1/A=2,任务回显变量值
216
+ const { store, api } = await makeApi(t)
217
+ await store.envs.create({ name: 'CB_E2E', value: 'one', enabled: true })
218
+ await store.envs.create({ name: 'CB_E2E', value: 'two', enabled: true })
219
+ const job = await store.jobs.create({
220
+ name: 'multi', kind: 'shell',
221
+ command: `${process.execPath} -e "console.log('V=' + process.env.CB_E2E)"`,
222
+ schedule: '* * * * *', enabled: true, timeoutMs: 10 * 1000,
223
+ })
224
+ // When 手动运行
225
+ const runRes = await call(api, 'POST', '/api/cron-board/jobs/' + job.id + '/run')
226
+ const { runIds } = runRes.payload
227
+ // Then 两条记录、两条日志各注入一个值
228
+ assert.equal(runIds.length, 2)
229
+ const records = await waitFor(() => {
230
+ const rows = runIds.map((id) => store.runs.get(id))
231
+ return rows.every((row) => row && row.status !== 'queued' && row.status !== 'running') ? rows : null
232
+ })
233
+ assert.equal(records.length, 2)
234
+ const logs = new Set()
235
+ for (const id of runIds) {
236
+ const logRes = await call(api, 'GET', '/api/cron-board/runs/' + id + '/log')
237
+ logs.add(logRes.payload.text.match(/V=(\w+)/)[1])
238
+ }
239
+ assert.deepEqual([...logs].sort(), ['one', 'two'])
240
+ })
241
+
242
+ test('status 路由:报告调度器与 timer 可用性', async (t) => {
243
+ // Given scheduler 桩与 timer 运行标记
244
+ const { api } = await makeApi(t, {
245
+ scheduler: { status: () => ({ active: 1, queued: 2 }), nextRunAt: () => 1750000000000 },
246
+ periodic: { running: true, reason: null },
247
+ })
248
+ // When GET /status
249
+ const res = await call(api, 'GET', '/api/cron-board/status')
250
+ // Then 状态齐备
251
+ assert.equal(res.status, 200)
252
+ assert.equal(res.payload.timerRunning, true)
253
+ assert.deepEqual(res.payload.scheduler, { active: 1, queued: 2 })
254
+ assert.equal(res.payload.nextAt, 1750000000000)
255
+ })
256
+
257
+ test('status 路由:timer 缺失降级时 timerRunning=false 且带原因', async (t) => {
258
+ const { api } = await makeApi(t, { periodic: { running: false, reason: '宿主定时服务不可用' } })
259
+ const res = await call(api, 'GET', '/api/cron-board/status')
260
+ assert.equal(res.payload.timerRunning, false)
261
+ assert.equal(res.payload.timerReason, '宿主定时服务不可用')
262
+ })
263
+
264
+ test('jobs 路由:会话任务字段校验与默认值', async (t) => {
265
+ const { store, api } = await makeApi(t)
266
+ // When 建 pinned 会话任务
267
+ const created = await call(api, 'POST', '/api/cron-board/jobs', {
268
+ name: '日报', kind: 'session', prompt: '写日报',
269
+ schedule: '0 9 * * *', enabled: true, timeoutMs: 60 * 1000,
270
+ session: { mode: 'pinned', pinnedSessionId: ' s-9 ', windowStart: '09:00', windowEnd: '23:00', onMiss: 'defer' },
271
+ })
272
+ // Then 会话子对象落库,id 已裁剪,默认值正确
273
+ assert.equal(created.status, 200)
274
+ assert.deepEqual(created.payload.session, {
275
+ mode: 'pinned', pinnedSessionId: 's-9', windowStart: '09:00', windowEnd: '23:00', onMiss: 'defer',
276
+ })
277
+ // When shell 任务不带 session 字段
278
+ const shellJob = await call(api, 'POST', '/api/cron-board/jobs', {
279
+ name: 's1', kind: 'shell', command: 'echo x', schedule: '* * * * *', enabled: true, timeoutMs: 1000,
280
+ })
281
+ // Then session 字段缺省
282
+ assert.equal(shellJob.payload.session, undefined)
283
+ // When 窗口只给一端
284
+ const half = await call(api, 'POST', '/api/cron-board/jobs', {
285
+ name: 's2', kind: 'session', prompt: 'x', schedule: '* * * * *', enabled: true, timeoutMs: 1000,
286
+ session: { windowStart: '09:00' },
287
+ })
288
+ // Then 400(窗口须成对)
289
+ assert.equal(half.status, 400)
290
+ assert.match(half.payload.error, /窗口/)
291
+ // When 非法窗口格式
292
+ const badWindow = await call(api, 'POST', '/api/cron-board/jobs', {
293
+ name: 's3', kind: 'session', prompt: 'x', schedule: '* * * * *', enabled: true, timeoutMs: 1000,
294
+ session: { windowStart: '9:0', windowEnd: '23:00' },
295
+ })
296
+ // Then 400
297
+ assert.equal(badWindow.status, 400)
298
+ // When 非法 onMiss
299
+ const badMiss = await call(api, 'POST', '/api/cron-board/jobs', {
300
+ name: 's4', kind: 'session', prompt: 'x', schedule: '* * * * *', enabled: true, timeoutMs: 1000,
301
+ session: { onMiss: 'retry' },
302
+ })
303
+ // Then 400
304
+ assert.equal(badMiss.status, 400)
305
+ })
306
+
307
+ test('cron preview 路由:人话摘要与下三次触发', async (t) => {
308
+ const { api } = await makeApi(t)
309
+ // When 预览「每天 08:30」
310
+ const ok = await call(api, 'POST', '/api/cron-board/cron/preview', { schedule: '30 8 * * *' })
311
+ // Then 摘要含每天与时刻,三次触发时间戳齐备
312
+ assert.equal(ok.status, 200)
313
+ assert.equal(ok.payload.summary, '每天 08:30')
314
+ assert.equal(ok.payload.nextAt.length, 3)
315
+ assert.ok(ok.payload.nextAt.every((value) => typeof value === 'number' && value > Date.now()))
316
+ // When 非法表达式
317
+ const bad = await call(api, 'POST', '/api/cron-board/cron/preview', { schedule: 'not-cron' })
318
+ // Then 400 中文报错
319
+ assert.equal(bad.status, 400)
320
+ assert.match(bad.payload.error, /cron/i)
321
+ })
322
+
323
+ test('runs 路由:无参数查全量,带 jobId 查单任务', async (t) => {
324
+ // Given 两个任务各有一条运行记录
325
+ const { store, api } = await makeApi(t)
326
+ const j1 = await store.jobs.create({ name: 'j1', kind: 'shell', command: 'x', schedule: '* * * * *', enabled: true, timeoutMs: 1000 })
327
+ const j2 = await store.jobs.create({ name: 'j2', kind: 'shell', command: 'x', schedule: '* * * * *', enabled: true, timeoutMs: 1000 })
328
+ await store.runs.create({ jobId: j1.id, trigger: 'cron', status: 'success' })
329
+ await store.runs.create({ jobId: j2.id, trigger: 'manual', status: 'success' })
330
+ // When 无参数查全量
331
+ const all = await call(api, 'GET', '/api/cron-board/runs')
332
+ // Then 两条齐全(新记录在前)
333
+ assert.equal(all.status, 200)
334
+ assert.equal(all.payload.items.length, 2)
335
+ assert.equal(all.payload.items[0].trigger, 'manual')
336
+ // When 带 jobId 过滤
337
+ const mine = await call(api, 'GET', '/api/cron-board/runs?jobId=' + j1.id)
338
+ // Then 仅该任务记录
339
+ assert.equal(mine.payload.items.length, 1)
340
+ assert.equal(mine.payload.items[0].jobId, j1.id)
341
+ })
342
+
343
+ // 轮询等待:超时抛错(测试内时间敏感操作统一出口)
344
+ function waitFor(pick, timeoutMs = 10 * 1000) {
345
+ return new Promise((resolve, reject) => {
346
+ const started = Date.now()
347
+ const tick = () => {
348
+ const value = pick()
349
+ if (value) {
350
+ resolve(value)
351
+ return
352
+ }
353
+ if (Date.now() - started > timeoutMs) {
354
+ reject(new Error('waitFor 超时'))
355
+ return
356
+ }
357
+ setTimeout(tick, 50)
358
+ }
359
+ tick()
360
+ })
361
+ }