@mzzsfy/dsh-turn-notify 0.7.2
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.
- package/README.md +146 -0
- package/cordis.patch.yml +13 -0
- package/package.json +50 -0
- package/src/client.js +1857 -0
- package/src/core.mjs +583 -0
- package/src/index.js +660 -0
- package/test/client-id.test.mjs +16 -0
- package/test/core.test.mjs +736 -0
- package/test/event-wiring.test.mjs +585 -0
- package/test/flow.test.mjs +563 -0
- package/test/hmr-heal.test.mjs +31 -0
- package/test/im-targets.test.mjs +76 -0
- package/test/parity.test.mjs +312 -0
- package/test/rename.route.test.mjs +235 -0
- package/test/route.test.mjs +377 -0
- package/test/sounds.route.test.mjs +257 -0
- package/test/switch-guard.test.mjs +32 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// host 路由测试:stub ctx 装载真实 src/index.js,验证 config 读写链路与
|
|
2
|
+
// test-webhook 的真实投递结果(回归:测试按钮不再无条件谎报成功)。
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { EventEmitter } from 'node:events'
|
|
6
|
+
|
|
7
|
+
import { apply } from '../src/index.js'
|
|
8
|
+
|
|
9
|
+
const MIN_TURN_MS = 5 * 1000
|
|
10
|
+
|
|
11
|
+
function makeRes() {
|
|
12
|
+
return {
|
|
13
|
+
status: null,
|
|
14
|
+
body: null,
|
|
15
|
+
writeHead(status) { this.status = status },
|
|
16
|
+
end(body) { this.body = JSON.parse(body) },
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeReq(method, payload, headers) {
|
|
21
|
+
const req = new EventEmitter()
|
|
22
|
+
req.method = method
|
|
23
|
+
req.url = '/api/turn-notify/config'
|
|
24
|
+
req.headers = { host: '127.0.0.1:3080', ...(headers || {}) }
|
|
25
|
+
const data = payload === undefined ? null : Buffer.from(JSON.stringify(payload))
|
|
26
|
+
process.nextTick(() => {
|
|
27
|
+
if (data !== null) req.emit('data', data)
|
|
28
|
+
req.readableEnded = true
|
|
29
|
+
req.emit('end')
|
|
30
|
+
})
|
|
31
|
+
return req
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const JSON_HEADERS = { 'content-type': 'application/json' }
|
|
35
|
+
|
|
36
|
+
// settings 最小实现:get/update 挂服务级(对齐 SettingsProvider),update 按两层
|
|
37
|
+
// 对象合并,覆盖 enabled 部分开关语义;近似点:get 返回原始节,真实实现返回
|
|
38
|
+
// schema 解析后的冻结值,对被测读写语义无影响
|
|
39
|
+
function makeSettings() {
|
|
40
|
+
const doc = new Map()
|
|
41
|
+
const merge = (under, over) => {
|
|
42
|
+
const out = { ...under }
|
|
43
|
+
for (const [key, value] of Object.entries(over)) {
|
|
44
|
+
const underValue = out[key]
|
|
45
|
+
const bothPlain = (value !== null && typeof value === 'object' && !Array.isArray(value))
|
|
46
|
+
&& (underValue !== null && typeof underValue === 'object' && !Array.isArray(underValue))
|
|
47
|
+
out[key] = bothPlain ? merge(underValue, value) : value
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
register(ns) {
|
|
53
|
+
if (!doc.has(ns)) doc.set(ns, {})
|
|
54
|
+
return {
|
|
55
|
+
get: () => doc.get(ns),
|
|
56
|
+
watch: () => () => {},
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
get: (ns) => doc.get(ns),
|
|
60
|
+
update: async (ns, patch) => { doc.set(ns, merge(doc.get(ns) ?? {}, patch)) },
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeCtx(extraServices) {
|
|
65
|
+
const routes = new Map()
|
|
66
|
+
const settingsService = makeSettings()
|
|
67
|
+
const seed = extraServices ? extraServices.settingsStore : undefined
|
|
68
|
+
if (seed !== undefined) settingsService.update('turn-notify', seed)
|
|
69
|
+
const ctx = {
|
|
70
|
+
on() {},
|
|
71
|
+
get(key) {
|
|
72
|
+
if (key === 'settings') return settingsService
|
|
73
|
+
return extraServices ? extraServices[key] : undefined
|
|
74
|
+
},
|
|
75
|
+
inject(deps, fn) { fn({ settings: settingsService }) },
|
|
76
|
+
effect(thunk) { thunk() },
|
|
77
|
+
webServer: { register(route) { routes.set(route.path, route.handler) } },
|
|
78
|
+
}
|
|
79
|
+
return { ctx, routes }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
test('config GET 返回解析后的默认配置,webhookUrl 凭据不出主机', async () => {
|
|
83
|
+
const { ctx, routes } = makeCtx()
|
|
84
|
+
apply(ctx)
|
|
85
|
+
const handler = routes.get('/api/turn-notify/config')
|
|
86
|
+
assert.ok(handler, 'config 路由未注册')
|
|
87
|
+
const res = makeRes()
|
|
88
|
+
await handler(makeReq('GET'), res)
|
|
89
|
+
assert.equal(res.status, 200)
|
|
90
|
+
assert.equal(res.body.webhookConfigured, false)
|
|
91
|
+
assert.equal('webhookUrl' in res.body, false)
|
|
92
|
+
assert.equal(res.body.minTurnDurationMs, MIN_TURN_MS)
|
|
93
|
+
assert.equal(res.body.rootsOnly, true)
|
|
94
|
+
assert.equal(res.body.enabled.completed, true)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('config POST 非法补丁 400,合法补丁持久化且部分开关不覆盖其他分类', async () => {
|
|
98
|
+
const { ctx, routes } = makeCtx()
|
|
99
|
+
apply(ctx)
|
|
100
|
+
const handler = routes.get('/api/turn-notify/config')
|
|
101
|
+
const bad = makeRes()
|
|
102
|
+
await handler(makeReq('POST', { webhookUrl: 'ftp://hook.example' }, JSON_HEADERS), bad)
|
|
103
|
+
assert.equal(bad.status, 400)
|
|
104
|
+
assert.match(bad.body.error, /http\(s\)/)
|
|
105
|
+
const good = makeRes()
|
|
106
|
+
await handler(makeReq('POST', { webhookUrl: 'https://hook.example', enabled: { completed: false } }, JSON_HEADERS), good)
|
|
107
|
+
assert.equal(good.status, 200)
|
|
108
|
+
assert.equal(good.body.webhookConfigured, true)
|
|
109
|
+
assert.equal(good.body.enabled.completed, false)
|
|
110
|
+
assert.equal(good.body.enabled.error, true)
|
|
111
|
+
const readback = makeRes()
|
|
112
|
+
await handler(makeReq('GET'), readback)
|
|
113
|
+
assert.equal(readback.body.webhookConfigured, true)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('config POST 负路径:跨源 403,非 JSON 400,畸形体 400,空补丁 200', async () => {
|
|
117
|
+
const { ctx, routes } = makeCtx()
|
|
118
|
+
apply(ctx)
|
|
119
|
+
const handler = routes.get('/api/turn-notify/config')
|
|
120
|
+
const crossOrigin = makeRes()
|
|
121
|
+
await handler(makeReq('POST', {}, { ...JSON_HEADERS, origin: 'https://evil.example' }), crossOrigin)
|
|
122
|
+
assert.equal(crossOrigin.status, 403)
|
|
123
|
+
const sameOrigin = makeRes()
|
|
124
|
+
await handler(makeReq('POST', { rootsOnly: false }, { ...JSON_HEADERS, origin: 'http://127.0.0.1:3080' }), sameOrigin)
|
|
125
|
+
assert.equal(sameOrigin.status, 200)
|
|
126
|
+
const wrongType = makeRes()
|
|
127
|
+
await handler(makeReq('POST', {}, { 'content-type': 'text/plain' }), wrongType)
|
|
128
|
+
assert.equal(wrongType.status, 400)
|
|
129
|
+
const malformed = makeRes()
|
|
130
|
+
await handler(makeReq('POST', undefined, JSON_HEADERS), malformed)
|
|
131
|
+
assert.equal(malformed.status, 400)
|
|
132
|
+
const empty = makeRes()
|
|
133
|
+
await handler(makeReq('POST', {}, JSON_HEADERS), empty)
|
|
134
|
+
assert.equal(empty.status, 200)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('config POST 超限体 400', async () => {
|
|
138
|
+
const { ctx, routes } = makeCtx()
|
|
139
|
+
apply(ctx)
|
|
140
|
+
const handler = routes.get('/api/turn-notify/config')
|
|
141
|
+
const req = new EventEmitter()
|
|
142
|
+
req.method = 'POST'
|
|
143
|
+
req.url = '/api/turn-notify/config'
|
|
144
|
+
req.headers = { host: '127.0.0.1:3080', 'content-type': 'application/json' }
|
|
145
|
+
req.destroy = () => { req.destroyed = true }
|
|
146
|
+
process.nextTick(() => {
|
|
147
|
+
req.emit('data', Buffer.alloc(64 * 1024 + 1))
|
|
148
|
+
req.readableEnded = true
|
|
149
|
+
req.emit('end')
|
|
150
|
+
})
|
|
151
|
+
const res = makeRes()
|
|
152
|
+
await handler(req, res)
|
|
153
|
+
assert.equal(res.status, 400)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('config 路由 405:PUT 被拒', async () => {
|
|
157
|
+
const { ctx, routes } = makeCtx()
|
|
158
|
+
apply(ctx)
|
|
159
|
+
const res = makeRes()
|
|
160
|
+
await routes.get('/api/turn-notify/config')(makeReq('PUT', {}, JSON_HEADERS), res)
|
|
161
|
+
assert.equal(res.status, 405)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
function makeImReq(url) {
|
|
165
|
+
const req = makeReq('GET')
|
|
166
|
+
req.url = url
|
|
167
|
+
return req
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
test('config GET 返回 imAvailable 缺省 false 与 imTargets 空缺省', async () => {
|
|
171
|
+
const { ctx, routes } = makeCtx()
|
|
172
|
+
apply(ctx)
|
|
173
|
+
const res = makeRes()
|
|
174
|
+
await routes.get('/api/turn-notify/config')(makeReq('GET'), res)
|
|
175
|
+
assert.equal(res.body.imAvailable, false)
|
|
176
|
+
assert.deepEqual(res.body.imTargets, [])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('config POST 持久化 imTargets 且 dshIm 在场时 imAvailable 为 true', async () => {
|
|
180
|
+
const dshIm = { send: async () => ({ sent: true }), listTargets: async () => [] }
|
|
181
|
+
const { ctx, routes } = makeCtx({ dshIm })
|
|
182
|
+
apply(ctx)
|
|
183
|
+
const handler = routes.get('/api/turn-notify/config')
|
|
184
|
+
const res = makeRes()
|
|
185
|
+
await handler(makeReq('POST', { imTargets: [{ botId: 'wx_a', targetId: 'owner' }] }, JSON_HEADERS), res)
|
|
186
|
+
assert.equal(res.status, 200)
|
|
187
|
+
assert.deepEqual(res.body.imTargets, [{ botId: 'wx_a', targetId: 'owner' }])
|
|
188
|
+
assert.equal(res.body.imAvailable, true)
|
|
189
|
+
const readback = makeRes()
|
|
190
|
+
await handler(makeReq('GET'), readback)
|
|
191
|
+
assert.deepEqual(readback.body.imTargets, [{ botId: 'wx_a', targetId: 'owner' }])
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('config POST 非法 imTargets 400', async () => {
|
|
195
|
+
const { ctx, routes } = makeCtx()
|
|
196
|
+
apply(ctx)
|
|
197
|
+
const res = makeRes()
|
|
198
|
+
await routes.get('/api/turn-notify/config')(makeReq('POST', { imTargets: [{ botId: 'wx_a' }] }, JSON_HEADERS), res)
|
|
199
|
+
assert.equal(res.status, 400)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('config POST 仅 imTargets 的部分补丁:不触碰其他配置项,响应含完整面板状态', async () => {
|
|
203
|
+
// 勾选即存契约:面板勾选/取消注册只 POST {imTargets},不得清掉已存的 webhook 与分类开关
|
|
204
|
+
const dshIm = { send: async () => ({ sent: true }), listTargets: async () => [] }
|
|
205
|
+
const { ctx, routes } = makeCtx({ dshIm, settingsStore: { webhookUrl: 'https://hooks.example.com/x', enabled: { completed: false } } })
|
|
206
|
+
apply(ctx)
|
|
207
|
+
const handler = routes.get('/api/turn-notify/config')
|
|
208
|
+
const res = makeRes()
|
|
209
|
+
await handler(makeReq('POST', { imTargets: [{ botId: 'wx_a', targetId: 'owner' }, { botId: 'wx_b', targetId: 'owner' }] }, JSON_HEADERS), res)
|
|
210
|
+
assert.equal(res.status, 200)
|
|
211
|
+
assert.equal(res.body.webhookConfigured, true)
|
|
212
|
+
assert.equal(res.body.enabled.completed, false)
|
|
213
|
+
assert.deepEqual(res.body.imTargets, [{ botId: 'wx_a', targetId: 'owner' }, { botId: 'wx_b', targetId: 'owner' }])
|
|
214
|
+
// 取消注册:整列表替换为空,其余配置仍原样
|
|
215
|
+
const clear = makeRes()
|
|
216
|
+
await handler(makeReq('POST', { imTargets: [] }, JSON_HEADERS), clear)
|
|
217
|
+
assert.equal(clear.status, 200)
|
|
218
|
+
assert.deepEqual(clear.body.imTargets, [])
|
|
219
|
+
assert.equal(clear.body.webhookConfigured, true)
|
|
220
|
+
assert.equal(clear.body.enabled.completed, false)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('im-targets 代理成功时仅投影 targetId/name/kind,剔除 route', async () => {
|
|
224
|
+
const dshIm = {
|
|
225
|
+
send: async () => ({ sent: true }),
|
|
226
|
+
listTargets: async (botId) => {
|
|
227
|
+
assert.equal(botId, 'wx_a')
|
|
228
|
+
return [{ targetId: 'owner', name: '本人', kind: 'user', route: { toUserId: 'native-id' } }]
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
const { ctx, routes } = makeCtx({ dshIm })
|
|
232
|
+
apply(ctx)
|
|
233
|
+
const res = makeRes()
|
|
234
|
+
await routes.get('/api/turn-notify/im-targets')(makeImReq('/api/turn-notify/im-targets?botId=wx_a'), res)
|
|
235
|
+
assert.equal(res.status, 200)
|
|
236
|
+
assert.deepEqual(res.body.targets, [{ targetId: 'owner', name: '本人', kind: 'user' }])
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('im-targets 错误映射:botId 缺失 400,unknown-bot 404,bot-not-connected 503,其余 502,缺席 503', async () => {
|
|
240
|
+
const code = { value: 'unknown-bot' }
|
|
241
|
+
const dshIm = {
|
|
242
|
+
send: async () => ({ sent: true }),
|
|
243
|
+
listTargets: async () => { throw Object.assign(new Error('x'), { code: code.value }) },
|
|
244
|
+
}
|
|
245
|
+
const { ctx, routes } = makeCtx({ dshIm })
|
|
246
|
+
apply(ctx)
|
|
247
|
+
const handler = routes.get('/api/turn-notify/im-targets')
|
|
248
|
+
const missing = makeRes()
|
|
249
|
+
await handler(makeImReq('/api/turn-notify/im-targets'), missing)
|
|
250
|
+
assert.equal(missing.status, 400)
|
|
251
|
+
const unknown = makeRes()
|
|
252
|
+
await handler(makeImReq('/api/turn-notify/im-targets?botId=wx_nobody'), unknown)
|
|
253
|
+
assert.equal(unknown.status, 404)
|
|
254
|
+
code.value = 'bot-not-connected'
|
|
255
|
+
const offline = makeRes()
|
|
256
|
+
await handler(makeImReq('/api/turn-notify/im-targets?botId=wx_a'), offline)
|
|
257
|
+
assert.equal(offline.status, 503)
|
|
258
|
+
code.value = 'delivery-failed'
|
|
259
|
+
const other = makeRes()
|
|
260
|
+
await handler(makeImReq('/api/turn-notify/im-targets?botId=wx_a'), other)
|
|
261
|
+
assert.equal(other.status, 502)
|
|
262
|
+
const bare = makeCtx()
|
|
263
|
+
apply(bare.ctx)
|
|
264
|
+
const absent = makeRes()
|
|
265
|
+
await bare.routes.get('/api/turn-notify/im-targets')(makeImReq('/api/turn-notify/im-targets?botId=wx_a'), absent)
|
|
266
|
+
assert.equal(absent.status, 503)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('test-im 缺席与未配置目标时如实返回', async () => {
|
|
270
|
+
const { ctx, routes } = makeCtx()
|
|
271
|
+
apply(ctx)
|
|
272
|
+
const res = makeRes()
|
|
273
|
+
await routes.get('/api/turn-notify/test-im')(makeReq('POST'), res)
|
|
274
|
+
assert.equal(res.status, 200)
|
|
275
|
+
assert.deepEqual(res.body, { ok: false, detail: 'dsh-im 未安装' })
|
|
276
|
+
const withIm = makeCtx({ dshIm: { send: async () => ({ sent: true }), listTargets: async () => [] } })
|
|
277
|
+
apply(withIm.ctx)
|
|
278
|
+
const empty = makeRes()
|
|
279
|
+
await withIm.routes.get('/api/turn-notify/test-im')(makeReq('POST'), empty)
|
|
280
|
+
assert.deepEqual(empty.body, { ok: false, detail: '未配置投递目标' })
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('test-im 跨源 403', async () => {
|
|
284
|
+
const dshIm = { send: async () => ({ sent: true }), listTargets: async () => [] }
|
|
285
|
+
const { ctx, routes } = makeCtx({ dshIm })
|
|
286
|
+
apply(ctx)
|
|
287
|
+
const res = makeRes()
|
|
288
|
+
await routes.get('/api/turn-notify/test-im')(makeReq('POST', undefined, { origin: 'https://evil.example' }), res)
|
|
289
|
+
assert.equal(res.status, 403)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
test('test-im 逐目标返回真实结果,混合成败', async () => {
|
|
293
|
+
const sends = []
|
|
294
|
+
const dshIm = {
|
|
295
|
+
send: async (botId, targetId, text) => {
|
|
296
|
+
sends.push({ botId, targetId, text })
|
|
297
|
+
if (targetId === 'bad') throw Object.assign(new Error('x'), { code: 'bot-not-connected' })
|
|
298
|
+
return { sent: true }
|
|
299
|
+
},
|
|
300
|
+
listTargets: async () => [],
|
|
301
|
+
}
|
|
302
|
+
const { ctx, routes } = makeCtx({ dshIm })
|
|
303
|
+
apply(ctx)
|
|
304
|
+
await routes.get('/api/turn-notify/config')(
|
|
305
|
+
makeReq('POST', { imTargets: [{ botId: 'wx_a', targetId: 'ok1' }, { botId: 'wx_a', targetId: 'bad' }] }, JSON_HEADERS), makeRes())
|
|
306
|
+
const res = makeRes()
|
|
307
|
+
await routes.get('/api/turn-notify/test-im')(makeReq('POST'), res)
|
|
308
|
+
assert.equal(res.status, 200)
|
|
309
|
+
assert.equal(res.body.ok, false)
|
|
310
|
+
assert.equal(res.body.results.length, 2)
|
|
311
|
+
const bad = res.body.results.find((item) => item.targetId === 'bad')
|
|
312
|
+
const good = res.body.results.find((item) => item.targetId === 'ok1')
|
|
313
|
+
assert.equal(bad.ok, false)
|
|
314
|
+
assert.equal(bad.detail, 'bot-not-connected')
|
|
315
|
+
assert.equal(good.ok, true)
|
|
316
|
+
assert.equal(good.detail, 'sent')
|
|
317
|
+
assert.equal(sends.length, 2)
|
|
318
|
+
assert.ok(sends.every((call) => String(call.text).startsWith('[dsh]')), 'IM 文本应与通知单元一致')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
test('全部写路由守卫:upload 与 sound 删除跨源 403,mapping 非 JSON 400', async () => {
|
|
322
|
+
const { ctx, routes } = makeCtx()
|
|
323
|
+
apply(ctx)
|
|
324
|
+
const upload = makeRes()
|
|
325
|
+
await routes.get('/api/turn-notify/upload')(
|
|
326
|
+
makeReq('POST', undefined, { origin: 'https://evil.example' }), upload)
|
|
327
|
+
assert.equal(upload.status, 403)
|
|
328
|
+
const sound = makeRes()
|
|
329
|
+
await routes.get('/api/turn-notify/sound')(
|
|
330
|
+
makeReq('DELETE', undefined, { origin: 'https://evil.example' }), sound)
|
|
331
|
+
assert.equal(sound.status, 403)
|
|
332
|
+
const mapping = makeRes()
|
|
333
|
+
await routes.get('/api/turn-notify/mapping')(
|
|
334
|
+
makeReq('POST', { category: 'completed', id: '' }, { 'content-type': 'text/plain' }), mapping)
|
|
335
|
+
assert.equal(mapping.status, 400)
|
|
336
|
+
const mappingCross = makeRes()
|
|
337
|
+
await routes.get('/api/turn-notify/mapping')(
|
|
338
|
+
makeReq('POST', { category: 'completed', id: '' }, { ...JSON_HEADERS, origin: 'https://evil.example' }), mappingCross)
|
|
339
|
+
assert.equal(mappingCross.status, 403)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test('test-webhook 未配置时如实返回失败', async () => {
|
|
343
|
+
const { ctx, routes } = makeCtx()
|
|
344
|
+
apply(ctx)
|
|
345
|
+
const res = makeRes()
|
|
346
|
+
await routes.get('/api/turn-notify/test-webhook')(makeReq('POST'), res)
|
|
347
|
+
assert.equal(res.status, 200)
|
|
348
|
+
assert.deepEqual(res.body, { ok: false, detail: '未配置 webhook' })
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
test('test-webhook 跨源 403', async () => {
|
|
352
|
+
const { ctx, routes } = makeCtx()
|
|
353
|
+
apply(ctx)
|
|
354
|
+
const res = makeRes()
|
|
355
|
+
await routes.get('/api/turn-notify/test-webhook')(makeReq('POST', undefined, { origin: 'https://evil.example' }), res)
|
|
356
|
+
assert.equal(res.status, 403)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
test('test-webhook 已配置时送达并返回真实结果', async () => {
|
|
360
|
+
const calls = []
|
|
361
|
+
const original = globalThis.fetch
|
|
362
|
+
globalThis.fetch = async (url, options) => {
|
|
363
|
+
calls.push({ url, body: JSON.parse(options.body) })
|
|
364
|
+
return { ok: true, status: 200 }
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
const { ctx, routes } = makeCtx()
|
|
368
|
+
apply(ctx)
|
|
369
|
+
await routes.get('/api/turn-notify/config')(makeReq('POST', { webhookUrl: 'https://hook.example' }, JSON_HEADERS), makeRes())
|
|
370
|
+
const res = makeRes()
|
|
371
|
+
await routes.get('/api/turn-notify/test-webhook')(makeReq('POST'), res)
|
|
372
|
+
assert.deepEqual(res.body, { ok: true, detail: 'HTTP 200' })
|
|
373
|
+
assert.equal(calls.length, 1)
|
|
374
|
+
assert.equal(calls[0].url, 'https://hook.example')
|
|
375
|
+
assert.ok(String(calls[0].body.text).startsWith('[dsh]'), 'webhook payload 缺少 text')
|
|
376
|
+
} finally { globalThis.fetch = original }
|
|
377
|
+
})
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// 音效库路由集成测试:USERPROFILE 重定向到临时目录后动态装载 host 路由,
|
|
2
|
+
// 隔离真实 ~/.dsh 音效库;覆盖 sounds 列表 / sound 读取与删除 / upload 写入 /
|
|
3
|
+
// mapping 映射写读的完整链路(此前仅守卫路径有覆盖)。
|
|
4
|
+
import test from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { EventEmitter } from 'node:events'
|
|
7
|
+
import { mkdtemp, mkdir, writeFile, readFile, readdir, rm } from 'node:fs/promises'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
11
|
+
|
|
12
|
+
const homeRoot = await mkdtemp(join(tmpdir(), 'tn-sounds-'))
|
|
13
|
+
process.env.USERPROFILE = homeRoot
|
|
14
|
+
process.env.HOME = homeRoot
|
|
15
|
+
const { apply } = await import('../src/index.js')
|
|
16
|
+
|
|
17
|
+
test.after(async () => { await rm(homeRoot, { recursive: true, force: true }) })
|
|
18
|
+
|
|
19
|
+
const soundsDir = join(homeRoot, '.dsh', 'dsh-turn-notify', 'sounds')
|
|
20
|
+
const JSON_HEADERS = { 'content-type': 'application/json' }
|
|
21
|
+
const WAV_BYTES = Buffer.from([1, 2, 3, 4])
|
|
22
|
+
|
|
23
|
+
function makeRes() {
|
|
24
|
+
return {
|
|
25
|
+
status: null,
|
|
26
|
+
body: null,
|
|
27
|
+
raw: null,
|
|
28
|
+
headers: null,
|
|
29
|
+
writeHead(status, headers) { this.status = status; this.headers = headers },
|
|
30
|
+
end(body) {
|
|
31
|
+
if (Buffer.isBuffer(body)) this.raw = body
|
|
32
|
+
else if (typeof body === 'string' && (this.headers ? String(this.headers['content-type']) : '').includes('json')) this.body = JSON.parse(body)
|
|
33
|
+
else this.raw = body
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeReq(method, payload, headers, url) {
|
|
39
|
+
const req = new EventEmitter()
|
|
40
|
+
req.method = method
|
|
41
|
+
req.url = url || '/api/turn-notify/sounds'
|
|
42
|
+
req.headers = { host: '127.0.0.1:3080', ...(headers || {}) }
|
|
43
|
+
req.destroy = () => {}
|
|
44
|
+
const data = payload === undefined ? null : Buffer.from(JSON.stringify(payload))
|
|
45
|
+
process.nextTick(() => {
|
|
46
|
+
if (data !== null) req.emit('data', data)
|
|
47
|
+
req.readableEnded = true
|
|
48
|
+
req.emit('end')
|
|
49
|
+
})
|
|
50
|
+
return req
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeSettings() {
|
|
54
|
+
const doc = new Map()
|
|
55
|
+
const merge = (under, over) => {
|
|
56
|
+
const out = { ...under }
|
|
57
|
+
for (const [key, value] of Object.entries(over)) {
|
|
58
|
+
const underValue = out[key]
|
|
59
|
+
const bothPlain = (value !== null && typeof value === 'object' && !Array.isArray(value))
|
|
60
|
+
&& (underValue !== null && typeof underValue === 'object' && !Array.isArray(underValue))
|
|
61
|
+
out[key] = bothPlain ? merge(underValue, value) : value
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
register(ns) {
|
|
67
|
+
if (!doc.has(ns)) doc.set(ns, {})
|
|
68
|
+
return {
|
|
69
|
+
get: () => doc.get(ns),
|
|
70
|
+
watch: () => () => {},
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
get: (ns) => doc.get(ns),
|
|
74
|
+
update: async (ns, patch) => { doc.set(ns, merge(doc.get(ns) ?? {}, patch)) },
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function makeCtx() {
|
|
79
|
+
const routes = new Map()
|
|
80
|
+
const settingsService = makeSettings()
|
|
81
|
+
const ctx = {
|
|
82
|
+
on() {},
|
|
83
|
+
get(key) { return key === 'settings' ? settingsService : undefined },
|
|
84
|
+
inject(deps, fn) { fn({ settings: settingsService }) },
|
|
85
|
+
effect(thunk) { thunk() },
|
|
86
|
+
webServer: { register(route) { routes.set(route.path, route.handler) } },
|
|
87
|
+
}
|
|
88
|
+
return { ctx, routes, settingsService }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function resetSounds() {
|
|
92
|
+
await rm(soundsDir, { recursive: true, force: true })
|
|
93
|
+
await mkdir(soundsDir, { recursive: true })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function seedSound(name, bytes) {
|
|
97
|
+
await writeFile(join(soundsDir, name), bytes || WAV_BYTES)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function listNames() {
|
|
101
|
+
return readdir(soundsDir)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
test('sounds 列表:id/ext 按首点切分,非音效文件过滤,无索引记录展示名为空', async () => {
|
|
105
|
+
await resetSounds()
|
|
106
|
+
await seedSound('a.wav')
|
|
107
|
+
await seedSound('c.ogg')
|
|
108
|
+
await seedSound('notasound')
|
|
109
|
+
const { ctx, routes } = makeCtx()
|
|
110
|
+
apply(ctx)
|
|
111
|
+
const res = makeRes()
|
|
112
|
+
await routes.get('/api/turn-notify/sounds')(makeReq('GET'), res)
|
|
113
|
+
assert.equal(res.status, 200)
|
|
114
|
+
assert.deepEqual(
|
|
115
|
+
res.body.sounds.sort((x, y) => x.id.localeCompare(y.id)),
|
|
116
|
+
[{ id: 'a', ext: 'wav', name: null }, { id: 'c', ext: 'ogg', name: null }],
|
|
117
|
+
)
|
|
118
|
+
assert.equal(res.body.builtin, true)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('sounds 列表:展示名随索引附加,索引文件不入列,索引损坏降级为无名', async () => {
|
|
122
|
+
await resetSounds()
|
|
123
|
+
await seedSound('a.wav')
|
|
124
|
+
await writeFile(join(soundsDir, 'index.json'), JSON.stringify({ a: '别名甲', ghost: '孤儿名' }))
|
|
125
|
+
const { ctx, routes } = makeCtx()
|
|
126
|
+
apply(ctx)
|
|
127
|
+
const res = makeRes()
|
|
128
|
+
await routes.get('/api/turn-notify/sounds')(makeReq('GET'), res)
|
|
129
|
+
assert.equal(res.status, 200)
|
|
130
|
+
assert.deepEqual(res.body.sounds, [{ id: 'a', ext: 'wav', name: '别名甲' }])
|
|
131
|
+
await writeFile(join(soundsDir, 'index.json'), '{broken')
|
|
132
|
+
const degraded = makeRes()
|
|
133
|
+
await routes.get('/api/turn-notify/sounds')(makeReq('GET'), degraded)
|
|
134
|
+
assert.equal(degraded.status, 200)
|
|
135
|
+
assert.deepEqual(degraded.body.sounds, [{ id: 'a', ext: 'wav', name: null }])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('sounds 列表 405 与错误兜底', async () => {
|
|
139
|
+
await resetSounds()
|
|
140
|
+
const { ctx, routes } = makeCtx()
|
|
141
|
+
apply(ctx)
|
|
142
|
+
const res = makeRes()
|
|
143
|
+
await routes.get('/api/turn-notify/sounds')(makeReq('POST'), res)
|
|
144
|
+
assert.equal(res.status, 405)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('sound 读取:命中返回音频与 MIME,未命中 404,fs 故障 500', async () => {
|
|
148
|
+
await resetSounds()
|
|
149
|
+
await seedSound('x.wav')
|
|
150
|
+
// 同名目录:列表命中但读取报 EISDIR,锚定错误归一的 fs 类 500 分支
|
|
151
|
+
await mkdir(join(soundsDir, 'd.wav'))
|
|
152
|
+
const { ctx, routes } = makeCtx()
|
|
153
|
+
apply(ctx)
|
|
154
|
+
const hit = makeRes()
|
|
155
|
+
await routes.get('/api/turn-notify/sound')(makeReq('GET', undefined, undefined, '/api/turn-notify/sound?id=x'), hit)
|
|
156
|
+
assert.equal(hit.status, 200)
|
|
157
|
+
assert.equal(hit.headers['content-type'], 'audio/wav')
|
|
158
|
+
assert.deepEqual(hit.raw, WAV_BYTES)
|
|
159
|
+
const miss = makeRes()
|
|
160
|
+
await routes.get('/api/turn-notify/sound')(makeReq('GET', undefined, undefined, '/api/turn-notify/sound?id=missing'), miss)
|
|
161
|
+
assert.equal(miss.status, 404)
|
|
162
|
+
const fsFailure = makeRes()
|
|
163
|
+
await routes.get('/api/turn-notify/sound')(makeReq('GET', undefined, undefined, '/api/turn-notify/sound?id=d'), fsFailure)
|
|
164
|
+
assert.equal(fsFailure.status, 500)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('sound 删除:文件移除,映射引用同步清理,再次删除 404', async () => {
|
|
168
|
+
await resetSounds()
|
|
169
|
+
await seedSound('gone.wav')
|
|
170
|
+
const { ctx, routes, settingsService } = makeCtx()
|
|
171
|
+
apply(ctx)
|
|
172
|
+
await settingsService.update('turn-notify', { soundMapping: { completed: 'gone', error: 'other' } })
|
|
173
|
+
const res = makeRes()
|
|
174
|
+
await routes.get('/api/turn-notify/sound')(makeReq('DELETE', undefined, undefined, '/api/turn-notify/sound?id=gone'), res)
|
|
175
|
+
assert.equal(res.status, 200)
|
|
176
|
+
assert.deepEqual(await listNames(), [])
|
|
177
|
+
const stored = settingsService.get('turn-notify')
|
|
178
|
+
// 深合并存储语义:清除为置 null,读侧 resolvedConfig 过滤
|
|
179
|
+
assert.equal(stored.soundMapping.completed, null)
|
|
180
|
+
assert.equal(stored.soundMapping.error, 'other')
|
|
181
|
+
const again = makeRes()
|
|
182
|
+
await routes.get('/api/turn-notify/sound')(makeReq('DELETE', undefined, undefined, '/api/turn-notify/sound?id=gone'), again)
|
|
183
|
+
assert.equal(again.status, 404)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('upload 主路径:内容寻址落盘,非法扩展与超限体拒绝,同内容幂等', async () => {
|
|
187
|
+
await resetSounds()
|
|
188
|
+
const { ctx, routes } = makeCtx()
|
|
189
|
+
apply(ctx)
|
|
190
|
+
const wav = Buffer.alloc(64, 7)
|
|
191
|
+
const upload = routes.get('/api/turn-notify/upload')
|
|
192
|
+
const bodyReq = (name, bytes) => {
|
|
193
|
+
const req = new EventEmitter()
|
|
194
|
+
req.method = 'POST'
|
|
195
|
+
req.url = '/api/turn-notify/upload?name=' + encodeURIComponent(name)
|
|
196
|
+
req.headers = { host: '127.0.0.1:3080' }
|
|
197
|
+
req.destroy = () => {}
|
|
198
|
+
process.nextTick(() => {
|
|
199
|
+
req.emit('data', bytes)
|
|
200
|
+
req.readableEnded = true
|
|
201
|
+
req.emit('end')
|
|
202
|
+
})
|
|
203
|
+
return req
|
|
204
|
+
}
|
|
205
|
+
const ok = makeRes()
|
|
206
|
+
await upload(bodyReq('clip.wav', wav), ok)
|
|
207
|
+
assert.equal(ok.status, 200)
|
|
208
|
+
const expectedId = 'snd-' + createHash('sha256').update(wav).digest('hex').slice(0, 16)
|
|
209
|
+
assert.equal(ok.body.id, expectedId)
|
|
210
|
+
const names = await listNames()
|
|
211
|
+
assert.deepEqual(names, [expectedId + '.wav'])
|
|
212
|
+
assert.deepEqual(await readFile(join(soundsDir, expectedId + '.wav')), wav)
|
|
213
|
+
// 同内容重传:幂等返回既有 id,不产生第二份文件
|
|
214
|
+
const again = makeRes()
|
|
215
|
+
await upload(bodyReq('clip.wav', wav), again)
|
|
216
|
+
assert.equal(again.status, 200)
|
|
217
|
+
assert.equal(again.body.id, expectedId)
|
|
218
|
+
assert.deepEqual(await listNames(), [expectedId + '.wav'])
|
|
219
|
+
// 非法扩展名 400
|
|
220
|
+
const badExt = makeRes()
|
|
221
|
+
await upload(bodyReq('a.txt', Buffer.from('x')), badExt)
|
|
222
|
+
assert.equal(badExt.status, 400)
|
|
223
|
+
// 超限体 400
|
|
224
|
+
const tooBig = makeRes()
|
|
225
|
+
await upload(bodyReq('big.wav', Buffer.alloc(2 * 1024 * 1024 + 1)), tooBig)
|
|
226
|
+
assert.equal(tooBig.status, 400)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('mapping 写读链路:写映射生效,未知分类与未知音效 400,空 id 清除', async () => {
|
|
230
|
+
await resetSounds()
|
|
231
|
+
await seedSound('snd-1.wav')
|
|
232
|
+
const { ctx, routes, settingsService } = makeCtx()
|
|
233
|
+
apply(ctx)
|
|
234
|
+
const handler = routes.get('/api/turn-notify/mapping')
|
|
235
|
+
const write = makeRes()
|
|
236
|
+
await handler(makeReq('POST', { category: 'completed', id: 'snd-1' }, JSON_HEADERS, '/api/turn-notify/mapping'), write)
|
|
237
|
+
assert.equal(write.status, 200)
|
|
238
|
+
assert.deepEqual(write.body.soundMapping, { completed: 'snd-1' })
|
|
239
|
+
assert.equal(settingsService.get('turn-notify').soundMapping.completed, 'snd-1')
|
|
240
|
+
// 投影路由带回 mapping
|
|
241
|
+
const projection = makeRes()
|
|
242
|
+
await routes.get('/api/turn-notify/projection')(makeReq('GET', undefined, undefined, '/api/turn-notify/projection'), projection)
|
|
243
|
+
assert.deepEqual(projection.body.soundMapping, { completed: 'snd-1' })
|
|
244
|
+
const unknownCategory = makeRes()
|
|
245
|
+
await handler(makeReq('POST', { category: 'nope', id: 'snd-1' }, JSON_HEADERS, '/api/turn-notify/mapping'), unknownCategory)
|
|
246
|
+
assert.equal(unknownCategory.status, 400)
|
|
247
|
+
const unknownSound = makeRes()
|
|
248
|
+
await handler(makeReq('POST', { category: 'completed', id: 'snd-404' }, JSON_HEADERS, '/api/turn-notify/mapping'), unknownSound)
|
|
249
|
+
assert.equal(unknownSound.status, 400)
|
|
250
|
+
const clear = makeRes()
|
|
251
|
+
await handler(makeReq('POST', { category: 'completed', id: '' }, JSON_HEADERS, '/api/turn-notify/mapping'), clear)
|
|
252
|
+
assert.equal(clear.status, 200)
|
|
253
|
+
assert.deepEqual(clear.body.soundMapping, {})
|
|
254
|
+
// 深合并存储语义:清除为置 null;真实宿主 settings 经 schema 归一落为空串,
|
|
255
|
+
// 两者均被读侧 resolvedConfig 的非空字符串过滤挡下,mock 原样存 null
|
|
256
|
+
assert.equal(settingsService.get('turn-notify').soundMapping.completed, null)
|
|
257
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// 开关样式守卫 BDD:状态选择器回退为裸 input 会静默隐藏 label 内其他 input,
|
|
2
|
+
// 属无报错的 UI 损坏,故对源文本做静态断言锁死结构
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import test from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
|
|
9
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
10
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
11
|
+
|
|
12
|
+
test('switch 隐藏规则以 input[type="checkbox"] 精确匹配', () => {
|
|
13
|
+
assert.match(source, /\.tn-switch input\[type="checkbox"\] \{ position:absolute/)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('switch 状态选择器禁止裸 input 锚定(防误伤 label 内其他 input)', () => {
|
|
17
|
+
const bare = source.match(/\.tn-switch input:(?!\[type)[a-z-]+/g)
|
|
18
|
+
assert.equal(bare, null, `裸 input 状态选择器: ${bare}`)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('checkbox 仅允许出现在 switchToggle 工厂内,禁止裸 checkbox 直出', () => {
|
|
22
|
+
const occurrences = [...source.matchAll(/type: 'checkbox'/g)].map((match) => match.index)
|
|
23
|
+
assert.equal(occurrences.length, 1, `checkbox 字面量出现 ${occurrences.length} 次`)
|
|
24
|
+
const factoryStart = source.indexOf('function switchToggle')
|
|
25
|
+
const factoryEnd = source.indexOf('}', source.indexOf('__thumb', factoryStart))
|
|
26
|
+
assert.ok(occurrences[0] > factoryStart && occurrences[0] < factoryEnd, 'checkbox 字面量不在 switchToggle 工厂内')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('switchToggle 产出顺序为 input 在前 track 在后', () => {
|
|
30
|
+
const factory = source.slice(source.indexOf('function switchToggle'))
|
|
31
|
+
assert.ok(factory.indexOf("h('input'") < factory.indexOf('tn-switch__track'), '工厂内 input 必须先于 track')
|
|
32
|
+
})
|