@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,286 @@
|
|
|
1
|
+
// 用量面板解析器测试:BDD 场景 1-9(见 docs/feat-usage-panel/plan.md)
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
toStrictNumber,
|
|
7
|
+
getPath,
|
|
8
|
+
extractByRule,
|
|
9
|
+
parseDeepSeek,
|
|
10
|
+
parseOpenRouter,
|
|
11
|
+
parseKimi,
|
|
12
|
+
parseZhipu,
|
|
13
|
+
parseMiniMax,
|
|
14
|
+
parseNewApi,
|
|
15
|
+
extractCustom,
|
|
16
|
+
} from '../src/parsers.mjs'
|
|
17
|
+
|
|
18
|
+
const HOUR_MS = 60 * 60 * 1000
|
|
19
|
+
|
|
20
|
+
test('场景1 DeepSeek 余额:双币种 balance_infos 解析', () => {
|
|
21
|
+
const body = {
|
|
22
|
+
is_available: true,
|
|
23
|
+
balance_infos: [
|
|
24
|
+
{ currency: 'CNY', total_balance: '110.00', granted_balance: '2.00', topped_up_balance: '108.00' },
|
|
25
|
+
{ currency: 'USD', total_balance: '15.30', granted_balance: '0.00', topped_up_balance: '15.30' },
|
|
26
|
+
],
|
|
27
|
+
}
|
|
28
|
+
const reading = parseDeepSeek(body)
|
|
29
|
+
assert.equal(reading.kind, 'balance')
|
|
30
|
+
assert.equal(reading.entries.length, 2)
|
|
31
|
+
assert.deepEqual(reading.entries[0], { currency: 'CNY', total: 110, granted: 2, toppedUp: 108, isAvailable: true })
|
|
32
|
+
assert.deepEqual(reading.entries[1], { currency: 'USD', total: 15.3, granted: 0, toppedUp: 15.3, isAvailable: true })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('场景1b DeepSeek 余额:is_available=false 透传,缺省赠送/充值字段为 null', () => {
|
|
36
|
+
const body = { is_available: false, balance_infos: [{ currency: 'CNY', total_balance: '0.00' }] }
|
|
37
|
+
const reading = parseDeepSeek(body)
|
|
38
|
+
assert.equal(reading.entries[0].isAvailable, false)
|
|
39
|
+
assert.equal(reading.entries[0].granted, null)
|
|
40
|
+
assert.equal(reading.entries[0].toppedUp, null)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('场景2 DeepSeek 错误体:抛出含 message 的错误', () => {
|
|
44
|
+
assert.throws(() => parseDeepSeek({ error: { message: '认证失败', type: 'invalid_request_error' } }), /认证失败/)
|
|
45
|
+
assert.throws(() => parseDeepSeek({}), /balance_infos/)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('场景3 OpenRouter credits:总额/已用/剩余', () => {
|
|
49
|
+
const body = { data: { total_credits: 10, total_usage: 1.58 } }
|
|
50
|
+
const reading = parseOpenRouter(body)
|
|
51
|
+
assert.equal(reading.kind, 'balance')
|
|
52
|
+
assert.deepEqual(reading.entries[0], { currency: 'USD', total: 10, used: 1.58, remaining: 8.42 })
|
|
53
|
+
assert.throws(() => parseOpenRouter({ data: {} }), /total_credits/)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('场景4 Kimi usages:5小时窗口与每周配额', () => {
|
|
57
|
+
const body = {
|
|
58
|
+
limits: [{ detail: { limit: 1500, remaining: 1000, resetTime: '2026-03-01T00:00:00Z' } }],
|
|
59
|
+
usage: { limit: 10000, remaining: 6900, resetTime: '2026-03-02T00:00:00Z' },
|
|
60
|
+
user: { membership: { level: 'LEVEL_3' } },
|
|
61
|
+
}
|
|
62
|
+
const reading = parseKimi(body)
|
|
63
|
+
assert.equal(reading.kind, 'quota')
|
|
64
|
+
assert.equal(reading.windows.length, 2)
|
|
65
|
+
assert.equal(reading.windows[0].label, '5小时')
|
|
66
|
+
assert.equal(reading.windows[0].limit, 1500)
|
|
67
|
+
assert.equal(reading.windows[0].remaining, 1000)
|
|
68
|
+
assert.equal(reading.windows[0].used, 500)
|
|
69
|
+
assert.ok(Math.abs(reading.windows[0].utilization - (500 / 1500) * 100) < 1e-9)
|
|
70
|
+
assert.equal(reading.windows[1].label, '7天')
|
|
71
|
+
assert.equal(reading.membership, 'LEVEL_3')
|
|
72
|
+
assert.throws(() => parseKimi({ limits: [] }), /usage|limits/)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('场景5 智谱 quota:unit 3/6 分窗 + 未分类回填 + 失败体', () => {
|
|
76
|
+
const base = 1740000000000
|
|
77
|
+
const body = {
|
|
78
|
+
success: true,
|
|
79
|
+
data: {
|
|
80
|
+
limits: [
|
|
81
|
+
{ type: 'TOKENS_LIMIT', unit: 6, percentage: 30, nextResetTime: base + 24 * HOUR_MS },
|
|
82
|
+
{ type: 'TOKENS_LIMIT', unit: 3, percentage: 12.5, nextResetTime: base + 2 * HOUR_MS },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
const reading = parseZhipu(body)
|
|
87
|
+
assert.equal(reading.kind, 'quota')
|
|
88
|
+
assert.equal(reading.windows[0].label, '5小时')
|
|
89
|
+
assert.equal(reading.windows[0].utilization, 12.5)
|
|
90
|
+
assert.equal(reading.windows[1].label, '7天')
|
|
91
|
+
assert.equal(reading.windows[1].utilization, 30)
|
|
92
|
+
|
|
93
|
+
const heuristic = parseZhipu({
|
|
94
|
+
success: true,
|
|
95
|
+
data: { limits: [{ type: 'TOKENS_LIMIT', percentage: 5, nextResetTime: base }] },
|
|
96
|
+
})
|
|
97
|
+
assert.equal(heuristic.windows.length, 1)
|
|
98
|
+
assert.equal(heuristic.windows[0].label, '5小时')
|
|
99
|
+
|
|
100
|
+
assert.throws(() => parseZhipu({ success: false, msg: '额度查询失败' }), /额度查询失败/)
|
|
101
|
+
assert.throws(() => parseZhipu({ success: true, data: { limits: [] } }), /额度窗口/)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('场景5b 智谱 Lite:CREDIT_LIMIT 窗口与 currentValue/usage 反推', () => {
|
|
105
|
+
const base = 1740000000000
|
|
106
|
+
const reading = parseZhipu({
|
|
107
|
+
success: true,
|
|
108
|
+
data: {
|
|
109
|
+
limits: [
|
|
110
|
+
{ type: 'CREDIT_LIMIT', unit: 3, percentage: 8.4, nextResetTime: base + 2 * HOUR_MS },
|
|
111
|
+
{ type: 'CREDIT_LIMIT', unit: 6, percentage: 21, nextResetTime: base + 24 * HOUR_MS },
|
|
112
|
+
],
|
|
113
|
+
level: 'pro',
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
assert.equal(reading.kind, 'quota')
|
|
117
|
+
assert.equal(reading.windows.length, 2)
|
|
118
|
+
assert.equal(reading.windows[0].label, '5小时')
|
|
119
|
+
assert.equal(reading.windows[0].utilization, 8.4)
|
|
120
|
+
assert.equal(reading.windows[1].label, '7天')
|
|
121
|
+
assert.equal(reading.level, 'pro')
|
|
122
|
+
|
|
123
|
+
const inferred = parseZhipu({
|
|
124
|
+
success: true,
|
|
125
|
+
data: {
|
|
126
|
+
limits: [{ type: 'CREDIT_LIMIT', unit: 3, currentValue: 25, usage: 500, nextResetTime: base }],
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
assert.equal(inferred.windows.length, 1)
|
|
130
|
+
assert.equal(inferred.windows[0].utilization, 5)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('场景5c 智谱 Max:TOKENS_LIMIT 加 TIME_LIMIT 提示数窗口', () => {
|
|
134
|
+
const base = 1740000000000
|
|
135
|
+
const reading = parseZhipu({
|
|
136
|
+
success: true,
|
|
137
|
+
data: {
|
|
138
|
+
limits: [
|
|
139
|
+
{ type: 'TOKENS_LIMIT', unit: 3, percentage: 3, nextResetTime: base + 2 * HOUR_MS },
|
|
140
|
+
{ type: 'TOKENS_LIMIT', unit: 6, percentage: 81, nextResetTime: base + 5 * 24 * HOUR_MS },
|
|
141
|
+
{ type: 'TIME_LIMIT', unit: 5, percentage: 1, currentValue: 8, usage: 4000, remaining: 3992, nextResetTime: base + 3 * HOUR_MS,
|
|
142
|
+
usageDetails: [
|
|
143
|
+
{ modelCode: 'search-prime', usage: 4 },
|
|
144
|
+
{ modelCode: 'web-reader', usage: 4 },
|
|
145
|
+
{ modelCode: 'zread', usage: 0 },
|
|
146
|
+
] },
|
|
147
|
+
],
|
|
148
|
+
level: 'max',
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
assert.equal(reading.kind, 'quota')
|
|
152
|
+
assert.equal(reading.windows.length, 3)
|
|
153
|
+
assert.equal(reading.windows[0].label, '5小时')
|
|
154
|
+
assert.equal(reading.windows[1].label, '7天')
|
|
155
|
+
assert.equal(reading.windows[2].label, '工具用量')
|
|
156
|
+
assert.equal(reading.windows[2].utilization, 1)
|
|
157
|
+
assert.equal(reading.windows[2].remaining, 3992)
|
|
158
|
+
assert.equal(reading.windows[2].limit, 4000)
|
|
159
|
+
assert.equal(Date.parse(reading.windows[2].resetsAt), base + 3 * HOUR_MS)
|
|
160
|
+
assert.deepEqual(reading.windows[2].details, [
|
|
161
|
+
{ model: 'search-prime', usage: 4 },
|
|
162
|
+
{ model: 'web-reader', usage: 4 },
|
|
163
|
+
{ model: 'zread', usage: 0 },
|
|
164
|
+
])
|
|
165
|
+
assert.equal(reading.level, 'max')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('场景5d 智谱真实响应:zp-jzjy 账号 CREDIT_LIMIT 原样结构', () => {
|
|
169
|
+
const reading = parseZhipu({
|
|
170
|
+
code: 200,
|
|
171
|
+
msg: '操作成功',
|
|
172
|
+
success: true,
|
|
173
|
+
data: {
|
|
174
|
+
limits: [
|
|
175
|
+
{ type: 'CREDIT_LIMIT', unit: 3, number: 5, usage: 12000, currentValue: 0, remaining: 12000, percentage: 0 },
|
|
176
|
+
{ type: 'CREDIT_LIMIT', unit: 6, number: 1, usage: 60000, currentValue: 59881, remaining: 118, percentage: 99, nextResetTime: 1788507277997 },
|
|
177
|
+
],
|
|
178
|
+
level: 'pro',
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
assert.equal(reading.windows.length, 2)
|
|
182
|
+
assert.equal(reading.windows[0].utilization, 0)
|
|
183
|
+
assert.equal(reading.windows[0].remaining, 12000)
|
|
184
|
+
assert.equal(reading.windows[0].limit, 12000)
|
|
185
|
+
assert.equal(reading.windows[1].utilization, 99)
|
|
186
|
+
assert.equal(reading.windows[1].remaining, 118)
|
|
187
|
+
assert.equal(reading.windows[1].resetsAt, new Date(1788507277997).toISOString())
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('场景5c 智谱:仅 TIME_LIMIT 等无关类型时报出见到的类型', () => {
|
|
191
|
+
assert.throws(
|
|
192
|
+
() => parseZhipu({ success: true, data: { limits: [{ type: 'TIME_LIMIT', percentage: 1 }] } }),
|
|
193
|
+
/TIME_LIMIT/,
|
|
194
|
+
)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('场景6 MiniMax remains:剩余百分比反推已用,周窗口按状态开关', () => {
|
|
198
|
+
const body = {
|
|
199
|
+
base_resp: { status_code: 0 },
|
|
200
|
+
model_remains: [{
|
|
201
|
+
model_name: 'general',
|
|
202
|
+
current_interval_remaining_percent: 88,
|
|
203
|
+
end_time: 1740000000000,
|
|
204
|
+
current_weekly_status: 1,
|
|
205
|
+
current_weekly_remaining_percent: 70,
|
|
206
|
+
weekly_end_time: 1740600000000,
|
|
207
|
+
}],
|
|
208
|
+
}
|
|
209
|
+
const reading = parseMiniMax(body)
|
|
210
|
+
assert.equal(reading.kind, 'quota')
|
|
211
|
+
assert.equal(reading.windows.length, 2)
|
|
212
|
+
assert.equal(reading.windows[0].utilization, 12)
|
|
213
|
+
assert.equal(reading.windows[1].utilization, 30)
|
|
214
|
+
|
|
215
|
+
const noWeekly = parseMiniMax({
|
|
216
|
+
base_resp: { status_code: 0 },
|
|
217
|
+
model_remains: [{ model_name: 'general', current_interval_remaining_percent: 50 }],
|
|
218
|
+
})
|
|
219
|
+
assert.equal(noWeekly.windows.length, 1)
|
|
220
|
+
|
|
221
|
+
assert.throws(
|
|
222
|
+
() => parseMiniMax({ base_resp: { status_code: 1001, status_msg: 'key 无效' } }),
|
|
223
|
+
/key 无效/,
|
|
224
|
+
)
|
|
225
|
+
assert.throws(
|
|
226
|
+
() => parseMiniMax({ base_resp: { status_code: 0 }, model_remains: [] }),
|
|
227
|
+
/general/,
|
|
228
|
+
)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('场景7 NewApi token:quota 换算 USD,无限额度报错', () => {
|
|
232
|
+
const QUOTA_PER_USD = 500000
|
|
233
|
+
const body = {
|
|
234
|
+
code: 200,
|
|
235
|
+
data: { total_granted: 5 * QUOTA_PER_USD, total_used: QUOTA_PER_USD, total_available: 4 * QUOTA_PER_USD, unlimited_quota: false },
|
|
236
|
+
}
|
|
237
|
+
const reading = parseNewApi(body)
|
|
238
|
+
assert.equal(reading.kind, 'balance')
|
|
239
|
+
assert.deepEqual(reading.entries[0], { currency: 'USD', total: 5, used: 1, remaining: 4 })
|
|
240
|
+
|
|
241
|
+
assert.throws(
|
|
242
|
+
() => parseNewApi({ code: 200, data: { unlimited_quota: true } }),
|
|
243
|
+
/无限额度/,
|
|
244
|
+
)
|
|
245
|
+
assert.throws(() => parseNewApi({ code: 401, message: '无权进行此操作,未登录' }), /无权/)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('场景8 custom extract:点路径/add/subtract/divide/常量', () => {
|
|
249
|
+
const data = { info: { max_budget: 100, spend: 40 }, a: 1, b: 2 }
|
|
250
|
+
assert.equal(extractByRule(data, 'info.max_budget'), 100)
|
|
251
|
+
assert.equal(extractByRule(data, { op: 'subtract', paths: ['info.max_budget', 'info.spend'] }), 60)
|
|
252
|
+
assert.equal(extractByRule(data, { op: 'add', paths: ['a', 'b'] }), 3)
|
|
253
|
+
assert.equal(extractByRule(data, { op: 'divide', path: 'info.max_budget', by: 4 }), 25)
|
|
254
|
+
assert.equal(extractByRule(data, 42), 42)
|
|
255
|
+
assert.equal(extractByRule(data, 'info.missing'), null)
|
|
256
|
+
assert.equal(extractByRule(data, { op: 'subtract', paths: ['info.max_budget', 'info.missing'] }), null)
|
|
257
|
+
|
|
258
|
+
const reading = extractCustom(data, {
|
|
259
|
+
remaining: { op: 'subtract', paths: ['info.max_budget', 'info.spend'] },
|
|
260
|
+
maxBudget: 'info.max_budget',
|
|
261
|
+
spend: 'info.spend',
|
|
262
|
+
unit: 'CNY',
|
|
263
|
+
})
|
|
264
|
+
// kind+entries 形态与 balance 读数联合对齐:历史采样/通知评估/渲染三链路按 kind 判别
|
|
265
|
+
assert.deepEqual(reading, {
|
|
266
|
+
kind: 'balance',
|
|
267
|
+
entries: [{ currency: 'CNY', remaining: 60, total: 100, used: 40 }],
|
|
268
|
+
})
|
|
269
|
+
assert.throws(() => extractCustom(data, { remaining: 'info.missing' }), /remaining/)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('场景8b getPath 原型链逃逸被拒绝', () => {
|
|
273
|
+
assert.equal(getPath({}, '__proto__'), undefined)
|
|
274
|
+
assert.equal(getPath({ a: 1 }, 'constructor'), undefined)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('场景9 严格数值:非数值一律 NaN', () => {
|
|
278
|
+
assert.ok(Number.isNaN(toStrictNumber('1,234')))
|
|
279
|
+
assert.ok(Number.isNaN(toStrictNumber('')))
|
|
280
|
+
assert.ok(Number.isNaN(toStrictNumber(null)))
|
|
281
|
+
assert.ok(Number.isNaN(toStrictNumber(false)))
|
|
282
|
+
assert.ok(Number.isNaN(toStrictNumber('$12')))
|
|
283
|
+
assert.equal(toStrictNumber('12.5'), 12.5)
|
|
284
|
+
assert.equal(toStrictNumber(' 7 '), 7)
|
|
285
|
+
assert.equal(toStrictNumber(0), 0)
|
|
286
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// 轮询纯逻辑 BDD:时间驱动调度 / 失败退避 / 档位间隔。无外部依赖,定时器由调用方注入。
|
|
2
|
+
// 历史:round 分频形态(shouldQueryThisRound/longWindowDivisor)已被时间驱动取代——
|
|
3
|
+
// round 仅查询时递增使余额类账号死锁停摆,短窗账号每 tick 必查使用户间隔失效。
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import {
|
|
7
|
+
BACKOFF_CAP_MULTIPLE,
|
|
8
|
+
SHORT_TIER_INTERVAL_SEC,
|
|
9
|
+
LONG_TIER_INTERVAL_SEC,
|
|
10
|
+
createBackoff,
|
|
11
|
+
tierIntervalSec,
|
|
12
|
+
lastQuerySecOf,
|
|
13
|
+
isDue,
|
|
14
|
+
isShortWindowTier,
|
|
15
|
+
} from '../src/poller.mjs'
|
|
16
|
+
|
|
17
|
+
test('场景: 失败退避指数增长并封顶', () => {
|
|
18
|
+
const backoff = createBackoff({ baseSec: 600 })
|
|
19
|
+
assert.equal(backoff.isBlocked(0), false, '初始不退避')
|
|
20
|
+
backoff.onFailure(0)
|
|
21
|
+
assert.equal(backoff.nextRetryAt, 600)
|
|
22
|
+
backoff.onFailure(600)
|
|
23
|
+
assert.equal(backoff.nextRetryAt, 1800)
|
|
24
|
+
backoff.onFailure(1800)
|
|
25
|
+
assert.equal(backoff.nextRetryAt, 4200)
|
|
26
|
+
backoff.onFailure(4200)
|
|
27
|
+
// 封顶 = 基期 * 8
|
|
28
|
+
assert.equal(backoff.nextRetryAt, 4200 + 600 * BACKOFF_CAP_MULTIPLE)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('场景: 成功即恢复退避', () => {
|
|
32
|
+
const backoff = createBackoff({ baseSec: 600 })
|
|
33
|
+
backoff.onFailure(0)
|
|
34
|
+
backoff.onSuccess()
|
|
35
|
+
assert.equal(backoff.isBlocked(1), false)
|
|
36
|
+
backoff.onFailure(1)
|
|
37
|
+
assert.equal(backoff.nextRetryAt, 1 + 600, '恢复后按基期重新退避')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('场景: 退避期间被跳过,到期放行', () => {
|
|
41
|
+
const backoff = createBackoff({ baseSec: 600 })
|
|
42
|
+
backoff.onFailure(0)
|
|
43
|
+
assert.equal(backoff.isBlocked(599), true)
|
|
44
|
+
assert.equal(backoff.isBlocked(600), false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('场景: 档位间隔 = 序列快照粒度(短档 10 分钟,长档 1 小时)', () => {
|
|
48
|
+
assert.equal(SHORT_TIER_INTERVAL_SEC, 10 * 60)
|
|
49
|
+
assert.equal(LONG_TIER_INTERVAL_SEC, 60 * 60)
|
|
50
|
+
assert.equal(tierIntervalSec(true), SHORT_TIER_INTERVAL_SEC)
|
|
51
|
+
assert.equal(tierIntervalSec(false), LONG_TIER_INTERVAL_SEC)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('场景: 上次尝试查询时刻换算,缺失或非法回 null 视为立即到点', () => {
|
|
55
|
+
assert.equal(lastQuerySecOf(null), null)
|
|
56
|
+
assert.equal(lastQuerySecOf(undefined), null)
|
|
57
|
+
assert.equal(lastQuerySecOf({ ok: true }), null, '旧数据无 queriedAt')
|
|
58
|
+
assert.equal(lastQuerySecOf({ queriedAt: 'bad' }), null)
|
|
59
|
+
assert.equal(lastQuerySecOf({ queriedAt: 1700000000000 }), 1700000000)
|
|
60
|
+
assert.equal(lastQuerySecOf({ queriedAt: 1700000000500 }), 1700000000, '毫秒截断到秒')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('场景: 时间驱动到点判定', () => {
|
|
64
|
+
assert.equal(isDue({ lastQuerySec: null, nowSec: 1000, intervalSec: 600 }), true, '从未查询立即到点')
|
|
65
|
+
assert.equal(isDue({ lastQuerySec: 500, nowSec: 1099, intervalSec: 600 }), false, '未满间隔')
|
|
66
|
+
assert.equal(isDue({ lastQuerySec: 500, nowSec: 1100, intervalSec: 600 }), true, '恰满间隔到点')
|
|
67
|
+
assert.equal(isDue({ lastQuerySec: 0, nowSec: LONG_TIER_INTERVAL_SEC, intervalSec: LONG_TIER_INTERVAL_SEC }), true)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('场景: 退避与到点独立叠加,双过才查(时序推演锁定)', () => {
|
|
71
|
+
// 短窗账号失败:退避基期 = 档间隔 600;首次失败后 600s 内到点亦被退避压住
|
|
72
|
+
const backoff = createBackoff({ baseSec: SHORT_TIER_INTERVAL_SEC })
|
|
73
|
+
backoff.onFailure(1000)
|
|
74
|
+
const dueAt = (lastQuerySec, nowSec) =>
|
|
75
|
+
!backoff.isBlocked(nowSec) && isDue({ lastQuerySec, nowSec, intervalSec: SHORT_TIER_INTERVAL_SEC })
|
|
76
|
+
assert.equal(dueAt(400, 1500), false, '退避未过(到 1600 才解),尽管 1000 已到点')
|
|
77
|
+
assert.equal(dueAt(400, 1600), true, '退避与到点双过')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('场景: 短窗口档判定,从未成功(含最近失败)按短档,失败不塌缩到长档', () => {
|
|
81
|
+
const readingHasShort = true
|
|
82
|
+
assert.equal(isShortWindowTier(null, readingHasShort), true, 'last 为空视为短窗口档')
|
|
83
|
+
assert.equal(isShortWindowTier(null, false), true, 'last 为空即使无短窗口读数也首轮即查')
|
|
84
|
+
assert.equal(isShortWindowTier({ ok: true, reading: {} }, readingHasShort), true)
|
|
85
|
+
assert.equal(isShortWindowTier({ ok: true, reading: {} }, false), false, '成功且仅长窗口按长档')
|
|
86
|
+
assert.equal(isShortWindowTier({ ok: false, reading: null }, readingHasShort), true, '失败不塌缩:含短窗账号保持短档节奏')
|
|
87
|
+
assert.equal(isShortWindowTier({ ok: false, reading: null }, false), true, '失败的长窗账号也归短档:10 分钟节奏 + 退避压制重试')
|
|
88
|
+
})
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// host 路由层最小测试:settings GET 的 pollArmed 形态(定时软依赖两形态)+ 写路由守卫全覆盖
|
|
2
|
+
// + accounts 损坏守卫。数据目录经 env 注入临时路径,须在 import src/index.js 之前设置。
|
|
3
|
+
// 通知接线路由测试在 notify.route.test.mjs(独立进程独立数据目录)。
|
|
4
|
+
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { EventEmitter } from 'node:events'
|
|
8
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
const tempDir = await mkdtemp(join(tmpdir(), 'usage-route-'))
|
|
13
|
+
process.env.DSH_USAGE_PANEL_DATA_DIR = tempDir
|
|
14
|
+
|
|
15
|
+
const { apply } = await import('../src/index.js')
|
|
16
|
+
|
|
17
|
+
test.after(async () => {
|
|
18
|
+
delete process.env.DSH_USAGE_PANEL_DATA_DIR
|
|
19
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
function makeCtx({ timerAvailable = true, settingsValue = {}, dshIm = undefined } = {}) {
|
|
23
|
+
let value = settingsValue
|
|
24
|
+
const routes = new Map()
|
|
25
|
+
const settingsService = {
|
|
26
|
+
register() {},
|
|
27
|
+
get: () => value,
|
|
28
|
+
update: async (_ns, patch) => {
|
|
29
|
+
value = { ...value, ...patch }
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
const ctx = {
|
|
33
|
+
get(name) {
|
|
34
|
+
if (name === 'settings') return settingsService
|
|
35
|
+
if (name === 'dshIm') return dshIm
|
|
36
|
+
return undefined
|
|
37
|
+
},
|
|
38
|
+
effect(fn) {
|
|
39
|
+
fn()
|
|
40
|
+
},
|
|
41
|
+
inject(deps, fn) {
|
|
42
|
+
// timer 服务桩:模拟宿主 timer 激活后的 interval(返回 disposer 同官方契约)
|
|
43
|
+
fn({
|
|
44
|
+
settings: settingsService,
|
|
45
|
+
interval: timerAvailable
|
|
46
|
+
? (intervalFn) => {
|
|
47
|
+
void intervalFn
|
|
48
|
+
return () => {}
|
|
49
|
+
}
|
|
50
|
+
: undefined,
|
|
51
|
+
})
|
|
52
|
+
},
|
|
53
|
+
webServer: {
|
|
54
|
+
register(route) {
|
|
55
|
+
routes.set(route.path, route.handler)
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
return { ctx, routes }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeReq(method, { origin, contentType, body } = {}) {
|
|
63
|
+
const req = new EventEmitter()
|
|
64
|
+
req.method = method
|
|
65
|
+
req.headers = { host: 'localhost:3000' }
|
|
66
|
+
if (origin !== undefined) req.headers.origin = origin
|
|
67
|
+
if (contentType !== undefined) req.headers['content-type'] = contentType
|
|
68
|
+
if (body !== undefined) {
|
|
69
|
+
process.nextTick(() => {
|
|
70
|
+
req.emit('data', Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)))
|
|
71
|
+
req.emit('end')
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
return req
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function call(routes, path, req) {
|
|
78
|
+
const res = { status: null, payload: null }
|
|
79
|
+
res.writeHead = (status) => { res.status = status }
|
|
80
|
+
res.end = (text) => { res.payload = JSON.parse(text) }
|
|
81
|
+
await routes.get(path)(req, res)
|
|
82
|
+
return res
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test('settings:timer 服务激活时 pollArmed 为 true', async () => {
|
|
86
|
+
const { ctx, routes } = makeCtx({ timerAvailable: true })
|
|
87
|
+
apply(ctx)
|
|
88
|
+
const res = await call(routes, '/api/usage-panel/settings', makeReq('GET'))
|
|
89
|
+
assert.equal(res.status, 200)
|
|
90
|
+
assert.equal(res.payload.pollArmed, true)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('settings:timer 服务缺失时 pollArmed 为 false(降级可见)', async () => {
|
|
94
|
+
const { ctx, routes } = makeCtx({ timerAvailable: false })
|
|
95
|
+
apply(ctx)
|
|
96
|
+
const res = await call(routes, '/api/usage-panel/settings', makeReq('GET'))
|
|
97
|
+
assert.equal(res.status, 200)
|
|
98
|
+
assert.equal(res.payload.pollArmed, false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('settings:写方法已收缩,POST 405 不再接受间隔设置', async () => {
|
|
102
|
+
const { ctx, routes } = makeCtx()
|
|
103
|
+
apply(ctx)
|
|
104
|
+
const res = await call(routes, '/api/usage-panel/settings', makeReq('POST', {
|
|
105
|
+
contentType: 'application/json',
|
|
106
|
+
origin: 'http://localhost:3000',
|
|
107
|
+
body: '{"pollIntervalSec":60}',
|
|
108
|
+
}))
|
|
109
|
+
assert.equal(res.status, 405)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// 写路由守卫全覆盖:POST 路由的跨源与 content-type 守卫逐路由验证,
|
|
113
|
+
// 防 drive-by 简单请求改写账号配置(含注入攻击者端点账号);POST-only 路由 GET 必须 405
|
|
114
|
+
// (GET 无守卫,放行即给跨站 <img src> 驱动测试通道外发的面)。
|
|
115
|
+
test('守卫:全部 POST 路由拒绝跨源与非 JSON,POST-only 路由拒绝 GET', async () => {
|
|
116
|
+
const { ctx, routes } = makeCtx()
|
|
117
|
+
apply(ctx)
|
|
118
|
+
const dualPaths = [
|
|
119
|
+
'/api/usage-panel/accounts',
|
|
120
|
+
'/api/usage-panel/notify-config',
|
|
121
|
+
]
|
|
122
|
+
const postOnlyPaths = [
|
|
123
|
+
'/api/usage-panel/query',
|
|
124
|
+
'/api/usage-panel/test-webhook',
|
|
125
|
+
'/api/usage-panel/test-im',
|
|
126
|
+
]
|
|
127
|
+
for (const path of [...dualPaths, ...postOnlyPaths]) {
|
|
128
|
+
const cross = await call(routes, path, makeReq('POST', {
|
|
129
|
+
origin: 'http://evil.example',
|
|
130
|
+
contentType: 'application/json',
|
|
131
|
+
body: '{"accounts":[]}',
|
|
132
|
+
}))
|
|
133
|
+
assert.equal(cross.status, 403, path + ' 跨源未拒')
|
|
134
|
+
const nonJson = await call(routes, path, makeReq('POST', {
|
|
135
|
+
origin: 'http://localhost:3000',
|
|
136
|
+
contentType: 'text/plain',
|
|
137
|
+
body: '{"accounts":[]}',
|
|
138
|
+
}))
|
|
139
|
+
assert.equal(nonJson.status, 400, path + ' 非 JSON 未拒')
|
|
140
|
+
assert.match(nonJson.payload.error, /content-type/, path + ' 400 须出自守卫层而非业务校验')
|
|
141
|
+
}
|
|
142
|
+
for (const path of postOnlyPaths) {
|
|
143
|
+
const get = await call(routes, path, makeReq('GET'))
|
|
144
|
+
assert.equal(get.status, 405, path + ' POST-only 路由放行了 GET')
|
|
145
|
+
}
|
|
146
|
+
const read = await call(routes, '/api/usage-panel/accounts', makeReq('GET'))
|
|
147
|
+
assert.equal(read.status, 200, 'GET 读路由放行')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('accounts:损坏配置文件拒绝写入并备份(broken 守卫)', async () => {
|
|
151
|
+
await writeFile(join(tempDir, 'accounts.json'), '{broken', 'utf8')
|
|
152
|
+
const { ctx, routes } = makeCtx()
|
|
153
|
+
apply(ctx)
|
|
154
|
+
const res = await call(routes, '/api/usage-panel/accounts', makeReq('POST', {
|
|
155
|
+
origin: 'http://localhost:3000',
|
|
156
|
+
contentType: 'application/json',
|
|
157
|
+
body: '{"accounts":[]}',
|
|
158
|
+
}))
|
|
159
|
+
assert.equal(res.status, 400)
|
|
160
|
+
assert.match(res.payload.error, /损坏/)
|
|
161
|
+
// 坏文件已备份移走;重读(解除路径)后 GET 得空配置,写入恢复
|
|
162
|
+
const get = await call(routes, '/api/usage-panel/accounts', makeReq('GET'))
|
|
163
|
+
assert.equal(get.status, 200)
|
|
164
|
+
assert.deepEqual(get.payload.accounts, [])
|
|
165
|
+
const save = await call(routes, '/api/usage-panel/accounts', makeReq('POST', {
|
|
166
|
+
origin: 'http://localhost:3000',
|
|
167
|
+
contentType: 'application/json',
|
|
168
|
+
body: '{"accounts":[{"id":"acct-r","type":"custom"}]}',
|
|
169
|
+
}))
|
|
170
|
+
assert.equal(save.status, 200, '重读解除损坏后写入恢复')
|
|
171
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// 开关样式守卫 BDD:状态选择器回退为裸 input 会静默隐藏 label 内其他 input,
|
|
2
|
+
// 属无报错的 UI 损坏,故对源文本做静态断言锁死结构(turn-notify 同款守卫)
|
|
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, /\.up-switch input\[type="checkbox"\] \{ position:absolute/)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('switch 状态选择器禁止裸 input 锚定(防误伤 label 内其他 input)', () => {
|
|
17
|
+
const bare = source.match(/\.up-switch input:(?!\[type)[a-z-]+/g)
|
|
18
|
+
assert.equal(bare, null, `裸 input 状态选择器: ${bare}`)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('checkbox 仅允许出现在 Switch 组件内,禁止裸 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 Switch(')
|
|
25
|
+
const factoryEnd = source.indexOf('__thumb', factoryStart)
|
|
26
|
+
assert.ok(occurrences[0] > factoryStart && occurrences[0] < factoryEnd, 'checkbox 字面量不在 Switch 组件内')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('Switch 产出顺序为 input 在前 track 在后', () => {
|
|
30
|
+
const factory = source.slice(source.indexOf('function Switch('))
|
|
31
|
+
assert.ok(factory.indexOf("h('input'") < factory.indexOf('up-switch__track'), '组件内 input 必须先于 track')
|
|
32
|
+
})
|