w-dispatch-ai 1.0.21 → 1.0.23
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 +627 -569
- package/dist/w-dispatch-ai.umd.js +2 -2
- package/dist/w-dispatch-ai.umd.js.map +1 -1
- package/docs/WDispatchAi.mjs.html +12 -4
- package/docs/adapters.mjs.html +5 -3
- package/docs/budgetFor.mjs.html +2 -2
- package/docs/buildValidator.mjs.html +166 -0
- package/docs/castPintOr.mjs.html +2 -2
- package/docs/dfTimeoutMs.mjs.html +2 -2
- package/docs/dispatchAi.mjs.html +2 -2
- package/docs/dispatchAiFallback.mjs.html +2 -2
- package/docs/dispatchAiWkf.mjs.html +2 -2
- package/docs/dispatchAntigravity.mjs.html +2 -2
- package/docs/dispatchApiOpenaiCompat.mjs.html +4 -68
- package/docs/dispatchApiOpenaiResponses.mjs.html +498 -0
- package/docs/dispatchClaude.mjs.html +2 -2
- package/docs/dispatchCodex.mjs.html +2 -2
- package/docs/dispatchOpencode.mjs.html +2 -2
- package/docs/getCliArgs.mjs.html +2 -2
- package/docs/getErrorResult.mjs.html +2 -2
- package/docs/getErrorType.mjs.html +5 -3
- package/docs/global.html +11790 -3425
- package/docs/index.html +2 -2
- package/docs/quota_dfQuotaTimeoutMs.mjs.html +99 -0
- package/docs/quota_fetchQuotaJson.mjs.html +348 -0
- package/docs/quota_fromCodexUsageHttp.mjs.html +335 -0
- package/docs/quota_getQuotaAntigravity.mjs.html +463 -0
- package/docs/quota_getQuotaClaude.mjs.html +457 -0
- package/docs/quota_getQuotaCodex.mjs.html +515 -0
- package/docs/quota_readJsonOrNull.mjs.html +113 -0
- package/docs/quota_toQuotaLabel.mjs.html +146 -0
- package/docs/quota_toQuotaResult.mjs.html +214 -0
- package/docs/quota_toQuotaScopedLabel.mjs.html +117 -0
- package/docs/quota_toQuotaWindow.mjs.html +239 -0
- package/docs/readEnvFile.mjs.html +2 -2
- package/docs/resolveProviders.mjs.html +3 -3
- package/docs/wkf_callAiWithFallback.mjs.html +2 -2
- package/docs/wkf_createFileStore.mjs.html +2 -2
- package/docs/wkf_createUsageCounter.mjs.html +2 -2
- package/docs/wkf_extractJsonLoose.mjs.html +2 -2
- package/docs/wkf_noSideEffectPrefix.mjs.html +2 -2
- package/docs/wkf_runFanout.mjs.html +2 -2
- package/docs/wkf_runFanoutPipeline.mjs.html +2 -2
- package/docs/wkf_runRolePipeline.mjs.html +2 -2
- package/docs/wkf_salvageTruncatedArray.mjs.html +2 -2
- package/g.mjs +2 -2
- package/package.json +2 -2
- package/src/WDispatchAi.mjs +10 -2
- package/src/adapters.mjs +3 -1
- package/src/buildValidator.mjs +94 -0
- package/src/dispatchApiOpenaiCompat.mjs +2 -66
- package/src/dispatchApiOpenaiResponses.mjs +426 -0
- package/src/getErrorType.mjs +3 -1
- package/src/providers.mjs +303 -213
- package/src/quota/dfQuotaTimeoutMs.mjs +27 -0
- package/src/quota/fetchQuotaJson.mjs +276 -0
- package/src/quota/fromCodexUsageHttp.mjs +263 -0
- package/src/quota/getQuotaAntigravity.mjs +391 -0
- package/src/quota/getQuotaClaude.mjs +385 -0
- package/src/quota/getQuotaCodex.mjs +443 -0
- package/src/quota/readJsonOrNull.mjs +41 -0
- package/src/quota/toQuotaLabel.mjs +74 -0
- package/src/quota/toQuotaResult.mjs +142 -0
- package/src/quota/toQuotaScopedLabel.mjs +45 -0
- package/src/quota/toQuotaWindow.mjs +167 -0
- package/src/resolveProviders.mjs +1 -1
- package/test/tools/fakeCliForTest.mjs +4 -3
- package/test/tools/fakeServerForApiTest.mjs +108 -3
- package/test/tools/fakeServerForQuotaTest.mjs +171 -0
- package/test/unit-WDispatchAi.test.mjs +19 -6
- package/test/unit-adapters.test.mjs +5 -3
- package/test/unit-dispatchAi.test.mjs +1 -1
- package/test/unit-dispatchApiOpenaiResponses.test.mjs +198 -0
- package/test/unit-fetchQuotaJson.test.mjs +70 -0
- package/test/unit-fromCodexUsageHttp.test.mjs +76 -0
- package/test/unit-getQuotaAntigravity.test.mjs +123 -0
- package/test/unit-getQuotaClaude.test.mjs +137 -0
- package/test/unit-getQuotaCodex.test.mjs +191 -0
- package/test/unit-readJsonOrNull.test.mjs +44 -0
- package/test/unit-toQuotaLabel.test.mjs +43 -0
- package/test/unit-toQuotaResult.test.mjs +53 -0
- package/test/unit-toQuotaWindow.test.mjs +73 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import getQuotaAntigravity from '../src/quota/getQuotaAntigravity.mjs'
|
|
3
|
+
import createFakeCli from './tools/fakeCliForTest.mjs'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
//假agy: 依MODE決定--version與-p /usage之行為; 帶--log-file時寫入含applyAuthResult之log
|
|
7
|
+
//(模擬2026-09-05實測agy 1.1.27之print模式JSON形狀)
|
|
8
|
+
let codeAgy = (mode) => `
|
|
9
|
+
import fs from 'fs'
|
|
10
|
+
let args = process.argv.slice(2)
|
|
11
|
+
let mode = '${mode}'
|
|
12
|
+
let say = (o) => process.stdout.write((typeof o === 'string' ? o : JSON.stringify(o)) + '\\n')
|
|
13
|
+
if (args.includes('--version')) {
|
|
14
|
+
say(mode === 'old' ? '1.1.10' : '1.1.27')
|
|
15
|
+
process.exit(0)
|
|
16
|
+
}
|
|
17
|
+
let iLog = args.indexOf('--log-file')
|
|
18
|
+
if (iLog >= 0) {
|
|
19
|
+
fs.writeFileSync(args[iLog + 1], '[info] boot\\n[info] applyAuthResult: email=agy-user@example.com, authMethod=consumer\\n', 'utf8')
|
|
20
|
+
}
|
|
21
|
+
let cmd = args[args.indexOf('-p') + 1]
|
|
22
|
+
if (mode === 'prompt') {
|
|
23
|
+
say({ status: 'SUCCESS', num_turns: 1, response: 'I treated it as a prompt' })
|
|
24
|
+
process.exit(0)
|
|
25
|
+
}
|
|
26
|
+
if (mode === 'fail') {
|
|
27
|
+
say({ status: 'ERROR', num_turns: 0, response: 'backend down', command: { name: 'usage' } })
|
|
28
|
+
process.exit(0)
|
|
29
|
+
}
|
|
30
|
+
if (cmd === '/usage') {
|
|
31
|
+
say('noise line before json')
|
|
32
|
+
say({ status: 'SUCCESS', num_turns: 0, usage: {}, command: { name: 'usage', data: { description: 'd', groups: [
|
|
33
|
+
{ name: 'Gemini Models', buckets: [
|
|
34
|
+
{ id: 'gemini-weekly', window: 'weekly', remaining_fraction: 0.7852, reset_time: '2099-01-03T00:00:00Z' },
|
|
35
|
+
{ id: 'gemini-5h', window: '5h', remaining_fraction: '0.9829', reset_time: '2099-01-01T05:00:00Z' },
|
|
36
|
+
] },
|
|
37
|
+
{ name: 'Claude and GPT models', buckets: [
|
|
38
|
+
{ id: '3p-5h', window: '5h', remaining_fraction: 0, reset_time: '2099-01-01T05:00:00Z' },
|
|
39
|
+
{ id: '3p-x', window: '3d', remaining_fraction: 1, disabled: true },
|
|
40
|
+
{ window: '5h', remaining_fraction: 1 },
|
|
41
|
+
] },
|
|
42
|
+
] } } })
|
|
43
|
+
process.exit(0)
|
|
44
|
+
}
|
|
45
|
+
if (cmd === '/credits') {
|
|
46
|
+
say({ status: 'SUCCESS', num_turns: 0, command: { name: 'credits', data: { remaining_credits: 123, upgrade_uri: 'https://x' } } })
|
|
47
|
+
process.exit(0)
|
|
48
|
+
}
|
|
49
|
+
say({})
|
|
50
|
+
process.exit(0)
|
|
51
|
+
`
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
describe('getQuotaAntigravity', function() {
|
|
55
|
+
|
|
56
|
+
let fakes = []
|
|
57
|
+
let mkFake = (mode) => {
|
|
58
|
+
let f = createFakeCli(`fake-agy-${mode}`, codeAgy(mode))
|
|
59
|
+
fakes.push(f)
|
|
60
|
+
return f.exe
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
after(function() {
|
|
64
|
+
for (let f of fakes) {
|
|
65
|
+
f.clean()
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('成功: 版本把關通過, 自log取得email, 各群組桶正規化(剩餘比例反推已用, 字串型比例亦可), 停用/用罄之severity明示, 無id之桶略過', async function() {
|
|
70
|
+
let r = await getQuotaAntigravity('', { exe: mkFake('ok'), timeoutMs: 20000 })
|
|
71
|
+
let rr = [
|
|
72
|
+
r.ok, r.matched, r.provider, r.email, r.plan, r.source, r.raw.version, r.raw.authMethod,
|
|
73
|
+
r.windows.map((w) => `${w.key}|${w.label}|${w.usedPercent}|${w.scope}|${w.severity}`),
|
|
74
|
+
r.credits,
|
|
75
|
+
]
|
|
76
|
+
assert.strict.deepEqual(rr, [
|
|
77
|
+
true, null, 'antigravity', 'agy-user@example.com', '', 'agy-print-usage', '1.1.27', 'consumer',
|
|
78
|
+
[
|
|
79
|
+
'gemini-weekly|7天(Gemini Models)|21.48|Gemini Models|normal',
|
|
80
|
+
'gemini-5h|5小時(Gemini Models)|1.71|Gemini Models|normal',
|
|
81
|
+
'3p-5h|5小時(Claude and GPT models)|100|Claude and GPT models|exhausted',
|
|
82
|
+
'3p-x|3天(Claude and GPT models)|0|Claude and GPT models|disabled',
|
|
83
|
+
],
|
|
84
|
+
null,
|
|
85
|
+
])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('帳號比對相符與不符', async function() {
|
|
89
|
+
let exe = mkFake('ok2')
|
|
90
|
+
let r1 = await getQuotaAntigravity('AGY-User@example.com', { exe, timeoutMs: 20000 })
|
|
91
|
+
let r2 = await getQuotaAntigravity('other@example.com', { exe, timeoutMs: 20000 })
|
|
92
|
+
let rr = [[r1.ok, r1.matched], [r2.ok, r2.matched, r2.errorType, r2.windows.length]]
|
|
93
|
+
assert.strict.deepEqual(rr, [[true, true], [false, false, 'account', 4]])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('withCredits時另執行/credits取得點數', async function() {
|
|
97
|
+
let r = await getQuotaAntigravity('', { exe: mkFake('ok3'), withCredits: true, timeoutMs: 20000 })
|
|
98
|
+
let rr = [r.ok, r.credits, r.raw.credits !== null]
|
|
99
|
+
assert.strict.deepEqual(rr, [true, { remainingCredits: 123, upgradeUri: 'https://x' }, true])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('版本低於門檻→unsupported且不執行/usage(舊版會把它當prompt跑而耗額度); checkVersion關閉則照跑', async function() {
|
|
103
|
+
let exe = mkFake('old')
|
|
104
|
+
let r1 = await getQuotaAntigravity('', { exe, timeoutMs: 20000 })
|
|
105
|
+
let r2 = await getQuotaAntigravity('', { exe, checkVersion: false, timeoutMs: 20000 })
|
|
106
|
+
let rr = [[r1.ok, r1.errorType, /1\.1\.10.*below 1\.1\.11/.test(r1.error), r1.raw.version], [r2.ok, r2.windows.length, r2.raw.version]]
|
|
107
|
+
assert.strict.deepEqual(rr, [[false, 'unsupported', true, '1.1.10'], [true, 4, '']])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('agy把指令當prompt執行(num_turns>0)→unsupported防呆; status非SUCCESS→exit', async function() {
|
|
111
|
+
let r1 = await getQuotaAntigravity('', { exe: mkFake('prompt'), timeoutMs: 20000 })
|
|
112
|
+
let r2 = await getQuotaAntigravity('', { exe: mkFake('fail'), timeoutMs: 20000 })
|
|
113
|
+
let rr = [[r1.ok, r1.errorType, /num_turns=1/.test(r1.error)], [r2.ok, r2.errorType, /status=ERROR/.test(r2.error)]]
|
|
114
|
+
assert.strict.deepEqual(rr, [[false, 'unsupported', true], [false, 'exit', true]])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('exe不存在→notfound且附安裝指引', async function() {
|
|
118
|
+
let r = await getQuotaAntigravity('', { exe: 'agy-no-such-exe-xyz', timeoutMs: 20000 })
|
|
119
|
+
let rr = [r.ok, r.errorType, /antigravity\.google\/docs\/cli/.test(r.error)]
|
|
120
|
+
assert.strict.deepEqual(rr, [false, 'notfound', true])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { fileURLToPath } from 'url'
|
|
4
|
+
import assert from 'assert'
|
|
5
|
+
import getQuotaClaude from '../src/quota/getQuotaClaude.mjs'
|
|
6
|
+
import fakeServerForQuotaTest from './tools/fakeServerForQuotaTest.mjs'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
//產物落test/tmp/(同fakeCliForTest慣例), 測試結束清除
|
|
10
|
+
let FD = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'tmp', 'quota-claude')
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
//建立一組假Claude設定目錄(憑證檔+帳號檔), 回傳目錄路徑
|
|
14
|
+
let mkConfigDir = (name, cred, conf) => {
|
|
15
|
+
let fd = path.join(FD, name)
|
|
16
|
+
fs.mkdirSync(fd, { recursive: true })
|
|
17
|
+
if (cred !== null) {
|
|
18
|
+
fs.writeFileSync(path.join(fd, '.credentials.json'), JSON.stringify(cred), 'utf8')
|
|
19
|
+
}
|
|
20
|
+
if (conf !== null) {
|
|
21
|
+
fs.writeFileSync(path.join(fd, '.claude.json'), JSON.stringify(conf), 'utf8')
|
|
22
|
+
}
|
|
23
|
+
return fd
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
describe('getQuotaClaude', function() {
|
|
28
|
+
|
|
29
|
+
let svr = null
|
|
30
|
+
let base = null
|
|
31
|
+
|
|
32
|
+
before(async function() {
|
|
33
|
+
svr = await fakeServerForQuotaTest()
|
|
34
|
+
fs.mkdirSync(FD, { recursive: true })
|
|
35
|
+
//env注入空物件: 隔離本機之CLAUDE_CONFIG_DIR/CLAUDE_CODE_OAUTH_TOKEN/ANTHROPIC_API_KEY
|
|
36
|
+
base = { env: {}, usageUrl: svr.usageUrlClaude, profileUrl: svr.profileUrlClaude, timeoutMs: 5000 }
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
after(async function() {
|
|
40
|
+
if (svr) {
|
|
41
|
+
await svr.close()
|
|
42
|
+
}
|
|
43
|
+
fs.rmSync(FD, { recursive: true, force: true })
|
|
44
|
+
try {
|
|
45
|
+
fs.rmdirSync(path.dirname(FD))
|
|
46
|
+
}
|
|
47
|
+
catch (e) {}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('成功: 優先取limits[](含模型別週限額), plan/planTier取自憑證檔, email取自帳號檔', async function() {
|
|
51
|
+
let configDir = mkConfigDir('ok',
|
|
52
|
+
{ claudeAiOauth: { accessToken: 'tok-ok', subscriptionType: 'max', rateLimitTier: 'default_claude_max_20x', expiresAt: 4102444800000 } },
|
|
53
|
+
{ oauthAccount: { emailAddress: 'claude-user@example.com' } },
|
|
54
|
+
)
|
|
55
|
+
let r = await getQuotaClaude('', { ...base, configDir })
|
|
56
|
+
let rr = [
|
|
57
|
+
r.ok, r.matched, r.provider, r.email, r.plan, r.planTier, r.source,
|
|
58
|
+
r.windows.map((w) => `${w.key}|${w.label}|${w.usedPercent}|${w.active}|${w.severity}`),
|
|
59
|
+
r.raw.configDir === configDir,
|
|
60
|
+
r.credits.spendCurrency,
|
|
61
|
+
]
|
|
62
|
+
assert.strict.deepEqual(rr, [
|
|
63
|
+
true, null, 'claude', 'claude-user@example.com', 'max', 'default_claude_max_20x', 'anthropic-oauth-usage-api',
|
|
64
|
+
['session|5小時|3|false|normal', 'weekly_all|7天|13|false|normal', 'weekly_scoped|7天(Fable)|15|true|normal'],
|
|
65
|
+
true,
|
|
66
|
+
'USD',
|
|
67
|
+
])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('帳號比對: 相符ok為true; 不符ok為false且matched為false但額度仍回傳', async function() {
|
|
71
|
+
let configDir = mkConfigDir('match',
|
|
72
|
+
{ claudeAiOauth: { accessToken: 'tok-ok' } },
|
|
73
|
+
{ oauthAccount: { emailAddress: 'Claude-User@Example.com' } },
|
|
74
|
+
)
|
|
75
|
+
let r1 = await getQuotaClaude('claude-user@example.com', { ...base, configDir })
|
|
76
|
+
let r2 = await getQuotaClaude('other@example.com', { ...base, configDir })
|
|
77
|
+
let rr = [[r1.ok, r1.matched], [r2.ok, r2.matched, r2.errorType, r2.windows.length]]
|
|
78
|
+
assert.strict.deepEqual(rr, [[true, true], [false, false, 'account', 3]])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('無limits[]時回退頂層舊欄位(含模型別), 標籤由秒數推導', async function() {
|
|
82
|
+
let configDir = mkConfigDir('legacy', { claudeAiOauth: { accessToken: 'tok-legacy' } }, { oauthAccount: { emailAddress: 'a@b.c' } })
|
|
83
|
+
let r = await getQuotaClaude('', { ...base, configDir })
|
|
84
|
+
let rr = r.windows.map((w) => `${w.key}|${w.label}|${w.usedPercent}|${w.scope}`)
|
|
85
|
+
assert.strict.deepEqual(rr, ['five_hour|5小時|42|', 'seven_day|7天|7|', 'seven_day_sonnet|7天(Sonnet)|1|Sonnet'])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('憑證不存在→notfound(訊息含檔案路徑與指引); 非訂閱模式環境變數→unsupported', async function() {
|
|
89
|
+
let configDir = mkConfigDir('nocred', null, null)
|
|
90
|
+
let r1 = await getQuotaClaude('', { ...base, configDir })
|
|
91
|
+
let r2 = await getQuotaClaude('', { ...base, configDir, env: { ANTHROPIC_API_KEY: 'sk-ant-xxx' } })
|
|
92
|
+
let rr = [
|
|
93
|
+
[r1.ok, r1.errorType, r1.error.includes('.credentials.json'), r1.error.includes('claude auth login')],
|
|
94
|
+
[r2.ok, r2.errorType, r2.error.includes('ANTHROPIC_API_KEY')],
|
|
95
|
+
]
|
|
96
|
+
assert.strict.deepEqual(rr, [[false, 'notfound', true, true], [false, 'unsupported', true]])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('權杖被拒(401)→auth, 訊息附本機到期時刻與「執行一次claude自行刷新、不需重新登入」指引', async function() {
|
|
100
|
+
let configDir = mkConfigDir('expired', { claudeAiOauth: { accessToken: 'tok-401', expiresAt: 946684800000 } }, { oauthAccount: { emailAddress: 'a@b.c' } })
|
|
101
|
+
let r = await getQuotaClaude('', { ...base, configDir })
|
|
102
|
+
//錯誤訊息不得外洩權杖明文(redact於fetchQuotaJson層施作)
|
|
103
|
+
let rr = [r.ok, r.errorType, /local recorded expiry: 2000-01-01/.test(r.error), /re-login is not needed/.test(r.error), /tok-401/.test(r.error)]
|
|
104
|
+
assert.strict.deepEqual(rr, [false, 'auth', true, true, false])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('環境變數CLAUDE_CODE_OAUTH_TOKEN不作為權杖來源(setup-token缺user:profile scope, 2026-09-05實測403): 有憑證檔即用憑證檔; 無憑證檔時notfound並說明該變數被忽略之原因', async function() {
|
|
108
|
+
let configDir = mkConfigDir('envtok', { claudeAiOauth: { accessToken: 'tok-ok', subscriptionType: 'max' } }, { oauthAccount: { emailAddress: 'a@b.c' } })
|
|
109
|
+
let configDir2 = mkConfigDir('envtok-nocred', null, null)
|
|
110
|
+
//環境變數給一枚會被端點拒絕的權杖, 若被誤用為來源便會401而非ok
|
|
111
|
+
let r1 = await getQuotaClaude('', { ...base, configDir, env: { CLAUDE_CODE_OAUTH_TOKEN: 'tok-401' } })
|
|
112
|
+
let r2 = await getQuotaClaude('', { ...base, configDir: configDir2, env: { CLAUDE_CODE_OAUTH_TOKEN: 'tok-ok' } })
|
|
113
|
+
let r3 = await getQuotaClaude('', { ...base, configDir: configDir2 })
|
|
114
|
+
let rr = [
|
|
115
|
+
[r1.ok, r1.email, r1.plan],
|
|
116
|
+
[r2.ok, r2.errorType, /CLAUDE_CODE_OAUTH_TOKEN is set but is ignored/.test(r2.error), /user:profile/.test(r2.error)],
|
|
117
|
+
[r3.ok, r3.errorType, /CLAUDE_CODE_OAUTH_TOKEN/.test(r3.error)],
|
|
118
|
+
]
|
|
119
|
+
assert.strict.deepEqual(rr, [[true, 'a@b.c', 'max'], [false, 'notfound', true, true], [false, 'notfound', false]])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('帳號檔無email時改打profile端點取得(profileFallback), 關閉則email為空', async function() {
|
|
123
|
+
let configDir = mkConfigDir('noemail', { claudeAiOauth: { accessToken: 'tok-ok' } }, {})
|
|
124
|
+
let r1 = await getQuotaClaude('', { ...base, configDir })
|
|
125
|
+
let r2 = await getQuotaClaude('', { ...base, configDir, profileFallback: false })
|
|
126
|
+
let rr = [[r1.ok, r1.email], [r2.ok, r2.email]]
|
|
127
|
+
assert.strict.deepEqual(rr, [[true, 'profile-user@example.com'], [true, '']])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('403之oauth_not_allowed_for_organization附組織限制說明', async function() {
|
|
131
|
+
let configDir = mkConfigDir('org', { claudeAiOauth: { accessToken: 'tok-403-org' } }, { oauthAccount: { emailAddress: 'a@b.c' } })
|
|
132
|
+
let r = await getQuotaClaude('', { ...base, configDir })
|
|
133
|
+
let rr = [r.ok, r.errorType, /organization disallows OAuth usage queries/.test(r.error)]
|
|
134
|
+
assert.strict.deepEqual(rr, [false, 'forbidden', true])
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { fileURLToPath } from 'url'
|
|
4
|
+
import assert from 'assert'
|
|
5
|
+
import getQuotaCodex from '../src/quota/getQuotaCodex.mjs'
|
|
6
|
+
import fakeServerForQuotaTest from './tools/fakeServerForQuotaTest.mjs'
|
|
7
|
+
import createFakeCli from './tools/fakeCliForTest.mjs'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
//產物落test/tmp/(同fakeCliForTest慣例), 測試結束清除
|
|
11
|
+
let FD = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'tmp', 'quota-codex')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
//假codex app-server: 一行一則JSON之RPC, 依MODE決定account/read之回應
|
|
15
|
+
//(模擬2026-09-05實測之真實形狀: account.type/email/planType; rateLimits含primary/secondary與byLimitId)
|
|
16
|
+
let codeAppServer = (mode) => `
|
|
17
|
+
import readline from 'readline'
|
|
18
|
+
let mode = '${mode}'
|
|
19
|
+
let rl = readline.createInterface({ input: process.stdin })
|
|
20
|
+
rl.on('line', (l) => {
|
|
21
|
+
let m = null
|
|
22
|
+
try {
|
|
23
|
+
m = JSON.parse(l)
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
if (m.id === undefined) {
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
let out = (result) => process.stdout.write(JSON.stringify({ id: m.id, result }) + '\\n')
|
|
32
|
+
let err = (message) => process.stdout.write(JSON.stringify({ id: m.id, error: { code: -1, message } }) + '\\n')
|
|
33
|
+
if (m.method === 'initialize') {
|
|
34
|
+
return out({ userAgent: 'fake-codex' })
|
|
35
|
+
}
|
|
36
|
+
if (m.method === 'account/read') {
|
|
37
|
+
if (mode === 'nologin') return out({ account: null })
|
|
38
|
+
if (mode === 'apikey') return out({ account: { type: 'apikey' } })
|
|
39
|
+
if (mode === 'rpcfail') return err('boom')
|
|
40
|
+
return out({ account: { type: 'chatgpt', email: 'codex-user@example.com', planType: 'plus' } })
|
|
41
|
+
}
|
|
42
|
+
if (m.method === 'account/rateLimits/read') {
|
|
43
|
+
return out({
|
|
44
|
+
rateLimits: {
|
|
45
|
+
limitId: 'codex', planType: 'plus',
|
|
46
|
+
primary: { usedPercent: 0, windowDurationMins: 300, resetsAt: 4102444800 },
|
|
47
|
+
secondary: { usedPercent: 31, windowDurationMins: 10080, resetsAt: 4102444800 },
|
|
48
|
+
credits: { hasCredits: false, unlimited: false, balance: '0' },
|
|
49
|
+
},
|
|
50
|
+
rateLimitsByLimitId: {
|
|
51
|
+
codex: {},
|
|
52
|
+
'codex-spark': { limitName: 'GPT-5.3-Codex-Spark', primary: { usedPercent: 12, windowDurationMins: 300, resetsAt: 4102444800 } },
|
|
53
|
+
},
|
|
54
|
+
rateLimitResetCredits: { availableCount: 1, credits: [{ id: 'c1', title: 'Reset' }] },
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
err('unknown method ' + m.method)
|
|
58
|
+
})
|
|
59
|
+
rl.on('close', () => process.exit(0))
|
|
60
|
+
`
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
//以base64url組一枚不驗簽之JWT(僅供解出payload)
|
|
64
|
+
let mkJwt = (payload) => {
|
|
65
|
+
let b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url')
|
|
66
|
+
return `${b64({ alg: 'none' })}.${b64(payload)}.sig`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
//建立假codexHome(auth.json)
|
|
71
|
+
let mkCodexHome = (name, auth) => {
|
|
72
|
+
let fd = path.join(FD, name)
|
|
73
|
+
fs.mkdirSync(fd, { recursive: true })
|
|
74
|
+
if (auth !== null) {
|
|
75
|
+
fs.writeFileSync(path.join(fd, 'auth.json'), JSON.stringify(auth), 'utf8')
|
|
76
|
+
}
|
|
77
|
+
return fd
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
describe('getQuotaCodex', function() {
|
|
82
|
+
|
|
83
|
+
let svr = null
|
|
84
|
+
let fakes = []
|
|
85
|
+
let mkFake = (mode) => {
|
|
86
|
+
let f = createFakeCli(`fake-codex-${mode}`, codeAppServer(mode))
|
|
87
|
+
fakes.push(f)
|
|
88
|
+
return f.exe
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
before(async function() {
|
|
92
|
+
svr = await fakeServerForQuotaTest()
|
|
93
|
+
fs.mkdirSync(FD, { recursive: true })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
after(async function() {
|
|
97
|
+
if (svr) {
|
|
98
|
+
await svr.close()
|
|
99
|
+
}
|
|
100
|
+
for (let f of fakes) {
|
|
101
|
+
f.clean()
|
|
102
|
+
}
|
|
103
|
+
fs.rmSync(FD, { recursive: true, force: true })
|
|
104
|
+
try {
|
|
105
|
+
fs.rmdirSync(path.dirname(FD))
|
|
106
|
+
}
|
|
107
|
+
catch (e) {}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('app-server主路徑: 帳號與額度經JSON-RPC取得, byLimitId另列並略過與彙總同id者, severity對稱推導', async function() {
|
|
111
|
+
let r = await getQuotaCodex('', { exe: mkFake('ok'), env: {}, timeoutMs: 20000 })
|
|
112
|
+
let rr = [
|
|
113
|
+
r.ok, r.matched, r.provider, r.email, r.plan, r.source,
|
|
114
|
+
r.windows.map((w) => `${w.key}|${w.label}|${w.usedPercent}|${w.scope}|${w.severity}`),
|
|
115
|
+
r.credits.resetCreditsAvailable,
|
|
116
|
+
r.raw.accountType,
|
|
117
|
+
]
|
|
118
|
+
assert.strict.deepEqual(rr, [
|
|
119
|
+
true, null, 'codex', 'codex-user@example.com', 'plus', 'codex-app-server',
|
|
120
|
+
['primary|5小時|0||normal', 'secondary|7天|31||normal', 'codex-spark:primary|5小時(GPT-5.3-Codex-Spark)|12|GPT-5.3-Codex-Spark|normal'],
|
|
121
|
+
1,
|
|
122
|
+
'chatgpt',
|
|
123
|
+
])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('app-server回報尚未登入(account為null)→notfound且不走備援(以notLoggedIn旗標判別, 非字串比對)', async function() {
|
|
127
|
+
let codexHome = mkCodexHome('nologin', { tokens: { access_token: 'tok-ok' } })
|
|
128
|
+
let r = await getQuotaCodex('', { exe: mkFake('nologin'), env: {}, codexHome, usageUrl: svr.usageUrlCodex, timeoutMs: 20000 })
|
|
129
|
+
let rr = [r.ok, r.errorType, r.source, /codex login/.test(r.error)]
|
|
130
|
+
assert.strict.deepEqual(rr, [false, 'notfound', 'codex-app-server', true])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('app-server回報非chatgpt帳號型別(如apikey)→unsupported且不走備援', async function() {
|
|
134
|
+
let codexHome = mkCodexHome('apikey-rpc', { tokens: { access_token: 'tok-ok' } })
|
|
135
|
+
let r = await getQuotaCodex('', { exe: mkFake('apikey'), env: {}, codexHome, usageUrl: svr.usageUrlCodex, timeoutMs: 20000 })
|
|
136
|
+
let rr = [r.ok, r.errorType, r.source, /\[apikey\]/.test(r.error)]
|
|
137
|
+
assert.strict.deepEqual(rr, [false, 'unsupported', 'codex-app-server', true])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('app-server之RPC失敗或exe不存在→退回HTTP備援, 成功時raw記錄app-server之錯誤', async function() {
|
|
141
|
+
let codexHome = mkCodexHome('fallback', {
|
|
142
|
+
auth_mode: 'chatgpt',
|
|
143
|
+
tokens: { access_token: 'tok-ok', account_id: 'acct-123456', id_token: mkJwt({ 'email': 'jwt@example.com', 'https://api.openai.com/auth': { chatgpt_plan_type: 'pro' } }) },
|
|
144
|
+
})
|
|
145
|
+
let r1 = await getQuotaCodex('', { exe: mkFake('rpcfail'), env: {}, codexHome, usageUrl: svr.usageUrlCodex, timeoutMs: 20000 })
|
|
146
|
+
let r2 = await getQuotaCodex('', { exe: 'codex-no-such-exe-xyz', env: {}, codexHome, usageUrl: svr.usageUrlCodex, timeoutMs: 20000 })
|
|
147
|
+
let rr = [
|
|
148
|
+
[r1.ok, r1.source, r1.email, r1.plan, r1.windows.length, /boom/.test(r1.raw.appServerError)],
|
|
149
|
+
[r2.ok, r2.source, r2.raw.appServerError !== ''],
|
|
150
|
+
]
|
|
151
|
+
//回應之email/plan為權威值(覆蓋id_token所解); fixture有主窗口2+code review1+additional1=4
|
|
152
|
+
assert.strict.deepEqual(rr, [[true, 'chatgpt-wham-usage-api', 'codex-user@example.com', 'plus', 4, true], [true, 'chatgpt-wham-usage-api', true]])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('fallbackHttp為false時app-server失敗即回報, 不打備援', async function() {
|
|
156
|
+
let r = await getQuotaCodex('', { exe: mkFake('rpcfail'), env: {}, fallbackHttp: false, usageUrl: svr.usageUrlCodex, timeoutMs: 20000 })
|
|
157
|
+
let rr = [r.ok, r.source, r.errorType]
|
|
158
|
+
assert.strict.deepEqual(rr, [false, 'codex-app-server', 'rpc'])
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('HTTP備援(useAppServer:false): 憑證不存在→notfound; API key模式→unsupported; 401→auth附刷新指引', async function() {
|
|
162
|
+
let h1 = mkCodexHome('nocred', null)
|
|
163
|
+
let h2 = mkCodexHome('apikey-http', { OPENAI_API_KEY: 'sk-xxx' })
|
|
164
|
+
let h3 = mkCodexHome('expired', { tokens: { access_token: 'tok-401', id_token: mkJwt({ email: 'old@example.com' }) } })
|
|
165
|
+
let o = { useAppServer: false, env: {}, usageUrl: svr.usageUrlCodex, timeoutMs: 5000 }
|
|
166
|
+
let r1 = await getQuotaCodex('', { ...o, codexHome: h1 })
|
|
167
|
+
let r2 = await getQuotaCodex('', { ...o, codexHome: h2 })
|
|
168
|
+
let r3 = await getQuotaCodex('', { ...o, codexHome: h3 })
|
|
169
|
+
let rr = [
|
|
170
|
+
[r1.ok, r1.errorType, /auth\.json/.test(r1.error)],
|
|
171
|
+
[r2.ok, r2.errorType, /API key mode/.test(r2.error)],
|
|
172
|
+
[r3.ok, r3.errorType, r3.email, /run any codex command/.test(r3.error), /tok-401/.test(r3.error)],
|
|
173
|
+
]
|
|
174
|
+
//401時仍以id_token指出本機綁定帳號; 錯誤訊息不外洩權杖
|
|
175
|
+
assert.strict.deepEqual(rr, [[false, 'notfound', true], [false, 'unsupported', true], [false, 'auth', 'old@example.com', true, false]])
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('HTTP備援之env注入: OPENAI_API_KEY僅存在於env時亦判為API key模式', async function() {
|
|
179
|
+
let codexHome = mkCodexHome('apikey-env', {})
|
|
180
|
+
let r = await getQuotaCodex('', { useAppServer: false, env: { OPENAI_API_KEY: 'sk-yyy' }, codexHome, usageUrl: svr.usageUrlCodex, timeoutMs: 5000 })
|
|
181
|
+
let rr = [r.ok, r.errorType]
|
|
182
|
+
assert.strict.deepEqual(rr, [false, 'unsupported'])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('帳號比對: 不符時ok為false、matched為false且額度仍回傳', async function() {
|
|
186
|
+
let r = await getQuotaCodex('someone-else@example.com', { exe: mkFake('ok'), env: {}, timeoutMs: 20000 })
|
|
187
|
+
let rr = [r.ok, r.matched, r.errorType, r.windows.length, /codex-user@example.com/.test(r.error)]
|
|
188
|
+
assert.strict.deepEqual(rr, [false, false, 'account', 3, true])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { fileURLToPath } from 'url'
|
|
4
|
+
import assert from 'assert'
|
|
5
|
+
import readJsonOrNull from '../src/quota/readJsonOrNull.mjs'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
//產物落test/tmp/(同fakeCliForTest慣例), 測試結束清除
|
|
9
|
+
let FD = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'tmp', 'read-json-or-null')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
describe('readJsonOrNull', function() {
|
|
13
|
+
|
|
14
|
+
before(function() {
|
|
15
|
+
fs.mkdirSync(FD, { recursive: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
after(function() {
|
|
19
|
+
fs.rmSync(FD, { recursive: true, force: true })
|
|
20
|
+
try {
|
|
21
|
+
fs.rmdirSync(path.dirname(FD))
|
|
22
|
+
}
|
|
23
|
+
catch (e) {}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('合法JSON回解析值(物件或陣列皆可)', function() {
|
|
27
|
+
let fp1 = path.join(FD, 'a.json')
|
|
28
|
+
let fp2 = path.join(FD, 'b.json')
|
|
29
|
+
fs.writeFileSync(fp1, '{"a":1,"b":{"c":[1,2]}}', 'utf8')
|
|
30
|
+
fs.writeFileSync(fp2, '[1,2,3]', 'utf8')
|
|
31
|
+
let r = [readJsonOrNull(fp1), readJsonOrNull(fp2)]
|
|
32
|
+
let rr = [{ a: 1, b: { c: [1, 2] } }, [1, 2, 3]]
|
|
33
|
+
assert.strict.deepEqual(r, rr)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('檔案不存在、內容非JSON、路徑無效皆回null不throw(三種情形對呼叫端皆為「取不到」)', function() {
|
|
37
|
+
let fp = path.join(FD, 'broken.json')
|
|
38
|
+
fs.writeFileSync(fp, '{ not json', 'utf8')
|
|
39
|
+
let r = [readJsonOrNull(path.join(FD, 'nope.json')), readJsonOrNull(fp), readJsonOrNull(''), readJsonOrNull(null)]
|
|
40
|
+
let rr = [null, null, null, null]
|
|
41
|
+
assert.strict.deepEqual(r, rr)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import toQuotaLabel from '../src/quota/toQuotaLabel.mjs'
|
|
3
|
+
import toQuotaScopedLabel from '../src/quota/toQuotaScopedLabel.mjs'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('toQuotaLabel', function() {
|
|
7
|
+
|
|
8
|
+
it('由窗口秒數推導中文標籤: 整除者取最大單位(天>小時>分鐘), 皆不整除則秒', function() {
|
|
9
|
+
let r = [toQuotaLabel(604800), toQuotaLabel(18000), toQuotaLabel(900), toQuotaLabel(90), toQuotaLabel(86400), toQuotaLabel(3600)]
|
|
10
|
+
let rr = ['7天', '5小時', '15分鐘', '90秒', '1天', '1小時']
|
|
11
|
+
assert.strict.deepEqual(r, rr)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('無效或非正數回傳空字串(呼叫端據此判斷供應商未提供窗口長度)', function() {
|
|
15
|
+
let r = [toQuotaLabel(null), toQuotaLabel(undefined), toQuotaLabel(0), toQuotaLabel(-5), toQuotaLabel('abc'), toQuotaLabel({})]
|
|
16
|
+
let rr = ['', '', '', '', '', '']
|
|
17
|
+
assert.strict.deepEqual(r, rr)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('數值字串亦可推導(供應商可能回字串型秒數)', function() {
|
|
21
|
+
let r = toQuotaLabel('18000')
|
|
22
|
+
let rr = '5小時'
|
|
23
|
+
assert.strict.deepEqual(r, rr)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
describe('toQuotaScopedLabel', function() {
|
|
30
|
+
|
|
31
|
+
it('有範圍時為「基底(範圍)」, 基底取自toQuotaLabel(單一來源, 窗口變動標籤自動正確)', function() {
|
|
32
|
+
let r = [toQuotaScopedLabel(604800, 'Fable'), toQuotaScopedLabel(18000, 'Gemini Models')]
|
|
33
|
+
let rr = ['7天(Fable)', '5小時(Gemini Models)']
|
|
34
|
+
assert.strict.deepEqual(r, rr)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('有範圍但窗口秒數無效時僅以範圍為標籤; 無範圍回傳空字串令toQuotaWindow自行推導', function() {
|
|
38
|
+
let r = [toQuotaScopedLabel(null, 'codex-spark'), toQuotaScopedLabel(18000, ''), toQuotaScopedLabel(18000, null)]
|
|
39
|
+
let rr = ['codex-spark', '', '']
|
|
40
|
+
assert.strict.deepEqual(r, rr)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import toQuotaResult from '../src/quota/toQuotaResult.mjs'
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
describe('toQuotaResult', function() {
|
|
6
|
+
|
|
7
|
+
it('未指定emailWant時不比對(matched為null), ok僅取決於查詢本身', function() {
|
|
8
|
+
let r1 = toQuotaResult('claude', { email: 'a@b.com', windows: [{ key: 'x' }] })
|
|
9
|
+
let r2 = toQuotaResult('claude', { email: 'a@b.com', error: 'boom', errorType: 'http' })
|
|
10
|
+
let r = [[r1.ok, r1.matched, r1.windows.length], [r2.ok, r2.matched, r2.errorType]]
|
|
11
|
+
let rr = [[true, null, 1], [false, null, 'http']]
|
|
12
|
+
assert.strict.deepEqual(r, rr)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('帳號比對去空白且不分大小寫; 不符時ok為false但額度資料仍回傳, error載明本機實際帳號', function() {
|
|
16
|
+
let r1 = toQuotaResult('codex', { email: 'User@Example.com', emailWant: ' user@example.com ', windows: [{ key: 'x' }] })
|
|
17
|
+
let r2 = toQuotaResult('codex', { email: 'a@b.com', emailWant: 'c@d.com', windows: [{ key: 'x' }] })
|
|
18
|
+
let r = [
|
|
19
|
+
[r1.ok, r1.matched],
|
|
20
|
+
[r2.ok, r2.matched, r2.errorType, r2.windows.length, r2.error],
|
|
21
|
+
]
|
|
22
|
+
let rr = [
|
|
23
|
+
[true, true],
|
|
24
|
+
[false, false, 'account', 1, 'local codex login account is [a@b.com], which does not match the requested account [c@d.com]'],
|
|
25
|
+
]
|
|
26
|
+
assert.strict.deepEqual(r, rr)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('指定emailWant但無從取得本機帳號時視為不符並補述原因(errorType為account)', function() {
|
|
30
|
+
let r1 = toQuotaResult('antigravity', { emailWant: 'a@b.com' })
|
|
31
|
+
let r = [r1.ok, r1.matched, r1.errorType, r1.error]
|
|
32
|
+
let rr = [false, false, 'account', 'cannot determine the local antigravity login account, so it is unknown whether it is the requested account [a@b.com]']
|
|
33
|
+
assert.strict.deepEqual(r, rr)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('已有查詢錯誤時不以帳號比對訊息覆蓋之', function() {
|
|
37
|
+
let r1 = toQuotaResult('claude', { emailWant: 'a@b.com', error: 'unauthorized(401)', errorType: 'auth' })
|
|
38
|
+
let r = [r1.ok, r1.matched, r1.error, r1.errorType]
|
|
39
|
+
let rr = [false, false, 'unauthorized(401)', 'auth']
|
|
40
|
+
assert.strict.deepEqual(r, rr)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('回傳鍵名固定且無效輸入回退預設', function() {
|
|
44
|
+
let r1 = toQuotaResult(null, { windows: 'bad', plan: 123, credits: undefined })
|
|
45
|
+
let r = [Object.keys(r1), r1.provider, r1.windows, r1.plan, r1.credits, r1.raw, r1.durationMs]
|
|
46
|
+
let rr = [
|
|
47
|
+
['ok', 'provider', 'email', 'matched', 'plan', 'planTier', 'source', 'windows', 'credits', 'raw', 'error', 'errorType', 'durationMs'],
|
|
48
|
+
'', [], '', null, null, 0,
|
|
49
|
+
]
|
|
50
|
+
assert.strict.deepEqual(r, rr)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import toQuotaWindow from '../src/quota/toQuotaWindow.mjs'
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
describe('toQuotaWindow', function() {
|
|
6
|
+
|
|
7
|
+
it('正規化基本欄位: label由windowSeconds推導, remainingPercent由usedPercent推得', function() {
|
|
8
|
+
let w = toQuotaWindow({ key: 'five_hour', windowSeconds: 18000, usedPercent: 11 })
|
|
9
|
+
let r = [w.key, w.label, w.windowSeconds, w.usedPercent, w.remainingPercent, w.scope, w.active, w.severity]
|
|
10
|
+
let rr = ['five_hour', '5小時', 18000, 11, 89, '', false, 'normal']
|
|
11
|
+
assert.strict.deepEqual(r, rr)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('usedPercent夾至0~100且未知時remainingPercent同為null(不假造100)', function() {
|
|
15
|
+
let w1 = toQuotaWindow({ usedPercent: 150 })
|
|
16
|
+
let w2 = toQuotaWindow({ usedPercent: -3 })
|
|
17
|
+
let w3 = toQuotaWindow({})
|
|
18
|
+
let r = [[w1.usedPercent, w1.remainingPercent], [w2.usedPercent, w2.remainingPercent], [w3.usedPercent, w3.remainingPercent]]
|
|
19
|
+
let rr = [[100, 0], [0, 100], [null, null]]
|
|
20
|
+
assert.strict.deepEqual(r, rr)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('resetAt接受ISO字串或unix秒數, 一律轉ISO字串並補算resetAfterSeconds', function() {
|
|
24
|
+
let future = Math.floor(Date.now() / 1000) + 3600
|
|
25
|
+
let w1 = toQuotaWindow({ resetAt: future })
|
|
26
|
+
let w2 = toQuotaWindow({ resetAt: '2099-01-01T00:00:00.000Z' })
|
|
27
|
+
let w3 = toQuotaWindow({ resetAt: 'not-a-date' })
|
|
28
|
+
let r = [
|
|
29
|
+
w1.resetAt === new Date(future * 1000).toISOString(),
|
|
30
|
+
w1.resetAfterSeconds >= 3598 && w1.resetAfterSeconds <= 3600,
|
|
31
|
+
w2.resetAt,
|
|
32
|
+
w2.resetAfterSeconds > 0,
|
|
33
|
+
w3.resetAt,
|
|
34
|
+
w3.resetAfterSeconds,
|
|
35
|
+
]
|
|
36
|
+
let rr = [true, true, '2099-01-01T00:00:00.000Z', true, '', null]
|
|
37
|
+
assert.strict.deepEqual(r, rr)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('只給resetAfterSeconds時反推resetAt; 已過期之resetAt其resetAfterSeconds夾為0', function() {
|
|
41
|
+
let w1 = toQuotaWindow({ resetAfterSeconds: 120 })
|
|
42
|
+
let w2 = toQuotaWindow({ resetAt: '2000-01-01T00:00:00.000Z' })
|
|
43
|
+
let dt = Math.round((new Date(w1.resetAt).getTime() - Date.now()) / 1000)
|
|
44
|
+
let r = [dt >= 118 && dt <= 120, w1.resetAfterSeconds, w2.resetAfterSeconds]
|
|
45
|
+
let rr = [true, 120, 0]
|
|
46
|
+
assert.strict.deepEqual(r, rr)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('severity: 供應商有給即用之; 未給則由usedPercent推導(用罄exhausted/其餘normal/未知空字串)——三家對稱', function() {
|
|
50
|
+
let r = [
|
|
51
|
+
toQuotaWindow({ usedPercent: 50, severity: 'warning' }).severity,
|
|
52
|
+
toQuotaWindow({ usedPercent: 50 }).severity,
|
|
53
|
+
toQuotaWindow({ usedPercent: 100 }).severity,
|
|
54
|
+
toQuotaWindow({}).severity,
|
|
55
|
+
]
|
|
56
|
+
let rr = ['warning', 'normal', 'exhausted', '']
|
|
57
|
+
assert.strict.deepEqual(r, rr)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('明給label優先於推導; scope與active原樣保留', function() {
|
|
61
|
+
let w = toQuotaWindow({ windowSeconds: 604800, label: '7天(Fable)', scope: 'Fable', active: true })
|
|
62
|
+
let r = [w.label, w.scope, w.active]
|
|
63
|
+
let rr = ['7天(Fable)', 'Fable', true]
|
|
64
|
+
assert.strict.deepEqual(r, rr)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('回傳鍵名固定', function() {
|
|
68
|
+
let r = Object.keys(toQuotaWindow({}))
|
|
69
|
+
let rr = ['key', 'label', 'windowSeconds', 'usedPercent', 'remainingPercent', 'resetAt', 'resetAfterSeconds', 'scope', 'active', 'severity']
|
|
70
|
+
assert.strict.deepEqual(r, rr)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
})
|