diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index e42650b..7a5fd72 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -58,5 +58,11 @@ "role": "Leo", "original_finding": "日誌分隔線寬度與符號直接硬編碼在模組內,調整風格需改多處。建議集中管理並提供通用產生函數。", "reason": "分隔線已是模組頂層集中定義的常數 LINE/SUBLINE,單點即可調整;為固定的視覺樣式再加產生函數屬過度設計。" + }, + { + "location": "app/test/releases.test.js:10", + "role": "Leo", + "original_finding": "withFetch 為全域 fetch 的通用封裝工具,目前定義在特定測試檔案內。建議提取至獨立的測試工具檔案(例如 app/test/test-utils.js)以提升重用性。", + "reason": "目前僅 releases.test.js 單一測試檔使用 withFetch(logger.test.js 使用的是不同的 stdout/stderr 攔截輔助),尚無第二個消費者;為單一用途提前抽出共用模組屬過度設計。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 93d2ebe..ef308d5 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,26 +1,45 @@ [ { - "level": "warning", - "role": "Assassin", - "location": "app/config.js:71", - "problem": "對 GITEA_REPOSITORY 環境變數缺乏輸入驗證。由於此值會直接拼接於 API URL 中(見 app/index.js:37),若攻擊者傳入特殊字元或路徑穿越字元(如 `../`),可能導致 API 請求路徑異常,甚至造成非預期的 API 端點存取。", - "suggestion": "增加格式驗證機制,使用嚴格的正則表達式限制 GITEA_REPOSITORY 格式(例如確保只包含合法的 repo 名稱字元:`^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$`),拒絕任何不符合規範的輸入。", - "is_new": true - }, - { - "level": "warning", - "role": "Leo", - "location": "app/releases.js:77", - "problem": "JSON.parse 失敗時,僅拋出通用錯誤訊息,未來除錯時無法得知具體回傳內容,將導致除錯時浪費大量時間追查。", - "suggestion": "建議將錯誤訊息擴充,納入部分的 response body 內容,以利於快速定位回傳格式異常的確切原因。", + "level": "critical", + "role": "Maya", + "location": "app/releases.js:68", + "problem": "新增的異常處理邏輯截斷了 `text` 為 200 字元,但在 `text` 為極大值或無法轉為字串時未進行防禦性檢查,且截斷點可能將多位元組字元拆分導致亂碼。", + "suggestion": "請確保 `text` 是字串後再截斷,並使用正確處理多位元組字元的方式(如 `Array.from(text).slice(0, 200).join('')`)以避免亂碼。", "is_new": true }, { "level": "warning", "role": "Maya", - "location": "app/logger.js:1", - "problem": "整個 logger.js 模組完全沒有測試,無法確保 section、info 與 error 函式是否正確將訊息格式化並寫入標準輸出與標準錯誤。", - "suggestion": "為 app/logger.js 新增測試,模擬 process.stdout 與 process.stderr,驗證輸出的字串格式是否符合預期(例如分隔線寬度、前綴是否正確)。", - "is_new": true + "location": "app/config.js:61", + "problem": "新增的 `assertRepository` 函式缺乏對 owner/repo 字串長度的邊界檢查,且變數名稱 `valid` 過於通用,語義不夠明確。", + "suggestion": "建議在 `assertRepository` 中加入長度限制檢查,並將 `valid` 更名為 `isRepoFormatValid` 或類似具備明確語義的命名。" + }, + { + "level": "warning", + "role": "Leo", + "location": "app/test/logger.test.js:8", + "problem": "在測試中使用 monkey-patch 直接覆蓋 `process.stdout.write` 或 `process.stderr.write` 是極度危險的模式,可能導致狀態污染;且針對 `logger.error` 的測試不夠全面。", + "suggestion": "建議重構 `logger` 使其支援依賴注入;並增加針對錯誤內容包含情況及非字串參數的斷言測試。" + }, + { + "level": "warning", + "role": "Maya", + "location": "app/test/config.test.js:84", + "problem": "缺少針對 `assertRepository` 邊界條件(如空字串、異常格式)的測試。", + "suggestion": "補上 `assert.throws` 測試案例以涵蓋上述邊界條件,確保驗證邏輯完整。" + }, + { + "level": "info", + "role": "Bard", + "location": "app/releases.js:67", + "problem": "將截斷字串直接串接在 Error 訊息中若處理不當易造成混淆,變數名稱 `snippet` 語義不明確。", + "suggestion": "建議在錯誤訊息中對片段內容進行適當標記,並將變數更名為 `contentSnippet`。" + }, + { + "level": "info", + "role": "Bard", + "location": "app/test/logger.test.js:10", + "problem": "測試工具函式 `captureStdout` 與 `captureStderr` 邏輯高度重複,不符合 DRY 原則。", + "suggestion": "建議抽取出通用的 `captureStream(stream, fn)` 函式以提升可維護性。" } ] diff --git a/app/config.js b/app/config.js index bf57afe..e2ccd7f 100644 --- a/app/config.js +++ b/app/config.js @@ -55,20 +55,32 @@ function assertHttpUrl(name, value) { } } +// 驗證 repository 為 owner/repo 格式(僅允許字母數字與 . _ -,且拒絕 . 與 .. 路徑穿越段) +function assertRepository(name, value) { + const parts = value.split('/'); + const valid = parts.length === 2 + && parts.every((part) => /^[A-Za-z0-9._-]+$/.test(part) && part !== '.' && part !== '..'); + if (!valid) { + throw new Error(`${name} 格式錯誤,必須為 owner/repo`); + } +} + /** * 從環境變數載入並驗證執行所需的設定。 * * GITEA_SERVER_URL 與 GITEA_REPOSITORY 為必填,未設定時會拋出錯誤;GITEA_SERVER_URL - * 另需為合法的 http/https URL;GITEA_TOKEN 為非必填,未設定時為 null;IS_BETA 會被正規化為布林值。 + * 另需為合法的 http/https URL,GITEA_REPOSITORY 另需為 owner/repo 格式; + * GITEA_TOKEN 為非必填,未設定時為 null;IS_BETA 會被正規化為布林值。 * * @param {Object} [env=process.env] - 環境變數來源物件,預設為 process.env。 * @returns {{ serverUrl: string, repository: string, token: (string|null), isBeta: boolean }} 已驗證的設定物件。 - * @throws {Error} 當 GITEA_SERVER_URL 或 GITEA_REPOSITORY 未設定,或 GITEA_SERVER_URL 非合法 http/https URL 時拋出。 + * @throws {Error} 當必填項未設定、GITEA_SERVER_URL 非合法 http/https URL,或 GITEA_REPOSITORY 非 owner/repo 格式時拋出。 */ function loadConfig(env = process.env) { const serverUrl = requireEnv('GITEA_SERVER_URL', env.GITEA_SERVER_URL); assertHttpUrl('GITEA_SERVER_URL', serverUrl); const repository = requireEnv('GITEA_REPOSITORY', env.GITEA_REPOSITORY); + assertRepository('GITEA_REPOSITORY', repository); const token = isUnset(env.GITEA_TOKEN) ? null : env.GITEA_TOKEN; const isBeta = normalizeBetaFlag(env.IS_BETA); diff --git a/app/releases.js b/app/releases.js index e2b049d..e057039 100644 --- a/app/releases.js +++ b/app/releases.js @@ -63,7 +63,9 @@ async function fetchReleases(baseUrl, options = {}) { try { pageJson = JSON.parse(text); } catch { - throw new Error(`release API 回傳資料無法解析 (page=${page})`); + // 附上截斷的回傳內容片段,便於除錯回傳格式異常 + const snippet = text.slice(0, 200); + throw new Error(`release API 回傳資料無法解析 (page=${page}): ${snippet}`); } if (pageJson === null) { diff --git a/app/test/config.test.js b/app/test/config.test.js index 2e4f222..c41bed6 100644 --- a/app/test/config.test.js +++ b/app/test/config.test.js @@ -1,7 +1,8 @@ 'use strict'; -const { test } = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); + const { isUnset, requireEnv, @@ -9,42 +10,43 @@ const { loadConfig, } = require('../config'); -test('isUnset 對未設定值回傳 true', () => { +test('isUnset 對 undefined/null/空字串/字面 "null" 視為未設定', () => { assert.equal(isUnset(undefined), true); assert.equal(isUnset(null), true); assert.equal(isUnset(''), true); assert.equal(isUnset('null'), true); }); -test('isUnset 對有效值回傳 false', () => { +test('isUnset 對其他非空值視為已設定', () => { assert.equal(isUnset('value'), false); assert.equal(isUnset('false'), false); + assert.equal(isUnset('0'), false); }); -test('requireEnv 對未設定值丟出錯誤', () => { - assert.throws(() => requireEnv('NAME', ''), /NAME 未設定/); - assert.throws(() => requireEnv('NAME', 'null'), /NAME 未設定/); +test('requireEnv 未設定時拋錯', () => { + assert.throws(() => requireEnv('FOO', ''), /FOO 未設定/); + assert.throws(() => requireEnv('FOO', undefined), /FOO 未設定/); }); -test('requireEnv 對有效值回傳原值', () => { - assert.equal(requireEnv('NAME', 'value'), 'value'); +test('requireEnv 有值時回傳原值', () => { + assert.equal(requireEnv('FOO', 'bar'), 'bar'); }); -test('normalizeBetaFlag 僅將字面值 "true" 視為啟用', () => { +test('normalizeBetaFlag 僅 "true" 視為啟用', () => { assert.equal(normalizeBetaFlag('true'), true); assert.equal(normalizeBetaFlag('false'), false); - assert.equal(normalizeBetaFlag('yes'), false); assert.equal(normalizeBetaFlag(undefined), false); - assert.equal(normalizeBetaFlag('null'), false); + assert.equal(normalizeBetaFlag('TRUE'), false); }); -test('loadConfig 從環境變數組出設定', () => { +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', @@ -53,16 +55,50 @@ test('loadConfig 從環境變數組出設定', () => { }); }); -test('loadConfig 將未提供的 token 正規化為 null,beta 預設 false', () => { +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 在缺少必填環境變數時丟出錯誤', () => { +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 格式錯誤或含路徑穿越時拋錯', () => { + assert.throws(() => loadConfig({ + GITEA_SERVER_URL: 'https://gitea.example.com', + GITEA_REPOSITORY: '../evil', + }), /GITEA_REPOSITORY 格式錯誤/); + assert.throws(() => loadConfig({ + GITEA_SERVER_URL: 'https://gitea.example.com', + GITEA_REPOSITORY: 'owner/repo/extra', + }), /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..390b4dc --- /dev/null +++ b/app/test/logger.test.js @@ -0,0 +1,55 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const logger = require('../logger'); + +// 暫時攔截 process.stdout.write,回傳期間內寫出的內容 +function captureStdout(fn) { + const original = process.stdout.write; + let out = ''; + process.stdout.write = (chunk) => { out += chunk; return true; }; + try { + fn(); + } finally { + process.stdout.write = original; + } + return out; +} + +// 暫時攔截 process.stderr.write,回傳期間內寫出的內容 +function captureStderr(fn) { + const original = process.stderr.write; + let out = ''; + process.stderr.write = (chunk) => { out += chunk; return true; }; + try { + fn(); + } finally { + process.stderr.write = original; + } + return out; +} + +test('section 以主/次分隔線包夾標題輸出至 stdout', () => { + const out = captureStdout(() => logger.section('參數檢查')); + const line = '='.repeat(50); + const subline = '-'.repeat(50); + assert.equal(out, `\n${line}\n參數檢查\n${subline}\n`); +}); + +test('info 以 [info] 前綴與換行輸出至 stdout', () => { + const out = captureStdout(() => logger.info('IS_BETA=false')); + assert.equal(out, '[info] IS_BETA=false\n'); +}); + +test('error 以 [error] 前綴與換行輸出至 stderr', () => { + const out = captureStderr(() => logger.error('GITEA_SERVER_URL 未設定')); + assert.equal(out, '[error] GITEA_SERVER_URL 未設定\n'); +}); + +test('error 僅輸出不終止行程,呼叫後仍可繼續執行', () => { + let reached = false; + captureStderr(() => { logger.error('still alive'); reached = true; }); + assert.equal(reached, true); +}); diff --git a/app/test/releases.test.js b/app/test/releases.test.js index 1fe4c7f..13a386e 100644 --- a/app/test/releases.test.js +++ b/app/test/releases.test.js @@ -1,70 +1,105 @@ 'use strict'; -const { test, afterEach } = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); -const { RELEASES_PER_PAGE, fetchReleases } = require('../releases'); -const realFetch = globalThis.fetch; +const { fetchReleases } = require('../releases'); -afterEach(() => { - globalThis.fetch = realFetch; -}); - -function mockResponse(items) { - return { ok: true, text: async () => JSON.stringify(items) }; +// 以可控的假回應替換全域 fetch,並於結束後還原 +function withFetch(handler, run) { + const original = globalThis.fetch; + globalThis.fetch = handler; + return Promise.resolve() + .then(run) + .finally(() => { + globalThis.fetch = original; + }); } -test('fetchReleases 逐頁讀取直到不足一頁', async () => { - const fullPage = Array.from({ length: RELEASES_PER_PAGE }, (_, i) => ({ id: i })); - const lastPage = [{ id: 100 }, { id: 101 }]; - const requested = []; - globalThis.fetch = async (url) => { - const page = Number(new URL(url).searchParams.get('page')); - requested.push(page); - return mockResponse(page === 1 ? fullPage : lastPage); - }; - - const releases = await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'); - - assert.equal(releases.length, RELEASES_PER_PAGE + lastPage.length); - assert.deepEqual(requested, [1, 2]); +// 建立模擬 Response 物件 +const jsonResponse = (body, ok = true) => ({ + ok, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), }); -test('fetchReleases 在空陣列頁面停止', async () => { - globalThis.fetch = async () => mockResponse([]); - const releases = await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'); - assert.deepEqual(releases, []); -}); - -test('fetchReleases 帶上授權標頭', async () => { - let seenHeaders; - globalThis.fetch = async (_url, opts) => { - seenHeaders = opts.headers; - return mockResponse([]); - }; - - await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases', { token: 'secret' }); - assert.equal(seenHeaders.Authorization, 'token secret'); -}); - -test('fetchReleases 在 HTTP 錯誤時丟出例外', async () => { - globalThis.fetch = async () => ({ ok: false, status: 500 }); - await assert.rejects( - () => fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'), - /release API 請求失敗/, +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 () => { - globalThis.fetch = async () => mockResponse({ message: 'not an array' }); - await assert.rejects( - () => fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'), - /回傳非陣列資料/, +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 在 null 回應時視為無更多資料', async () => { - globalThis.fetch = async () => ({ ok: true, text: async () => 'null' }); - const releases = await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'); - assert.deepEqual(releases, []); +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/app/test/version.test.js b/app/test/version.test.js index 5d3a636..13fdc43 100644 --- a/app/test/version.test.js +++ b/app/test/version.test.js @@ -1,7 +1,8 @@ 'use strict'; -const { test } = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); + const { compareVersionArrays, parseStableVersions, @@ -11,91 +12,75 @@ const { calculateVersion, } = require('../version'); -test('compareVersionArrays 逐區段數值比較,較短者視為較小', () => { - assert.ok(compareVersionArrays([1, 10, 0], [1, 9, 0]) > 0); +// 便於建構測試資料的小工具 +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); assert.ok(compareVersionArrays([2, 0, 0], [1, 9, 9]) > 0); + assert.equal(compareVersionArrays([1, 2, 3], [1, 2, 3]), 0); }); -test('parseStableVersions 排除 beta、去除前綴 v 並驗證數字格式', () => { +test('parseStableVersions 排除 beta 與非法 tag 並去除 v 前綴', () => { const releases = [ - { tag_name: 'v1.2.3' }, - { tag_name: '2.0.0' }, - { tag_name: 'v1.3.0-beta.1' }, - { tag_name: 'nightly' }, - { tag_name: null }, - null, + 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('parseStableVersions 對非陣列回傳空陣列', () => { - assert.deepEqual(parseStableVersions(null), []); - assert.deepEqual(parseStableVersions(undefined), []); -}); - -test('latestStableVersion 在無資料時回傳 0.0.0', () => { +test('latestStableVersion 無資料時回傳 0.0.0', () => { assert.equal(latestStableVersion([]), '0.0.0'); - assert.equal(latestStableVersion([{ tag_name: 'v1.0.0-beta.1' }]), '0.0.0'); + assert.equal(latestStableVersion(null), '0.0.0'); }); -test('latestStableVersion 取最新的穩定版(含跨區段比較)', () => { - assert.equal(latestStableVersion([ - { tag_name: 'v1.9.0' }, - { tag_name: 'v1.10.0' }, - { tag_name: 'v1.2.3' }, - ]), '1.10.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 進位', () => { +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', () => { +test('nextReleaseVersion patch 達 10 進位至 minor', () => { assert.equal(nextReleaseVersion('1.2.9'), '1.3.0'); }); -test('nextReleaseVersion 在 minor 也達 10 時進位至 major', () => { +test('nextReleaseVersion minor 達 10 進位至 major', () => { assert.equal(nextReleaseVersion('1.9.9'), '2.0.0'); }); -test('nextBetaNumber 在查無對應 beta 時回傳 1', () => { - assert.equal(nextBetaNumber([], '1.2.4'), 1); - assert.equal(nextBetaNumber([{ tag_name: 'v9.9.9-beta.5' }], '1.2.4'), 1); - assert.equal(nextBetaNumber(null, '1.2.4'), 1); +test('nextBetaNumber 無對應 beta 時回傳 1', () => { + assert.equal(nextBetaNumber([], '0.0.1'), 1); + assert.equal(nextBetaNumber([rel('v0.0.1')], '0.0.1'), 1); }); -test('nextBetaNumber 取對應 beta 流水號最大值 + 1', () => { - const releases = [ - { tag_name: 'v1.2.4-beta.1' }, - { tag_name: 'v1.2.4-beta.3' }, - { tag_name: 'v1.2.4-beta.2' }, - { tag_name: 'v1.2.5-beta.9' }, - ]; - assert.equal(nextBetaNumber(releases, '1.2.4'), 4); +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 計算下一個穩定版', () => { - assert.deepEqual( - calculateVersion([{ tag_name: 'v1.2.3' }], false), - { latest: '1.2.3', version: '1.2.4' }, - ); -}); - -test('calculateVersion 計算下一個 beta 版', () => { - const releases = [ - { tag_name: 'v1.2.3' }, - { tag_name: 'v1.2.4-beta.2' }, - ]; - assert.deepEqual( - calculateVersion(releases, true), - { latest: '1.2.3', version: '1.2.4-beta.3' }, - ); -}); - -test('calculateVersion 在無任何 release 時的預設值', () => { +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' }); +}); 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' }); -});