dsh-router-core 0.1.0 → 0.2.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.
- package/README.md +36 -12
- package/lib/client-registry.js +92 -66
- package/lib/client.js +92 -66
- package/lib/index.js +567 -204
- package/lib/types/router/account-pool.d.ts +41 -0
- package/lib/types/router/index.d.ts +43 -4
- package/lib/types/router/types.d.ts +12 -10
- package/lib/types/supplier-config.d.ts +0 -3
- package/lib/types/suppliers/contract.d.ts +70 -21
- package/lib/types/suppliers/registry.d.ts +4 -3
- package/package.json +2 -1
- package/src/client/CombosTab.tsx +7 -5
- package/src/client/SupplierDetail.tsx +58 -34
- package/src/client/router.css +9 -0
- package/src/index.ts +2 -2
- package/src/router/account-pool.test.ts +120 -0
- package/src/router/account-pool.ts +161 -0
- package/src/router/index.ts +233 -19
- package/src/router/models-cache.test.ts +186 -0
- package/src/router/types.ts +12 -7
- package/src/supplier-config.ts +1 -12
- package/src/suppliers/contract.ts +71 -8
- package/src/suppliers/loader.ts +19 -12
- package/src/suppliers/nvidia/plugin.ts +161 -118
- package/src/suppliers/opencode/plugin.ts +47 -94
- package/src/suppliers/openrouter/plugin.ts +67 -121
- package/src/suppliers/registry.test.ts +194 -0
- package/src/suppliers/registry.ts +72 -57
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* 一个供应商可有多个命名 key,按池顺序/策略尝试。凭证存通用 CredentialStore
|
|
10
10
|
* (SQLite:auths/credentials.sqlite,{ name, apiKey })。
|
|
11
11
|
*/
|
|
12
|
-
import type {
|
|
13
|
-
import type {
|
|
12
|
+
import type { ChatRequest, ModelInfo } from '../../router/types.ts'
|
|
13
|
+
import type { AccountState, ChatOnceResult, SupplierStatusNow } from '../contract.ts'
|
|
14
14
|
import type { SupplierEnv, SupplierModule } from '../contract.ts'
|
|
15
15
|
|
|
16
16
|
export const id = 'openrouter'
|
|
@@ -22,7 +22,8 @@ export const icon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAA
|
|
|
22
22
|
const BASE = 'https://openrouter.ai/api/v1'
|
|
23
23
|
const MODELS_URL = `${BASE}/models`
|
|
24
24
|
const CHAT_URL = `${BASE}/chat/completions`
|
|
25
|
-
|
|
25
|
+
/** 默认前缀(用户可在面板改;loader 包装会优先用 store 里的值)。 */
|
|
26
|
+
const DEFAULT_ALIAS = 'or'
|
|
26
27
|
|
|
27
28
|
/** 免费模型过滤(同 9Router openrouter-free):pricing 全 0 且 context ≥ 200k。 */
|
|
28
29
|
function isFreeModelMeta(m: { pricing?: { prompt?: string; completion?: string }; context_length?: number }): boolean {
|
|
@@ -33,10 +34,11 @@ function isFreeModelMeta(m: { pricing?: { prompt?: string; completion?: string }
|
|
|
33
34
|
)
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
|
|
36
38
|
/** 剥 alias 前缀(or/xxx → xxx)。 */
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return
|
|
39
|
+
/** 剥本供应商 alias 前缀(只剥自己的,模型 id 自带的斜杠保留)。 */
|
|
40
|
+
function stripAlias(model: string, alias: string): string {
|
|
41
|
+
return alias !== '' && model.startsWith(`${alias}/`) ? model.slice(alias.length + 1) : model
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
interface ApiKeyAccount {
|
|
@@ -48,9 +50,8 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
48
50
|
// 模型缓存由 dsh-router 核心统一管;插件每次拉取,失败回退上次成功结果
|
|
49
51
|
let modelsCache: ModelInfo[] | undefined
|
|
50
52
|
let freeIds = new Set<string>()
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
/** 上次 chatOnce 失败原因(供核心测试模型汇总诊断)。 */
|
|
54
|
+
let lastErr: string | undefined
|
|
54
55
|
function listKeys(): string[] {
|
|
55
56
|
return env.credentials.list(id)
|
|
56
57
|
}
|
|
@@ -67,8 +68,9 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
67
68
|
return uids.map((uid) => ({ uid, acct: getKey(uid)! })).filter((x) => x.acct !== undefined)
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
/** 当前前缀(与 loader 包装一致:store 覆盖默认值)。 */
|
|
72
|
+
function currentAlias(): string {
|
|
73
|
+
return env.store.get(id).alias || DEFAULT_ALIAS
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
async function refreshModels(): Promise<ModelInfo[]> {
|
|
@@ -109,15 +111,13 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
109
111
|
name,
|
|
110
112
|
priority,
|
|
111
113
|
icon,
|
|
112
|
-
status: ():
|
|
113
|
-
|
|
114
|
+
status: (): SupplierStatusNow => {
|
|
115
|
+
// 只报「现在状态」:凭证是否存在。冷却/禁用/错误累计由核心叠加。
|
|
114
116
|
const accounts = orderedKeys().map(({ uid, acct }) => ({
|
|
115
117
|
uid,
|
|
116
118
|
nickname: acct.name || 'API Key',
|
|
117
119
|
credits: 0,
|
|
118
|
-
|
|
119
|
-
disabled: false,
|
|
120
|
-
err_count: 0,
|
|
120
|
+
state: 'ok' as AccountState,
|
|
121
121
|
}))
|
|
122
122
|
return { id, name, accounts }
|
|
123
123
|
},
|
|
@@ -150,43 +150,21 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
150
150
|
async removeLink(uid: string): Promise<boolean> {
|
|
151
151
|
if (getKey(uid) === undefined) return false
|
|
152
152
|
env.credentials.remove(id, uid)
|
|
153
|
-
cooling.delete(uid)
|
|
154
153
|
return true
|
|
155
154
|
},
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
155
|
+
lastError: (): string | undefined => lastErr,
|
|
156
|
+
/** 对单个 key 调一次上游。选号/冷却/换号是核心的活,这里只报结果。 */
|
|
157
|
+
async chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult> {
|
|
158
|
+
const base = stripAlias(req.model, currentAlias())
|
|
160
159
|
if (!freeIds.has(base) && !(await allModels(false)).some((m) => m.id === base)) {
|
|
161
|
-
|
|
160
|
+
lastErr = `unknown free model ${JSON.stringify(req.model)}`
|
|
161
|
+
return { ok: false, state: 'no_such_model', message: lastErr }
|
|
162
162
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
headers: {
|
|
168
|
-
'Content-Type': 'application/json',
|
|
169
|
-
Authorization: `Bearer ${acct.apiKey}`,
|
|
170
|
-
'HTTP-Referer': 'https://endpoint-proxy.local',
|
|
171
|
-
'X-Title': 'dsh-router',
|
|
172
|
-
},
|
|
173
|
-
body: JSON.stringify({ model: base, messages: [{ role: 'user', content: 'ping' }], stream: false, max_tokens: 1 }),
|
|
174
|
-
signal: AbortSignal.timeout(30000),
|
|
175
|
-
})
|
|
176
|
-
if (resp.ok) return { ok: true }
|
|
177
|
-
const body = await resp.text().catch(() => '')
|
|
178
|
-
return { ok: false, error: `upstream ${resp.status}: ${body.slice(0, 200)}` }
|
|
179
|
-
} catch (err) {
|
|
180
|
-
return { ok: false, error: (err as Error).message }
|
|
181
|
-
}
|
|
163
|
+
const acct = getKey(uid)
|
|
164
|
+
if (acct === undefined) {
|
|
165
|
+
lastErr = `unknown account ${JSON.stringify(uid)}`
|
|
166
|
+
return { ok: false, state: 'no_such_model', message: lastErr }
|
|
182
167
|
}
|
|
183
|
-
return { ok: false, error: 'no api key' }
|
|
184
|
-
},
|
|
185
|
-
async chatCompletions(req: ChatRequest, res: ServerResponse): Promise<boolean> {
|
|
186
|
-
const base = stripAlias(req.model)
|
|
187
|
-
if (!freeIds.has(base) && !(await allModels(false)).some((m) => m.id === base)) return false
|
|
188
|
-
const keys = orderedKeys().filter(({ uid }) => !isCooling(uid))
|
|
189
|
-
if (keys.length === 0) return false // 无健康 key → 路由器 fallback
|
|
190
168
|
|
|
191
169
|
let body = req.rawBody
|
|
192
170
|
try {
|
|
@@ -197,74 +175,50 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
197
175
|
// 保持原样
|
|
198
176
|
}
|
|
199
177
|
|
|
200
|
-
let
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
// 非 2xx:冷却该 key,试下一个
|
|
221
|
-
if (upstream.status < 200 || upstream.status >= 300) {
|
|
222
|
-
cooling.set(uid, Date.now() + COOL_DOWN_MS)
|
|
223
|
-
const text = await upstream.text().catch(() => '')
|
|
224
|
-
lastErr = `upstream ${upstream.status}: ${text.slice(0, 120)}`
|
|
225
|
-
continue
|
|
226
|
-
}
|
|
227
|
-
// 成功:透传(流式 SSE 或 JSON)
|
|
228
|
-
if (req.stream) {
|
|
229
|
-
res.writeHead(upstream.status, {
|
|
230
|
-
'Content-Type': 'text/event-stream',
|
|
231
|
-
'Cache-Control': 'no-cache',
|
|
232
|
-
Connection: 'keep-alive',
|
|
233
|
-
'X-Accel-Buffering': 'no',
|
|
234
|
-
})
|
|
235
|
-
if (!upstream.body) {
|
|
236
|
-
res.end()
|
|
237
|
-
return true
|
|
238
|
-
}
|
|
239
|
-
const reader = upstream.body.getReader()
|
|
240
|
-
try {
|
|
241
|
-
for (;;) {
|
|
242
|
-
const { done, value } = await reader.read()
|
|
243
|
-
if (done) break
|
|
244
|
-
res.write(Buffer.from(value))
|
|
245
|
-
if (typeof (res as { flushHeaders?: () => void }).flushHeaders === 'function') {
|
|
246
|
-
;(res as { flushHeaders: () => void }).flushHeaders()
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
} finally {
|
|
250
|
-
reader.releaseLock()
|
|
251
|
-
}
|
|
252
|
-
res.end()
|
|
253
|
-
return true
|
|
254
|
-
}
|
|
178
|
+
let upstream: Response
|
|
179
|
+
try {
|
|
180
|
+
upstream = await fetch(CHAT_URL, {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
headers: {
|
|
183
|
+
'Content-Type': 'application/json',
|
|
184
|
+
Authorization: `Bearer ${acct.apiKey}`,
|
|
185
|
+
'HTTP-Referer': 'https://endpoint-proxy.local',
|
|
186
|
+
'X-Title': 'dsh-router',
|
|
187
|
+
},
|
|
188
|
+
body,
|
|
189
|
+
signal: AbortSignal.timeout(120000),
|
|
190
|
+
})
|
|
191
|
+
} catch (err) {
|
|
192
|
+
lastErr = (err as Error).message
|
|
193
|
+
return { ok: false, state: 'transport', message: lastErr }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (upstream.status < 200 || upstream.status >= 300) {
|
|
255
197
|
const text = await upstream.text().catch(() => '')
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
198
|
+
lastErr = `upstream ${upstream.status}: ${text.slice(0, 120)}`
|
|
199
|
+
// 401/403 = 这个 key 本身不可用;429 = 限流;其余归为说不清
|
|
200
|
+
const state: AccountState =
|
|
201
|
+
upstream.status === 429 ? 'rate_limit'
|
|
202
|
+
: upstream.status === 401 || upstream.status === 403 ? 'session_dead'
|
|
203
|
+
: upstream.status === 404 ? 'unavailable'
|
|
204
|
+
: 'unknown'
|
|
205
|
+
return { ok: false, state, message: lastErr }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 流式:上游已是 OpenAI SSE,原样交回核心写
|
|
209
|
+
if (req.stream) {
|
|
210
|
+
if (!upstream.body) {
|
|
211
|
+
lastErr = 'openrouter upstream: empty stream body'
|
|
212
|
+
return { ok: false, state: 'transport', message: lastErr }
|
|
262
213
|
}
|
|
263
|
-
return true
|
|
214
|
+
return { ok: true, stream: upstream.body }
|
|
264
215
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
216
|
+
const text = await upstream.text().catch(() => '')
|
|
217
|
+
if (text === '') {
|
|
218
|
+
lastErr = 'openrouter upstream: empty body'
|
|
219
|
+
return { ok: false, state: 'transport', message: lastErr }
|
|
220
|
+
}
|
|
221
|
+
return { ok: true, status: upstream.status, body: text }
|
|
268
222
|
},
|
|
269
223
|
dispose: (): void => {
|
|
270
224
|
modelsCache = undefined
|
|
@@ -272,11 +226,3 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
272
226
|
}
|
|
273
227
|
}
|
|
274
228
|
|
|
275
|
-
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
276
|
-
const payload = typeof body === 'string' ? body : JSON.stringify(body)
|
|
277
|
-
res.writeHead(status, {
|
|
278
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
279
|
-
'Cache-Control': 'no-store',
|
|
280
|
-
})
|
|
281
|
-
res.end(payload)
|
|
282
|
-
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 签到汇聚(POST /suppliers/:id/checkin)契约测试 —— node --test 直接跑(Node 24 原生剥 TS 类型)。
|
|
3
|
+
*
|
|
4
|
+
* 锁定的契约:checkinNow 是**单账号**能力,遍历所有链接 + 汇总是核心的活:
|
|
5
|
+
* - 禁用链接不签(不动它)
|
|
6
|
+
* - 某个链接抛错 → 记成 error,不带垮其它链接
|
|
7
|
+
* - succeeded / already 按 status 统计,ok = 有任一成功或今日已签
|
|
8
|
+
*/
|
|
9
|
+
import test from 'node:test'
|
|
10
|
+
import assert from 'node:assert/strict'
|
|
11
|
+
import { supplierRoutes } from './registry.ts'
|
|
12
|
+
import type { SupplierConfigStore } from '../supplier-config.ts'
|
|
13
|
+
|
|
14
|
+
type CheckinResult = { uid: string; ok: boolean; status: string; message?: string }
|
|
15
|
+
|
|
16
|
+
/** 最小假 res:只接住 writeJson 写出的状态码与 JSON body。 */
|
|
17
|
+
function fakeRes(): { status: number; body: unknown; res: never } {
|
|
18
|
+
const out = { status: 0, body: undefined as unknown }
|
|
19
|
+
const res = {
|
|
20
|
+
writeHead(status: number): void {
|
|
21
|
+
out.status = status
|
|
22
|
+
},
|
|
23
|
+
end(payload: string): void {
|
|
24
|
+
out.body = JSON.parse(payload)
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
return { get status() { return out.status }, get body() { return out.body }, res: res as never }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type FakeAccount = { uid: string; disabled?: boolean; credits?: number }
|
|
31
|
+
|
|
32
|
+
/** 造一个只带 checkinNow 的假供应商:status() 返回账号列表,checkinNow 按 uid 应答。
|
|
33
|
+
* accounts 可传函数(每次 status() 重新求值,用来模拟插件后台异步刷积分)。 */
|
|
34
|
+
function fixture(
|
|
35
|
+
accounts: FakeAccount[] | (() => FakeAccount[]),
|
|
36
|
+
checkinNow: (uid: string) => Promise<{ ok: boolean; status: string; message?: string }>,
|
|
37
|
+
): never {
|
|
38
|
+
const list = (): FakeAccount[] => (typeof accounts === 'function' ? accounts() : accounts)
|
|
39
|
+
const supplier = {
|
|
40
|
+
id: 'fake',
|
|
41
|
+
name: 'fake',
|
|
42
|
+
status: () => ({
|
|
43
|
+
id: 'fake',
|
|
44
|
+
name: 'fake',
|
|
45
|
+
accounts: list().map((a) => ({
|
|
46
|
+
uid: a.uid,
|
|
47
|
+
nickname: a.uid,
|
|
48
|
+
credits: a.credits ?? 0,
|
|
49
|
+
cooling: false,
|
|
50
|
+
disabled: a.disabled ?? false,
|
|
51
|
+
err_count: 0,
|
|
52
|
+
})),
|
|
53
|
+
}),
|
|
54
|
+
__module: { checkinNow },
|
|
55
|
+
}
|
|
56
|
+
return { supplier, capabilities: new Set(['checkinNow']), source: 'builtin' } as never
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 打到某条路由上,返回 { status, body }。B 是期望的响应体形状(让 tsc 真检查)。 */
|
|
60
|
+
async function post<B>(loaded: never, path: string): Promise<{ status: number; body: B }> {
|
|
61
|
+
const routes = supplierRoutes('/router/api', loaded, {} as SupplierConfigStore, {} as never)
|
|
62
|
+
const route = routes.find((r) => r.path === `/router/api/suppliers/fake/${path}`)
|
|
63
|
+
assert.ok(route, `${path} 路由应注册`)
|
|
64
|
+
const sink = fakeRes() // getter 延迟读,不能解构
|
|
65
|
+
await route!.handler({} as never, sink.res)
|
|
66
|
+
return { status: sink.status, body: sink.body as B }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 跑到 /checkin 路由上,返回 { status, body }。 */
|
|
70
|
+
async function postCheckin(loaded: never): Promise<{ status: number; body: CheckinPayload }> {
|
|
71
|
+
return await post<CheckinPayload>(loaded, 'checkin')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** POST /links/refresh 的响应体。 */
|
|
75
|
+
interface RefreshPayload {
|
|
76
|
+
ok: boolean
|
|
77
|
+
changed: boolean
|
|
78
|
+
accounts: Array<{ uid: string; credits: number }>
|
|
79
|
+
error?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface CheckinPayload {
|
|
83
|
+
ok: boolean
|
|
84
|
+
total: number
|
|
85
|
+
succeeded: number
|
|
86
|
+
already: number
|
|
87
|
+
results?: CheckinResult[]
|
|
88
|
+
error?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
test('签到:遍历所有链接,逐个调 checkinNow(uid)', async () => {
|
|
92
|
+
const seen: string[] = []
|
|
93
|
+
const { status, body } = await postCheckin(
|
|
94
|
+
fixture([{ uid: 'a' }, { uid: 'b' }], async (uid) => {
|
|
95
|
+
seen.push(uid)
|
|
96
|
+
return { ok: true, status: 'ok', message: '+100 积分' }
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
assert.deepEqual(seen, ['a', 'b'])
|
|
100
|
+
assert.equal(status, 200)
|
|
101
|
+
assert.deepEqual(
|
|
102
|
+
{ ok: body.ok, total: body.total, succeeded: body.succeeded, already: body.already },
|
|
103
|
+
{ ok: true, total: 2, succeeded: 2, already: 0 },
|
|
104
|
+
)
|
|
105
|
+
assert.deepEqual(body.results?.map((r) => r.uid), ['a', 'b'])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('签到:禁用链接照样签(禁用由插件自己判定,核心不筛)', async () => {
|
|
109
|
+
const seen: string[] = []
|
|
110
|
+
const { body } = await postCheckin(
|
|
111
|
+
fixture([{ uid: 'a', disabled: true }, { uid: 'b' }], async (uid) => {
|
|
112
|
+
seen.push(uid)
|
|
113
|
+
// 插件(如 traework)自己会对禁用链接返回 status:'disabled'
|
|
114
|
+
return uid === 'a' ? { ok: false, status: 'disabled', message: '链接已禁用' } : { ok: true, status: 'ok' }
|
|
115
|
+
}),
|
|
116
|
+
)
|
|
117
|
+
assert.deepEqual(seen, ['a', 'b'])
|
|
118
|
+
assert.equal(body.total, 2)
|
|
119
|
+
assert.equal(body.succeeded, 1)
|
|
120
|
+
assert.equal(body.results?.[0]?.status, 'disabled')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('签到:单链接抛错记成 error,其余照签', async () => {
|
|
124
|
+
const { status, body } = await postCheckin(
|
|
125
|
+
fixture([{ uid: 'a' }, { uid: 'b' }], async (uid) => {
|
|
126
|
+
if (uid === 'a') throw new Error('boom')
|
|
127
|
+
return { ok: true, status: 'ok' }
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
130
|
+
assert.equal(status, 200)
|
|
131
|
+
assert.equal(body.succeeded, 1)
|
|
132
|
+
assert.equal(body.results?.[0]?.status, 'error')
|
|
133
|
+
assert.equal(body.results?.[0]?.message, 'boom')
|
|
134
|
+
assert.equal(body.results?.[1]?.status, 'ok')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('签到:今日已签(already)算成功,不计入 succeeded', async () => {
|
|
138
|
+
const { status, body } = await postCheckin(
|
|
139
|
+
fixture([{ uid: 'a' }], async () => ({ ok: true, status: 'already', message: '今日已签到' })),
|
|
140
|
+
)
|
|
141
|
+
assert.equal(status, 200)
|
|
142
|
+
assert.deepEqual({ ok: body.ok, succeeded: body.succeeded, already: body.already }, { ok: true, succeeded: 0, already: 1 })
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('签到:全失败 → 400;无链接 → total 0 且 ok false', async () => {
|
|
146
|
+
const fail = await postCheckin(fixture([{ uid: 'a' }], async () => ({ ok: false, status: 'error', message: '凭证失效 401' })))
|
|
147
|
+
assert.equal(fail.status, 400)
|
|
148
|
+
assert.equal(fail.body.ok, false)
|
|
149
|
+
const empty = await postCheckin(fixture([], async () => ({ ok: true, status: 'ok' })))
|
|
150
|
+
assert.equal(empty.status, 400)
|
|
151
|
+
assert.equal(empty.body.total, 0)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('刷新:等后台积分落地后返回最新快照(changed=true)', async () => {
|
|
155
|
+
// 模拟插件 fire-and-forget 刷积分:第 2 次 status() 起才带上新值
|
|
156
|
+
let ticks = 0
|
|
157
|
+
const { status, body } = await post<RefreshPayload>(
|
|
158
|
+
fixture(() => [{ uid: 'a', credits: ticks++ >= 2 ? 120 : 0 }], async () => ({ ok: true, status: 'ok' })),
|
|
159
|
+
'links/refresh',
|
|
160
|
+
)
|
|
161
|
+
assert.equal(status, 200)
|
|
162
|
+
assert.equal(body.ok, true)
|
|
163
|
+
assert.equal(body.changed, true)
|
|
164
|
+
assert.equal(body.accounts[0]?.credits, 120)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('刷新:快照稳定就早退,不死等超时', async () => {
|
|
168
|
+
const started = Date.now()
|
|
169
|
+
const { status, body } = await post<RefreshPayload>(
|
|
170
|
+
fixture([{ uid: 'a', credits: 7 }], async () => ({ ok: true, status: 'ok' })),
|
|
171
|
+
'links/refresh',
|
|
172
|
+
)
|
|
173
|
+
assert.equal(status, 200)
|
|
174
|
+
assert.equal(body.changed, false)
|
|
175
|
+
assert.equal(body.accounts[0]?.credits, 7)
|
|
176
|
+
// settleStatus 稳定 2 次即 break(~600ms),远小于 3s 超时
|
|
177
|
+
assert.ok(Date.now() - started < 2000, `刷新不该等满超时,实际 ${Date.now() - started}ms`)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('刷新:status() 抛错 → 500 带错误信息', async () => {
|
|
181
|
+
const supplier = {
|
|
182
|
+
id: 'fake',
|
|
183
|
+
name: 'fake',
|
|
184
|
+
status: (): never => { throw new Error('status boom') },
|
|
185
|
+
__module: { checkinNow: async () => ({ ok: true, status: 'ok' }) },
|
|
186
|
+
}
|
|
187
|
+
const { status, body } = await post<RefreshPayload>(
|
|
188
|
+
{ supplier, capabilities: new Set(['checkinNow']), source: 'builtin' } as never,
|
|
189
|
+
'links/refresh',
|
|
190
|
+
)
|
|
191
|
+
assert.equal(status, 500)
|
|
192
|
+
assert.equal(body.ok, false)
|
|
193
|
+
assert.match(body.error ?? '', /status boom/)
|
|
194
|
+
})
|