@mzzsfy/dsh-usage-panel 0.4.3
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 +132 -0
- package/cordis.patch.yml +8 -0
- package/package.json +49 -0
- package/src/client.js +1424 -0
- package/src/history.mjs +117 -0
- package/src/historyStore.mjs +61 -0
- package/src/index.js +777 -0
- package/src/notify.mjs +424 -0
- package/src/parsers.mjs +327 -0
- package/src/poller.mjs +60 -0
- package/test/client-id.test.mjs +16 -0
- package/test/history.test.mjs +170 -0
- package/test/historyStore.test.mjs +107 -0
- package/test/notify-poll.test.mjs +31 -0
- package/test/notify.route.test.mjs +447 -0
- package/test/notify.test.mjs +498 -0
- package/test/parity.test.mjs +119 -0
- package/test/parsers.test.mjs +286 -0
- package/test/poller.test.mjs +88 -0
- package/test/route.test.mjs +171 -0
- package/test/switch-guard.test.mjs +32 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// 页内通知长轮询 BDD:client 半区以顺序长轮询消费通知投影——
|
|
2
|
+
// 任何时刻至多一条在途请求,失败指数退避,HMR/闭包重建重复装载时
|
|
3
|
+
// 新代首挂 abort 旧代长轮询再启新代——旧代不滞留、不叠加、不告警。
|
|
4
|
+
// 轮询段运行于 factory 闭包内(IO),守卫以源码契约断言锁定形态。
|
|
5
|
+
|
|
6
|
+
import { test } from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
import { join, dirname } from 'node:path'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
13
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
14
|
+
|
|
15
|
+
test('代际令牌承载 AbortController:新代启动前中止旧代长轮询', () => {
|
|
16
|
+
assert.match(source, /window\[KEY_POLL_TOKEN\] instanceof AbortController/, '缺少旧代 AbortController 判定')
|
|
17
|
+
assert.match(source, /window\[KEY_POLL_TOKEN\]\.abort\(\)/, '缺少旧代长轮询中止')
|
|
18
|
+
assert.match(source, /window\[KEY_POLL_TOKEN\] = notifyController/, '控制器未交接给令牌')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('消费形态为顺序长轮询:setInterval 短轮询不得回归', () => {
|
|
22
|
+
assert.ok(!source.includes('setInterval'), '残留 interval 短轮询形态')
|
|
23
|
+
assert.match(source, /AbortSignal\.timeout\(NOTIFY_REQUEST_TIMEOUT_MS\)/, '长轮询请求缺少超时上限')
|
|
24
|
+
assert.match(source, /\?cursor=' \+ notifyCursor/, '长轮询缺少续传游标')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('失败退避与游标防呆:异常响应不退化成紧密首拉循环', () => {
|
|
28
|
+
assert.match(source, /backoffMs = NOTIFY_RETRY_MIN_MS/, '成功后未重置退避基数')
|
|
29
|
+
assert.match(source, /backoffMs = Math\.min\(backoffMs \* 2, NOTIFY_RETRY_MAX_MS\)/, '缺少指数退避')
|
|
30
|
+
assert.match(source, /typeof payload\.version !== 'number'\) return false/, '版本缺失未按失败退避')
|
|
31
|
+
})
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// 通知接线路由测试:查询评估入投影 / 凭据脱敏 / 配置校验 / 真实投递结果 / 账号 notify 归一。
|
|
2
|
+
// 数据目录经 env 注入临时路径,须在 import src/index.js 之前设置(模块顶层求值)。
|
|
3
|
+
|
|
4
|
+
import { test, after } 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
|
+
const tempDir = await mkdtemp(join(tmpdir(), 'usage-notify-route-'))
|
|
12
|
+
process.env.DSH_USAGE_PANEL_DATA_DIR = tempDir
|
|
13
|
+
|
|
14
|
+
const { apply } = await import('../src/index.js')
|
|
15
|
+
|
|
16
|
+
after(async () => {
|
|
17
|
+
delete process.env.DSH_USAGE_PANEL_DATA_DIR
|
|
18
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const NOTIFY_ON = { notify: { enabled: true, quotaThresholdPct: 50 } }
|
|
22
|
+
|
|
23
|
+
function makeCtx({ settingsValue = {}, dshIm = undefined } = {}) {
|
|
24
|
+
let value = settingsValue
|
|
25
|
+
const routes = new Map()
|
|
26
|
+
const settingsService = {
|
|
27
|
+
register() {},
|
|
28
|
+
get: () => value,
|
|
29
|
+
update: async (_ns, patch) => {
|
|
30
|
+
value = { ...value, ...patch }
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
const ctx = {
|
|
34
|
+
get(name) {
|
|
35
|
+
if (name === 'settings') return settingsService
|
|
36
|
+
if (name === 'dshIm') return dshIm
|
|
37
|
+
return undefined
|
|
38
|
+
},
|
|
39
|
+
effect(fn) {
|
|
40
|
+
fn()
|
|
41
|
+
},
|
|
42
|
+
inject(_deps, fn) {
|
|
43
|
+
fn({ settings: settingsService, interval: () => {} })
|
|
44
|
+
},
|
|
45
|
+
webServer: {
|
|
46
|
+
register(route) {
|
|
47
|
+
routes.set(route.path, route.handler)
|
|
48
|
+
return () => routes.delete(route.path)
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
return { ctx, routes }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeReq(method, body, extraHeaders = {}) {
|
|
56
|
+
const req = new EventEmitter()
|
|
57
|
+
req.method = method
|
|
58
|
+
req.headers = { host: 'localhost:3000', ...extraHeaders }
|
|
59
|
+
if (body !== undefined) {
|
|
60
|
+
if (req.headers['content-type'] === undefined) req.headers['content-type'] = 'application/json'
|
|
61
|
+
const text = typeof body === 'string' ? body : JSON.stringify(body)
|
|
62
|
+
process.nextTick(() => {
|
|
63
|
+
req.emit('data', Buffer.from(text))
|
|
64
|
+
req.emit('end')
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
return req
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function call(routes, path, req) {
|
|
71
|
+
if (req.url === undefined) req.url = path
|
|
72
|
+
const res = { status: null, payload: null }
|
|
73
|
+
res.writeHead = (status) => { res.status = status }
|
|
74
|
+
res.end = (text) => { res.payload = text ? JSON.parse(text) : null }
|
|
75
|
+
await routes.get(path.split('?')[0])(req, res)
|
|
76
|
+
return res
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// kimi 形态响应:5 小时窗 utilization 95,7 天窗 75。
|
|
80
|
+
const KIMI_BODY = {
|
|
81
|
+
limits: [{ detail: { limit: 100, remaining: 5, resetTime: 'T1' } }],
|
|
82
|
+
usage: { limit: 200, remaining: 50, resetTime: 'T2' },
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function saveKimiAccount(routes) {
|
|
86
|
+
return call(routes, '/api/usage-panel/accounts', makeReq('POST', {
|
|
87
|
+
accounts: [{ id: 'acct-1', name: '账号K', type: 'kimi', apiKey: 'sk-test' }],
|
|
88
|
+
}))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
test('评估接线: 查询成功后越阈窗口产生事件并进入投影', async () => {
|
|
92
|
+
// Given 全局通知启用阈值 50, 已保存 kimi 账号, 平台接口桩返回超阈读数
|
|
93
|
+
const { ctx, routes } = makeCtx({ settingsValue: NOTIFY_ON })
|
|
94
|
+
apply(ctx)
|
|
95
|
+
const original = globalThis.fetch
|
|
96
|
+
globalThis.fetch = async (url) => {
|
|
97
|
+
if (String(url).includes('/v1/usages')) return { ok: true, text: async () => JSON.stringify(KIMI_BODY) }
|
|
98
|
+
throw new Error('unexpected url: ' + url)
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
await saveKimiAccount(routes)
|
|
102
|
+
// When 手动查询该账号
|
|
103
|
+
const queryRes = await call(routes, '/api/usage-panel/query', makeReq('POST', { id: 'acct-1' }))
|
|
104
|
+
// Then 查询成功
|
|
105
|
+
assert.equal(queryRes.status, 200)
|
|
106
|
+
assert.equal(queryRes.payload.ok, true)
|
|
107
|
+
// When 读取通知投影
|
|
108
|
+
const notifyRes = await call(routes, '/api/usage-panel/notifications', makeReq('GET'))
|
|
109
|
+
// Then 两个越阈窗口各产生一条 quota 事件
|
|
110
|
+
assert.equal(notifyRes.status, 200)
|
|
111
|
+
assert.equal(notifyRes.payload.units.length, 2)
|
|
112
|
+
assert.equal(notifyRes.payload.units[0].kind, 'quota')
|
|
113
|
+
assert.match(notifyRes.payload.units[0].text, /账号K/)
|
|
114
|
+
} finally {
|
|
115
|
+
globalThis.fetch = original
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('评估接线: 全局通知关闭时查询不产生事件', async () => {
|
|
120
|
+
// Given 通知总开关关闭
|
|
121
|
+
const { ctx, routes } = makeCtx({ settingsValue: { notify: { enabled: false, quotaThresholdPct: 50 } } })
|
|
122
|
+
apply(ctx)
|
|
123
|
+
const original = globalThis.fetch
|
|
124
|
+
globalThis.fetch = async () => ({ ok: true, text: async () => JSON.stringify(KIMI_BODY) })
|
|
125
|
+
try {
|
|
126
|
+
await saveKimiAccount(routes)
|
|
127
|
+
await call(routes, '/api/usage-panel/query', makeReq('POST', { id: 'acct-1' }))
|
|
128
|
+
const notifyRes = await call(routes, '/api/usage-panel/notifications', makeReq('GET'))
|
|
129
|
+
// Then 投影为空
|
|
130
|
+
assert.equal(notifyRes.payload.units.length, 0)
|
|
131
|
+
} finally {
|
|
132
|
+
globalThis.fetch = original
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('评估接线: custom 账号 extract 规则驱动 balance 读数全链路(分发/评估/历史)', async () => {
|
|
137
|
+
// Given 通知启用且余额阈值 50;custom 账号 extract.remaining 指向响应字段,值 30 越阈
|
|
138
|
+
const { ctx, routes } = makeCtx({ settingsValue: { notify: { enabled: true, balanceThreshold: 50 } } })
|
|
139
|
+
apply(ctx)
|
|
140
|
+
const original = globalThis.fetch
|
|
141
|
+
globalThis.fetch = async (url) => {
|
|
142
|
+
// URL 负向校验:锁定请求端点确实来自 account.custom.url
|
|
143
|
+
if (String(url) === 'https://gw.example.com/quota') return { ok: true, text: async () => JSON.stringify({ data: { left: 30, budget: 100 } }) }
|
|
144
|
+
throw new Error('unexpected url: ' + url)
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
await call(routes, '/api/usage-panel/accounts', makeReq('POST', {
|
|
148
|
+
accounts: [{
|
|
149
|
+
id: 'acct-c',
|
|
150
|
+
name: '自定义',
|
|
151
|
+
type: 'custom',
|
|
152
|
+
custom: {
|
|
153
|
+
url: 'https://gw.example.com/quota',
|
|
154
|
+
extract: { remaining: 'data.left', maxBudget: 'data.budget', unit: 'CNY' },
|
|
155
|
+
},
|
|
156
|
+
}],
|
|
157
|
+
}))
|
|
158
|
+
// When 手动查询
|
|
159
|
+
const queryRes = await call(routes, '/api/usage-panel/query', makeReq('POST', { id: 'acct-c' }))
|
|
160
|
+
// Then 查询成功且读数形态为 balance 联合(extract 真实进入解析器)
|
|
161
|
+
assert.equal(queryRes.status, 200)
|
|
162
|
+
assert.equal(queryRes.payload.ok, true)
|
|
163
|
+
const reading = queryRes.payload.account.last.reading
|
|
164
|
+
assert.equal(reading.kind, 'balance')
|
|
165
|
+
assert.deepEqual(reading.entries, [{ currency: 'CNY', remaining: 30, total: 100, used: null }])
|
|
166
|
+
// And 余额越阈产生 balance 事件(评估链按 kind 判别生效)
|
|
167
|
+
const notifyRes = await call(routes, '/api/usage-panel/notifications', makeReq('GET'))
|
|
168
|
+
assert.equal(notifyRes.payload.units.length, 1)
|
|
169
|
+
assert.equal(notifyRes.payload.units[0].kind, 'balance')
|
|
170
|
+
assert.match(notifyRes.payload.units[0].text, /自定义/)
|
|
171
|
+
// And 历史采样落 balance 序列(采样链按 kind 判别生效)
|
|
172
|
+
const historyRes = await call(routes, '/api/usage-panel/history', makeReq('GET'))
|
|
173
|
+
assert.ok(historyRes.payload.sequences['acct-c:balance'], 'balance 序列已落点')
|
|
174
|
+
} finally {
|
|
175
|
+
globalThis.fetch = original
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('notify-config: GET 不回显 webhookUrl 原文, 仅回是否已配置', async () => {
|
|
180
|
+
// Given 已配置 webhook 的 settings
|
|
181
|
+
const { ctx, routes } = makeCtx({ settingsValue: { notify: { enabled: true, webhookUrl: 'https://hooks.example.com/private' } } })
|
|
182
|
+
apply(ctx)
|
|
183
|
+
// When 读取通知配置
|
|
184
|
+
const res = await call(routes, '/api/usage-panel/notify-config', makeReq('GET'))
|
|
185
|
+
// Then 原文不出主机
|
|
186
|
+
assert.equal(res.status, 200)
|
|
187
|
+
assert.equal(res.payload.notify.webhookUrl, undefined)
|
|
188
|
+
assert.equal(res.payload.notify.webhookConfigured, true)
|
|
189
|
+
assert.equal(res.payload.imAvailable, false)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('notify-config: 非法补丁 400, 合法补丁合并生效', async () => {
|
|
193
|
+
// Given 合法 ctx
|
|
194
|
+
const { ctx, routes } = makeCtx({ settingsValue: { notify: { enabled: true } } })
|
|
195
|
+
apply(ctx)
|
|
196
|
+
// When 提交越界阈值
|
|
197
|
+
const bad = await call(routes, '/api/usage-panel/notify-config', makeReq('POST', { quotaThresholdPct: 0 }))
|
|
198
|
+
// Then 拒绝
|
|
199
|
+
assert.equal(bad.status, 400)
|
|
200
|
+
// When 提交合法补丁(仅改阈值与余额阈值)
|
|
201
|
+
const good = await call(routes, '/api/usage-panel/notify-config', makeReq('POST', { quotaThresholdPct: 80, balanceThreshold: 20 }))
|
|
202
|
+
// Then 生效且未提供的键保持原值
|
|
203
|
+
assert.equal(good.status, 200)
|
|
204
|
+
const view = await call(routes, '/api/usage-panel/notify-config', makeReq('GET'))
|
|
205
|
+
assert.equal(view.payload.notify.quotaThresholdPct, 80)
|
|
206
|
+
assert.equal(view.payload.notify.balanceThreshold, 20)
|
|
207
|
+
assert.equal(view.payload.notify.enabled, true)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('test-webhook: 返回真实投递结果而非谎报成功', async () => {
|
|
211
|
+
// Given webhook 已配置, fetch 桩标记调用
|
|
212
|
+
const { ctx, routes } = makeCtx({ settingsValue: { notify: { enabled: true, webhookUrl: 'https://hooks.example.com/hook' } } })
|
|
213
|
+
apply(ctx)
|
|
214
|
+
const original = globalThis.fetch
|
|
215
|
+
let captured = null
|
|
216
|
+
globalThis.fetch = async (url, options) => {
|
|
217
|
+
captured = { url: String(url), body: JSON.parse(options.body) }
|
|
218
|
+
return { ok: true, status: 200 }
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
// When 触发测试投递
|
|
222
|
+
const res = await call(routes, '/api/usage-panel/test-webhook', makeReq('POST', {}, { origin: 'http://localhost:3000' }))
|
|
223
|
+
// Then 返回真实结果且 payload 发往配置地址
|
|
224
|
+
assert.equal(res.status, 200)
|
|
225
|
+
assert.equal(res.payload.ok, true)
|
|
226
|
+
assert.match(captured.url, /hooks\.example\.com\/hook/)
|
|
227
|
+
assert.match(captured.body.text, /测试/)
|
|
228
|
+
} finally {
|
|
229
|
+
globalThis.fetch = original
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('test-im: dsh-im 缺失如实降级, 在场时逐目标返回结果', async () => {
|
|
234
|
+
// Given dsh-im 未安装
|
|
235
|
+
const off = makeCtx({ settingsValue: NOTIFY_ON })
|
|
236
|
+
apply(off.ctx)
|
|
237
|
+
const offRes = await call(off.routes, '/api/usage-panel/test-im', makeReq('POST', {}, { origin: 'http://localhost:3000' }))
|
|
238
|
+
// Then 返回失败并注明未安装
|
|
239
|
+
assert.equal(offRes.payload.ok, false)
|
|
240
|
+
assert.match(offRes.payload.detail, /未安装/)
|
|
241
|
+
|
|
242
|
+
// Given dsh-im 在场且已配置一个投递目标
|
|
243
|
+
const sent = []
|
|
244
|
+
const dshIm = { send: async (botId, targetId, text) => { sent.push({ botId, targetId, text }) } }
|
|
245
|
+
const on = makeCtx({ settingsValue: { notify: { enabled: true, imTargets: [{ botId: 'wx_a', targetId: 'owner' }] } }, dshIm })
|
|
246
|
+
apply(on.ctx)
|
|
247
|
+
// When 触发测试投递
|
|
248
|
+
const onRes = await call(on.routes, '/api/usage-panel/test-im', makeReq('POST', {}, { origin: 'http://localhost:3000' }))
|
|
249
|
+
// Then 真实送达且结果逐目标可见
|
|
250
|
+
assert.equal(onRes.payload.ok, true)
|
|
251
|
+
assert.equal(sent.length, 1)
|
|
252
|
+
assert.equal(sent[0].botId, 'wx_a')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('账号保存: notify 覆盖字段归一, 非法键剔除', async () => {
|
|
256
|
+
// Given 带混合 notify 字段与非法值的账号
|
|
257
|
+
const { ctx, routes } = makeCtx({ settingsValue: NOTIFY_ON })
|
|
258
|
+
apply(ctx)
|
|
259
|
+
// When 保存
|
|
260
|
+
const res = await call(routes, '/api/usage-panel/accounts', makeReq('POST', {
|
|
261
|
+
accounts: [{ id: 'acct-1', name: '账号K', type: 'kimi', apiKey: 'sk-test', notify: { quotaThresholdPct: 70, junk: 1, resetNotice: 'x' } }],
|
|
262
|
+
}))
|
|
263
|
+
// Then 响应与落盘账号仅保留值域合法的覆盖键
|
|
264
|
+
assert.equal(res.status, 200)
|
|
265
|
+
assert.deepEqual(res.payload.accounts[0].notify, { quotaThresholdPct: 70 })
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
test('沿触发状态持久化: 查询后账号携带 notifyState, 再次 GET accounts 可见', async () => {
|
|
269
|
+
// Given 通知启用且已保存账号
|
|
270
|
+
const { ctx, routes } = makeCtx({ settingsValue: NOTIFY_ON })
|
|
271
|
+
apply(ctx)
|
|
272
|
+
const original = globalThis.fetch
|
|
273
|
+
globalThis.fetch = async () => ({ ok: true, text: async () => JSON.stringify(KIMI_BODY) })
|
|
274
|
+
try {
|
|
275
|
+
await saveKimiAccount(routes)
|
|
276
|
+
// When 查询一次后重新拉取账号
|
|
277
|
+
await call(routes, '/api/usage-panel/query', makeReq('POST', { id: 'acct-1' }))
|
|
278
|
+
const res = await call(routes, '/api/usage-panel/accounts', makeReq('GET'))
|
|
279
|
+
// Then 账号携带沿触发状态(窗口基线), 重启后不重发的锚点
|
|
280
|
+
const account = res.payload.accounts[0]
|
|
281
|
+
assert.ok(account.notifyState && account.notifyState.windows)
|
|
282
|
+
assert.equal(account.notifyState.windows['5小时'].armed, false)
|
|
283
|
+
} finally {
|
|
284
|
+
globalThis.fetch = original
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('编辑账号保留沿触发状态与读数: 提交对象缺 last/notifyState 时回填旧值', async () => {
|
|
289
|
+
// Given 已查询产生沿触发状态的账号
|
|
290
|
+
const { ctx, routes } = makeCtx({ settingsValue: NOTIFY_ON })
|
|
291
|
+
apply(ctx)
|
|
292
|
+
const original = globalThis.fetch
|
|
293
|
+
globalThis.fetch = async () => ({ ok: true, text: async () => JSON.stringify(KIMI_BODY) })
|
|
294
|
+
try {
|
|
295
|
+
await saveKimiAccount(routes)
|
|
296
|
+
await call(routes, '/api/usage-panel/query', makeReq('POST', { id: 'acct-1' }))
|
|
297
|
+
// When 仅改名重新保存(客户端提交对象不含 last/notifyState)
|
|
298
|
+
const saveRes = await call(routes, '/api/usage-panel/accounts', makeReq('POST', {
|
|
299
|
+
accounts: [{ id: 'acct-1', name: '改名账号', type: 'kimi', apiKey: '' }],
|
|
300
|
+
}))
|
|
301
|
+
// Then 旧 Key/读数/沿触发状态全部保留
|
|
302
|
+
assert.equal(saveRes.status, 200)
|
|
303
|
+
const account = saveRes.payload.accounts[0]
|
|
304
|
+
assert.equal(account.name, '改名账号')
|
|
305
|
+
assert.equal(account.hasKey, true)
|
|
306
|
+
assert.ok(account.last && account.last.ok === true)
|
|
307
|
+
assert.equal(account.notifyState.windows['5小时'].armed, false)
|
|
308
|
+
} finally {
|
|
309
|
+
globalThis.fetch = original
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test('toast 开关: 关闭后事件不进投影(出站通道不受影响无直接断言)', async () => {
|
|
314
|
+
// Given 通知启用但页内 toast 关闭
|
|
315
|
+
const settings = { notify: { enabled: true, quotaThresholdPct: 50, toast: false } }
|
|
316
|
+
const { ctx, routes } = makeCtx({ settingsValue: settings })
|
|
317
|
+
apply(ctx)
|
|
318
|
+
const original = globalThis.fetch
|
|
319
|
+
globalThis.fetch = async () => ({ ok: true, text: async () => JSON.stringify(KIMI_BODY) })
|
|
320
|
+
try {
|
|
321
|
+
await saveKimiAccount(routes)
|
|
322
|
+
await call(routes, '/api/usage-panel/query', makeReq('POST', { id: 'acct-1' }))
|
|
323
|
+
const res = await call(routes, '/api/usage-panel/notifications', makeReq('GET'))
|
|
324
|
+
// Then 投影为空
|
|
325
|
+
assert.equal(res.payload.units.length, 0)
|
|
326
|
+
} finally {
|
|
327
|
+
globalThis.fetch = original
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test('写路由守卫: 跨源 403, 非 JSON 400', async () => {
|
|
332
|
+
// Given 合法 ctx
|
|
333
|
+
const { ctx, routes } = makeCtx({ settingsValue: NOTIFY_ON })
|
|
334
|
+
apply(ctx)
|
|
335
|
+
// When 跨源 POST notify-config
|
|
336
|
+
const cross = await call(routes, '/api/usage-panel/notify-config', makeReq('POST', '{}', { origin: 'https://evil.example' }))
|
|
337
|
+
// Then 403
|
|
338
|
+
assert.equal(cross.status, 403)
|
|
339
|
+
// When 同源但 text/plain 简单请求
|
|
340
|
+
const plain = await call(routes, '/api/usage-panel/notify-config', makeReq('POST', '{}', {
|
|
341
|
+
origin: 'http://localhost:3000',
|
|
342
|
+
'content-type': 'text/plain',
|
|
343
|
+
}))
|
|
344
|
+
// Then 400
|
|
345
|
+
assert.equal(plain.status, 400)
|
|
346
|
+
// When 同源 JSON 合法请求
|
|
347
|
+
const ok = await call(routes, '/api/usage-panel/notify-config', makeReq('POST', { resetNotice: false }, { origin: 'http://localhost:3000' }))
|
|
348
|
+
// Then 通过
|
|
349
|
+
assert.equal(ok.status, 200)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
test('im-targets: dsh-im 缺失 503, botId 非法 400, 在场时字段裁剪返回', async () => {
|
|
353
|
+
// Given dsh-im 未安装
|
|
354
|
+
const off = makeCtx({ settingsValue: NOTIFY_ON })
|
|
355
|
+
apply(off.ctx)
|
|
356
|
+
const offRes = await call(off.routes, '/api/usage-panel/im-targets?botId=wx_a', makeReq('GET'))
|
|
357
|
+
assert.equal(offRes.status, 503)
|
|
358
|
+
|
|
359
|
+
// Given dsh-im 在场
|
|
360
|
+
const dshIm = {
|
|
361
|
+
listTargets: async () => [{ targetId: 'owner', name: '机主', kind: 'wechat', route: 'native-xyz' }],
|
|
362
|
+
}
|
|
363
|
+
const on = makeCtx({ settingsValue: NOTIFY_ON, dshIm })
|
|
364
|
+
apply(on.ctx)
|
|
365
|
+
// When botId 非法
|
|
366
|
+
const bad = await call(on.routes, '/api/usage-panel/im-targets?botId=', makeReq('GET'))
|
|
367
|
+
// Then 400
|
|
368
|
+
assert.equal(bad.status, 400)
|
|
369
|
+
// When 合法查询
|
|
370
|
+
const good = await call(on.routes, '/api/usage-panel/im-targets?botId=wx_a', makeReq('GET'))
|
|
371
|
+
// Then route 字段不出主机
|
|
372
|
+
assert.equal(good.status, 200)
|
|
373
|
+
assert.deepEqual(good.payload.targets, [{ targetId: 'owner', name: '机主', kind: 'wechat' }])
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
function notifyRequest(query) {
|
|
377
|
+
const res = { status: null, payload: null }
|
|
378
|
+
res.writeHead = (status) => { res.status = status }
|
|
379
|
+
res.end = (text) => { res.payload = text ? JSON.parse(text) : null }
|
|
380
|
+
const req = makeReq('GET')
|
|
381
|
+
req.url = '/api/usage-panel/notifications' + query
|
|
382
|
+
return { req, res }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// 查询一次产生越阈事件:复用 kimi 桩形态,事件入投影即递增版本。
|
|
386
|
+
// 每次独立账号 id:账号保存对缺省 last/notifyState 回填旧值,复用 id 会继承
|
|
387
|
+
// 已解除武装的沿触发状态导致不再越阈
|
|
388
|
+
let longpollSeq = 0
|
|
389
|
+
async function produceNotifyEvent(routes) {
|
|
390
|
+
longpollSeq += 1
|
|
391
|
+
const original = globalThis.fetch
|
|
392
|
+
globalThis.fetch = async () => ({ ok: true, text: async () => JSON.stringify(KIMI_BODY) })
|
|
393
|
+
try {
|
|
394
|
+
await call(routes, '/api/usage-panel/accounts', makeReq('POST', {
|
|
395
|
+
accounts: [{ id: 'acct-lp-' + longpollSeq, name: '账号长轮询' + longpollSeq, type: 'kimi', apiKey: 'sk-test' }],
|
|
396
|
+
}))
|
|
397
|
+
await call(routes, '/api/usage-panel/query', makeReq('POST', { id: 'acct-lp-' + longpollSeq }))
|
|
398
|
+
} finally {
|
|
399
|
+
globalThis.fetch = original
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
test('长轮询: 无 cursor 首拉立即返回空投影与当前版本', async () => {
|
|
404
|
+
// Given 通知启用且投影为空
|
|
405
|
+
const { ctx, routes } = makeCtx({ settingsValue: NOTIFY_ON })
|
|
406
|
+
apply(ctx)
|
|
407
|
+
// When 无 cursor 首拉
|
|
408
|
+
const { req, res } = notifyRequest('')
|
|
409
|
+
await routes.get('/api/usage-panel/notifications')(req, res)
|
|
410
|
+
// Then 立即返回且携带版本号
|
|
411
|
+
assert.equal(res.status, 200)
|
|
412
|
+
assert.deepEqual(res.payload.units, [])
|
|
413
|
+
assert.equal(res.payload.version, 0)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test('长轮询: cursor 追平当前版本时挂起, 事件 push 后唤醒并返回新版本', async () => {
|
|
417
|
+
// Given 通知启用
|
|
418
|
+
const { ctx, routes } = makeCtx({ settingsValue: NOTIFY_ON })
|
|
419
|
+
apply(ctx)
|
|
420
|
+
// When 以追平版本 0 发起长轮询
|
|
421
|
+
const { req, res } = notifyRequest('?cursor=0')
|
|
422
|
+
const pending = routes.get('/api/usage-panel/notifications')(req, res)
|
|
423
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
424
|
+
// Then 未唤醒前不响应
|
|
425
|
+
assert.equal(res.payload, null, '未唤醒前不应响应')
|
|
426
|
+
// When 查询产生越阈事件入投影
|
|
427
|
+
await produceNotifyEvent(routes)
|
|
428
|
+
await pending
|
|
429
|
+
// Then 唤醒返回新版本与事件
|
|
430
|
+
assert.equal(res.status, 200)
|
|
431
|
+
assert.equal(res.payload.version, 2, 'push 两条事件应各递增一次版本并唤醒等待者')
|
|
432
|
+
assert.equal(res.payload.units.length, 2)
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
test('长轮询: cursor 落后当前版本时立即返回不挂起', async () => {
|
|
436
|
+
// Given 投影已有事件(版本已前进)
|
|
437
|
+
const { ctx, routes } = makeCtx({ settingsValue: NOTIFY_ON })
|
|
438
|
+
apply(ctx)
|
|
439
|
+
await produceNotifyEvent(routes)
|
|
440
|
+
// When 以落后 cursor 请求
|
|
441
|
+
const { req, res } = notifyRequest('?cursor=0')
|
|
442
|
+
await routes.get('/api/usage-panel/notifications')(req, res)
|
|
443
|
+
// Then 立即返回全量
|
|
444
|
+
assert.equal(res.status, 200)
|
|
445
|
+
assert.equal(res.payload.units.length, 2)
|
|
446
|
+
assert.ok(res.payload.version > 0)
|
|
447
|
+
})
|