From 04e1ada80a0beb302ffa4dae9c188579f5035313 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 30 Jun 2026 12:42:11 +0800 Subject: [PATCH] =?UTF-8?q?test(calculate-version):=20=E6=95=B4=E4=BD=B5?= =?UTF-8?q?=E9=87=8D=E8=A4=87=E6=B8=AC=E8=A9=A6=E8=87=B3=20app/test=20?= =?UTF-8?q?=E4=B8=A6=E6=96=B0=E5=A2=9E=20logger=20=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/test/config.test.js | 46 ++++++++++++-- {test => app/test}/index.test.js | 2 +- app/test/logger.test.js | 53 ++++++++++++++++ app/test/releases.test.js | 8 +++ test/config.test.js | 75 ---------------------- test/releases.test.js | 105 ------------------------------- test/version.test.js | 85 ------------------------- 7 files changed, 104 insertions(+), 270 deletions(-) rename {test => app/test}/index.test.js (97%) create mode 100644 app/test/logger.test.js delete mode 100644 test/config.test.js delete mode 100644 test/releases.test.js delete mode 100644 test/version.test.js diff --git a/app/test/config.test.js b/app/test/config.test.js index 2e4f222..2fb1adb 100644 --- a/app/test/config.test.js +++ b/app/test/config.test.js @@ -36,6 +36,7 @@ test('normalizeBetaFlag 僅將字面值 "true" 視為啟用', () => { assert.equal(normalizeBetaFlag('yes'), false); assert.equal(normalizeBetaFlag(undefined), false); assert.equal(normalizeBetaFlag('null'), false); + assert.equal(normalizeBetaFlag('TRUE'), false); }); test('loadConfig 從環境變數組出設定', () => { @@ -53,16 +54,53 @@ test('loadConfig 從環境變數組出設定', () => { }); }); -test('loadConfig 將未提供的 token 正規化為 null,beta 預設 false', () => { - const config = loadConfig({ +test('loadConfig 將未提供或字面 "null" 的 token 正規化為 null,beta 預設 false', () => { + const omitted = loadConfig({ GITEA_SERVER_URL: 'https://gitea.example.com', GITEA_REPOSITORY: 'owner/repo', }); - assert.equal(config.token, null); - assert.equal(config.isBeta, false); + assert.equal(omitted.token, null); + assert.equal(omitted.isBeta, false); + + const literalNull = loadConfig({ + GITEA_SERVER_URL: 'https://gitea.example.com', + GITEA_REPOSITORY: 'owner/repo', + GITEA_TOKEN: 'null', + }); + assert.equal(literalNull.token, null); }); test('loadConfig 在缺少必填環境變數時丟出錯誤', () => { assert.throws(() => loadConfig({ GITEA_REPOSITORY: 'owner/repo' }), /GITEA_SERVER_URL 未設定/); assert.throws(() => loadConfig({ GITEA_SERVER_URL: 'https://x' }), /GITEA_REPOSITORY 未設定/); }); + +test('loadConfig 於 GITEA_SERVER_URL 非合法 URL 時拋錯', () => { + assert.throws(() => loadConfig({ + GITEA_SERVER_URL: 'not-a-url', + GITEA_REPOSITORY: 'owner/repo', + }), /GITEA_SERVER_URL 格式錯誤/); +}); + +test('loadConfig 於 GITEA_SERVER_URL 使用非 http(s) 協定時拋錯', () => { + assert.throws(() => loadConfig({ + GITEA_SERVER_URL: 'ftp://gitea.example.com', + GITEA_REPOSITORY: 'owner/repo', + }), /必須使用 http 或 https/); +}); + +test('loadConfig 於 GITEA_REPOSITORY 非 owner/repo 形式或含路徑穿越時拋錯', () => { + const base = { GITEA_SERVER_URL: 'https://gitea.example.com' }; + assert.throws(() => loadConfig({ ...base, GITEA_REPOSITORY: 'no-slash' }), /GITEA_REPOSITORY 格式錯誤/); + assert.throws(() => loadConfig({ ...base, GITEA_REPOSITORY: 'owner/repo/extra' }), /GITEA_REPOSITORY 格式錯誤/); + assert.throws(() => loadConfig({ ...base, GITEA_REPOSITORY: '../owner/repo' }), /GITEA_REPOSITORY 格式錯誤/); + assert.throws(() => loadConfig({ ...base, GITEA_REPOSITORY: 'owner/..' }), /GITEA_REPOSITORY 格式錯誤/); +}); + +test('loadConfig 接受含點號的合法 owner/repo', () => { + const config = loadConfig({ + GITEA_SERVER_URL: 'https://gitea.example.com', + GITEA_REPOSITORY: 'my.org/my.repo', + }); + assert.equal(config.repository, 'my.org/my.repo'); +}); diff --git a/test/index.test.js b/app/test/index.test.js similarity index 97% rename from test/index.test.js rename to app/test/index.test.js index 4309ba7..c9210b1 100644 --- a/test/index.test.js +++ b/app/test/index.test.js @@ -3,7 +3,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { main } = require('../app/index'); +const { main } = require('../index'); // 安靜的 log 記錄器,避免測試輸出雜訊 const silentLog = { section() {}, info() {}, error() {} }; diff --git a/app/test/logger.test.js b/app/test/logger.test.js new file mode 100644 index 0000000..b466e12 --- /dev/null +++ b/app/test/logger.test.js @@ -0,0 +1,53 @@ +'use strict'; + +const { test, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const logger = require('../logger'); + +const realStdout = process.stdout.write; +const realStderr = process.stderr.write; + +afterEach(() => { + process.stdout.write = realStdout; + process.stderr.write = realStderr; +}); + +// 同時攔截 stdout 與 stderr,回傳本次 run() 內各自寫出的字串 +function capture(run) { + const out = []; + const err = []; + process.stdout.write = (chunk) => { out.push(String(chunk)); return true; }; + process.stderr.write = (chunk) => { err.push(String(chunk)); return true; }; + try { + run(); + } finally { + process.stdout.write = realStdout; + process.stderr.write = realStderr; + } + return { out: out.join(''), err: err.join('') }; +} + +const TIME = /\[\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}\]/; + +test('section 於 stdout 輸出標題與 50 寬分隔線區塊', () => { + const { out, err } = capture(() => logger.section('參數檢查')); + assert.equal(out, `\n${'='.repeat(50)}\n參數檢查\n${'-'.repeat(50)}\n`); + assert.equal(err, ''); +}); + +test('info 以 [INF][時間]: 格式輸出至 stdout', () => { + const { out, err } = capture(() => logger.info('IS_BETA=false')); + assert.match(out, new RegExp(`^\\[INF\\]${TIME.source}: IS_BETA=false\\n$`)); + assert.equal(err, ''); +}); + +test('error 以 [ERR][時間]: 格式輸出至 stderr,且不寫入 stdout', () => { + const { out, err } = capture(() => logger.error('GITEA_SERVER_URL 未設定')); + assert.match(err, new RegExp(`^\\[ERR\\]${TIME.source}: GITEA_SERVER_URL 未設定\\n$`)); + assert.equal(out, ''); +}); + +test('info/error 的時間戳為 yyyy/MM/dd HH:mm:ss 格式', () => { + const { out } = capture(() => logger.info('x')); + assert.match(out, TIME); +}); diff --git a/app/test/releases.test.js b/app/test/releases.test.js index 1fe4c7f..25a1099 100644 --- a/app/test/releases.test.js +++ b/app/test/releases.test.js @@ -68,3 +68,11 @@ test('fetchReleases 在 null 回應時視為無更多資料', async () => { const releases = await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'); assert.deepEqual(releases, []); }); + +test('fetchReleases 在回傳無法解析的 JSON 時丟出例外', async () => { + globalThis.fetch = async () => ({ ok: true, text: async () => '{ this is not valid json' }); + await assert.rejects( + () => fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'), + /回傳資料無法解析/, + ); +}); diff --git a/test/config.test.js b/test/config.test.js deleted file mode 100644 index 03a989d..0000000 --- a/test/config.test.js +++ /dev/null @@ -1,75 +0,0 @@ -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); - -const { - isUnset, - requireEnv, - normalizeBetaFlag, - loadConfig, -} = require('../app/config'); - -test('isUnset 將 undefined/空字串/null 字面值視為未設定', () => { - assert.equal(isUnset(undefined), true); - assert.equal(isUnset(''), true); - assert.equal(isUnset('null'), true); - assert.equal(isUnset('value'), false); -}); - -test('requireEnv 未設定時拋錯,有值時回傳原值', () => { - assert.throws(() => requireEnv('FOO', ''), /FOO 未設定/); - assert.equal(requireEnv('FOO', 'bar'), 'bar'); -}); - -test('normalizeBetaFlag 僅 "true" 視為啟用', () => { - assert.equal(normalizeBetaFlag('true'), true); - assert.equal(normalizeBetaFlag('false'), false); - assert.equal(normalizeBetaFlag(undefined), false); - assert.equal(normalizeBetaFlag('TRUE'), false); -}); - -test('loadConfig 解析完整設定', () => { - const config = loadConfig({ - GITEA_SERVER_URL: 'https://gitea.example.com', - GITEA_REPOSITORY: 'owner/repo', - GITEA_TOKEN: 'secret', - IS_BETA: 'true', - }); - - assert.deepEqual(config, { - serverUrl: 'https://gitea.example.com', - repository: 'owner/repo', - token: 'secret', - isBeta: true, - }); -}); - -test('loadConfig 缺少 token 視為匿名(null)', () => { - const config = loadConfig({ - GITEA_SERVER_URL: 'https://gitea.example.com', - GITEA_REPOSITORY: 'owner/repo', - GITEA_TOKEN: 'null', - }); - - assert.equal(config.token, null); - assert.equal(config.isBeta, false); -}); - -test('loadConfig 缺少必填項目時拋錯', () => { - assert.throws(() => loadConfig({ GITEA_REPOSITORY: 'owner/repo' }), /GITEA_SERVER_URL 未設定/); -}); - -test('loadConfig 於 GITEA_SERVER_URL 非合法 URL 時拋錯', () => { - assert.throws(() => loadConfig({ - GITEA_SERVER_URL: 'not-a-url', - GITEA_REPOSITORY: 'owner/repo', - }), /GITEA_SERVER_URL 格式錯誤/); -}); - -test('loadConfig 於 GITEA_SERVER_URL 使用非 http(s) 協定時拋錯', () => { - assert.throws(() => loadConfig({ - GITEA_SERVER_URL: 'ftp://gitea.example.com', - GITEA_REPOSITORY: 'owner/repo', - }), /必須使用 http 或 https/); -}); diff --git a/test/releases.test.js b/test/releases.test.js deleted file mode 100644 index c305bf9..0000000 --- a/test/releases.test.js +++ /dev/null @@ -1,105 +0,0 @@ -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); - -const { fetchReleases } = require('../app/releases'); - -// 以可控的假回應替換全域 fetch,並於結束後還原 -function withFetch(handler, run) { - const original = globalThis.fetch; - globalThis.fetch = handler; - return Promise.resolve() - .then(run) - .finally(() => { - globalThis.fetch = original; - }); -} - -// 建立模擬 Response 物件 -const jsonResponse = (body, ok = true) => ({ - ok, - text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), -}); - -test('fetchReleases 單頁(不足一頁即停止)', async () => { - await withFetch( - async () => jsonResponse([{ tag_name: 'v1.0.0' }]), - async () => { - const releases = await fetchReleases('https://gitea.example.com/api'); - assert.deepEqual(releases, [{ tag_name: 'v1.0.0' }]); - }, - ); -}); - -test('fetchReleases 跨頁合併直到不足一頁', async () => { - const pages = { - 1: Array.from({ length: 10 }, (_, i) => ({ tag_name: `v1.0.${i}` })), - 2: [{ tag_name: 'v1.1.0' }], - }; - const seen = []; - - await withFetch( - async (url) => { - const page = Number(new URL(url).searchParams.get('page')); - seen.push(page); - return jsonResponse(pages[page]); - }, - async () => { - const releases = await fetchReleases('https://gitea.example.com/api'); - assert.equal(releases.length, 11); - assert.deepEqual(seen, [1, 2]); - }, - ); -}); - -test('fetchReleases 對非 2xx 回應拋錯', async () => { - await withFetch( - async () => jsonResponse('Not Found', false), - async () => { - await assert.rejects( - () => fetchReleases('https://gitea.example.com/api'), - /release API 請求失敗/, - ); - }, - ); -}); - -test('fetchReleases 對無法解析的 JSON 回應拋錯', async () => { - await withFetch( - async () => jsonResponse('{ this is not valid json', true), - async () => { - await assert.rejects( - () => fetchReleases('https://gitea.example.com/api'), - /回傳資料無法解析/, - ); - }, - ); -}); - -test('fetchReleases 對非陣列回應拋錯', async () => { - await withFetch( - async () => jsonResponse({ message: 'oops' }), - async () => { - await assert.rejects( - () => fetchReleases('https://gitea.example.com/api'), - /回傳非陣列資料/, - ); - }, - ); -}); - -test('fetchReleases 有 token 時帶入授權標頭', async () => { - let captured; - - await withFetch( - async (url, options) => { - captured = options; - return jsonResponse([]); - }, - async () => { - await fetchReleases('https://gitea.example.com/api', { token: 'secret' }); - assert.equal(captured.headers.Authorization, 'token secret'); - }, - ); -}); diff --git a/test/version.test.js b/test/version.test.js deleted file mode 100644 index bfdccba..0000000 --- a/test/version.test.js +++ /dev/null @@ -1,85 +0,0 @@ -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); - -const { - compareVersionArrays, - parseStableVersions, - latestStableVersion, - nextReleaseVersion, - nextBetaNumber, - calculateVersion, -} = require('../app/version'); - -// 便於建構測試資料的小工具 -const rel = (tag) => ({ tag_name: tag }); - -test('compareVersionArrays 逐區段比較且較短者較小', () => { - assert.ok(compareVersionArrays([1, 10], [1, 2, 3]) > 0); - assert.ok(compareVersionArrays([1, 2], [1, 2, 0]) < 0); - assert.equal(compareVersionArrays([1, 2, 3], [1, 2, 3]), 0); -}); - -test('parseStableVersions 排除 beta 與非法 tag 並去除 v 前綴', () => { - const releases = [ - rel('v1.2.3'), - rel('2.0.0'), - rel('v1.0.0-beta.1'), - rel('latest'), - { name: '沒有 tag_name' }, - ]; - - assert.deepEqual(parseStableVersions(releases), [[1, 2, 3], [2, 0, 0]]); -}); - -test('latestStableVersion 無資料時回傳 0.0.0', () => { - assert.equal(latestStableVersion([]), '0.0.0'); - assert.equal(latestStableVersion(null), '0.0.0'); -}); - -test('latestStableVersion 取出最大穩定版', () => { - const releases = [rel('v1.2.3'), rel('v1.9.9'), rel('v1.2.9'), rel('v0.5.0-beta.2')]; - assert.equal(latestStableVersion(releases), '1.9.9'); -}); - -test('nextReleaseVersion patch 進位', () => { - assert.equal(nextReleaseVersion('0.0.0'), '0.0.1'); - assert.equal(nextReleaseVersion('1.2.3'), '1.2.4'); -}); - -test('nextReleaseVersion patch 達 10 進位至 minor', () => { - assert.equal(nextReleaseVersion('1.2.9'), '1.3.0'); -}); - -test('nextReleaseVersion minor 達 10 進位至 major', () => { - assert.equal(nextReleaseVersion('1.9.9'), '2.0.0'); -}); - -test('nextBetaNumber 無對應 beta 時回傳 1', () => { - assert.equal(nextBetaNumber([], '0.0.1'), 1); - assert.equal(nextBetaNumber([rel('v0.0.1')], '0.0.1'), 1); -}); - -test('nextBetaNumber 取最大流水號加一', () => { - const releases = [rel('v1.3.0-beta.1'), rel('v1.3.0-beta.3'), rel('v1.3.0-beta.2')]; - assert.equal(nextBetaNumber(releases, '1.3.0'), 4); -}); - -test('calculateVersion 無 release 的穩定版', () => { - assert.deepEqual(calculateVersion([], false), { latest: '0.0.0', version: '0.0.1' }); -}); - -test('calculateVersion 無 release 的 beta 版', () => { - assert.deepEqual(calculateVersion([], true), { latest: '0.0.0', version: '0.0.1-beta.1' }); -}); - -test('calculateVersion 既有穩定版推下一個穩定版', () => { - const releases = [rel('v1.2.3'), rel('v1.2.2')]; - assert.deepEqual(calculateVersion(releases, false), { latest: '1.2.3', version: '1.2.4' }); -}); - -test('calculateVersion 既有穩定版推下一個 beta 版', () => { - const releases = [rel('v1.2.3'), rel('v1.2.4-beta.1')]; - assert.deepEqual(calculateVersion(releases, true), { latest: '1.2.3', version: '1.2.4-beta.2' }); -});