diff --git a/src/test/preflight.test.js b/src/test/preflight.test.js index 4f6e4c6..359f4e7 100644 --- a/src/test/preflight.test.js +++ b/src/test/preflight.test.js @@ -4,7 +4,7 @@ import axios from 'axios'; import { mkdtemp, writeFile, chmod, rm } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; -import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, runPreflight } from '../preflight.js'; +import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, fetchCodexModels, runPreflight } from '../preflight.js'; const LLM_ENV_KEYS = [ 'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH', @@ -130,18 +130,52 @@ describe('verifyLLM', () => { assert.match(result.error, /AI 助理 CLI/); }); - it('passes when a supported assistant CLI is detected', async () => { + it('passes when a supported assistant CLI is detected and the model is in the codex list', async () => { clearLLMEnv(); await installFakeCLI('codex'); process.env.AI_ASSISTANT_CLI = 'codex'; - process.env.MODEL = 'gpt-5-mini'; + process.env.MODEL = 'gpt-5.4-mini'; - const result = await verifyLLM(); + const result = await verifyLLM({ + fetchCodexModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }), + }); assert.equal(result.ok, true); assert.equal(result.provider, 'codex'); assert.equal(result.command, 'codex'); - assert.equal(result.model, 'gpt-5-mini'); + assert.equal(result.model, 'gpt-5.4-mini'); + assert.deepEqual(result.models, ['gpt-5.5', 'gpt-5.4-mini']); + }); + + it('fails when codex auth is invalid (model list check reports 401)', async () => { + clearLLMEnv(); + await installFakeCLI('codex'); + process.env.AI_ASSISTANT_CLI = 'codex'; + process.env.MODEL = 'gpt-5.4-mini'; + + const result = await verifyLLM({ + fetchCodexModelsFn: async () => ({ ok: false, error: 'codex 認證失效(HTTP 401)——token 已被撤銷或過期,請重新登入 codex 並更新 LLM_OAUTH secret' }), + }); + + assert.equal(result.ok, false); + assert.equal(result.provider, 'codex'); + assert.match(result.error, /HTTP 401/); + assert.match(result.error, /LLM_OAUTH/); + }); + + it('fails when the configured model is not in the codex available list', async () => { + clearLLMEnv(); + await installFakeCLI('codex'); + process.env.AI_ASSISTANT_CLI = 'codex'; + process.env.MODEL = 'gpt-9-imaginary'; + + const result = await verifyLLM({ + fetchCodexModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }), + }); + + assert.equal(result.ok, false); + assert.match(result.error, /不在 codex 可用清單/); + assert.match(result.error, /gpt-5\.4-mini/); }); it('fails when a requested CLI is not installed', async () => { @@ -157,6 +191,59 @@ describe('verifyLLM', () => { }); +describe('fetchCodexModels', () => { + async function writeAuth(json) { + tempDir = await mkdtemp(join(tmpdir(), 'codex-auth-test-')); + const authPath = join(tempDir, 'auth.json'); + await writeFile(authPath, JSON.stringify(json)); + return authPath; + } + + it('returns the model slugs on HTTP 200', async () => { + const authPath = await writeAuth({ tokens: { access_token: 'tok', account_id: 'acc' } }); + let capturedUrl, capturedHeaders; + const result = await fetchCodexModels({ + authPath, + fetchImpl: async (url, opts) => { + capturedUrl = url; + capturedHeaders = opts.headers; + return { status: 200, ok: true, json: async () => ({ models: [{ slug: 'gpt-5.5' }, { slug: 'gpt-5.4-mini' }] }) }; + }, + }); + assert.deepEqual(result, { ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }); + assert.match(capturedUrl, /client_version=/); + assert.equal(capturedHeaders['Authorization'], 'Bearer tok'); + assert.equal(capturedHeaders['chatgpt-account-id'], 'acc'); + }); + + it('reports an auth failure on HTTP 401', async () => { + const authPath = await writeAuth({ tokens: { access_token: 'revoked' } }); + const result = await fetchCodexModels({ + authPath, + fetchImpl: async () => ({ status: 401, ok: false, json: async () => ({}) }), + }); + assert.equal(result.ok, false); + assert.match(result.error, /HTTP 401/); + assert.match(result.error, /LLM_OAUTH/); + }); + + it('fails when the auth file cannot be read', async () => { + const result = await fetchCodexModels({ + authPath: join(tmpdir(), 'definitely-missing-codex-auth-xyz.json'), + fetchImpl: async () => ({ status: 200, ok: true, json: async () => ({ models: [] }) }), + }); + assert.equal(result.ok, false); + assert.match(result.error, /無法讀取 codex 認證檔/); + }); + + it('fails when the auth file lacks an access_token', async () => { + const authPath = await writeAuth({ tokens: {} }); + const result = await fetchCodexModels({ authPath, fetchImpl: async () => ({ status: 200, ok: true, json: async () => ({}) }) }); + assert.equal(result.ok, false); + assert.match(result.error, /缺少 tokens\.access_token/); + }); +}); + describe('runPreflight', () => { function makeDeps(overrides = {}) { return {