test: 整併測試至 app/test 並解決 AI 審查 findings #8

Closed
jiantw83 wants to merge 23 commits from test/consolidate-app-test-20260626-111823 into develop
13 changed files with 386 additions and 412 deletions
+54
View File
@@ -58,5 +58,59 @@
"role": "Leo", "role": "Leo",
"original_finding": "日誌分隔線寬度與符號直接硬編碼在模組內,調整風格需改多處。建議集中管理並提供通用產生函數。", "original_finding": "日誌分隔線寬度與符號直接硬編碼在模組內,調整風格需改多處。建議集中管理並提供通用產生函數。",
"reason": "分隔線已是模組頂層集中定義的常數 LINE/SUBLINE,單點即可調整;為固定的視覺樣式再加產生函數屬過度設計。" "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 單一測試檔使用 withFetchlogger.test.js 使用的是不同的 stdout/stderr 攔截輔助),尚無第二個消費者;為單一用途提前抽出共用模組屬過度設計。"
},
{
"location": "app/test/logger.test.js:8",
"role": "Leo",
"original_finding": "在測試中使用 monkey-patch 直接覆蓋 process.stdout.write 或 process.stderr.write 是極度危險的模式,可能導致狀態污染;建議重構 logger 使其支援依賴注入。",
"reason": "攔截已於 finally 還原原本的 write,無狀態污染,且為 Node 測試 stdout/stderr 輸出的標準作法;logger 僅是寫入 stdout/stderr 的薄封裝,為其引入依賴注入框架屬過度設計。已將兩個攔截輔助合併為單一 captureStream。"
},
{
"location": "app/config.js:63",
"role": "Mage",
"original_finding": "正規表達式 ^[A-Za-z0-9._-]+$ 仍允許以點號開頭或結尾的名稱(如 .foo 或 foo.)。建議調整為點號只能存在於中間。",
"reason": "路徑穿越風險來自 .、.. 與額外斜線,皆已明確排除;前/後綴點號非穿越向量,且禁止會誤拒如 .github 這類合法 repo 名稱。"
},
{
"location": "app/test/config.test.js:65",
"role": "Bard",
"original_finding": "for...of 迴圈測試將多種錯誤案例綁在同一個測試名稱下,失敗時難以辨識是哪一種格式。建議拆分或加入描述性訊息。",
"reason": "迴圈內 assert.throws 已帶入第三引數描述訊息(應拒絕:<repo>),失敗時可定位具體案例;逐案拆成獨立 test 屬純風格偏好。"
},
{
"location": "app/config.js:62",
"role": "Mage",
"original_finding": "parts.every 僅檢查單一區段長度,未對 owner/repo 總長度做限制。建議分割前先限制 value 總長度。",
"reason": "已限定恰兩段且每段 <= MAX_REPO_NAME_LENGTH,總長度已被間接上限;再加總長度檢查為冗餘。"
},
{
"location": "app/releases.js:68",
"role": "Assassin",
"original_finding": "將未經清洗的 API 回應片段納入 Error,若 API 返回敏感資訊可能導致外洩。建議僅含概括描述或先脫敏。",
"reason": "此片段為前一輪審查明確要求加入以利除錯;release 清單 API 的非 JSON 回應通常為錯誤頁/代理訊息而非機密資料,且片段已截斷。兩輪建議互相衝突,維持可偵錯性。"
},
{
"location": "app/test/releases.test.js:33",
"role": "Bard",
"original_finding": "jsonResponse 輔助函式定義在測試檔內,建議統一抽離至 app/test/support/mock.js 集中管理。",
"reason": "目前僅 releases.test.js 單一測試檔使用,「若未來」屬臆測性需求,為單一消費者提前抽出共用模組屬過度設計。"
},
{
"location": "app/config.js:65",
"role": "Rogue",
"original_finding": "將 Regex 移至函數外層宣告為常數。",
"reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)"
},
{
"location": "app/config.js:62",
"role": "Maya",
"original_finding": "assertRepository 並未測試所有邊界條件:特殊字元、極長字串段(>100)、完全空白的 value。",
"reason": "前提已不成立:app/test/config.test.js 已涵蓋非法字元(空白/星號/冒號)、單段超過 100 字元、缺斜線/多段/空段,空字串則由 requireEnv 先擋下。"
} }
] ]
+14 -14
View File
@@ -1,26 +1,26 @@
[ [
{
"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", "level": "warning",
"role": "Leo", "role": "Leo",
"location": "app/releases.js:77", "location": "app/releases.js:14",
"problem": "JSON.parse 失敗時,僅拋出通用錯誤訊息,未來除錯時無法得知具體回傳內容,將導致除錯時浪費大量時間追查。", "problem": "`truncateString` 使用 `Array.from(bounded).slice(0, limit).join('')` 的方式處理字串截斷,雖然能避免拆分代理對(surrogate pairs),但在處理極大字串(例如 `API_ERROR_SNIPPET_LENGTH` 很大時)會因為 `Array.from` 產生巨大的陣列而導致記憶體使用量激增。考慮到這是在解析失敗時處理的錯誤訊息,這種設計可能讓原本就已經吃緊的記憶體狀況雪上加霜。",
"suggestion": "建議將錯誤訊息擴充,納入部分的 response body 內容,以利於快速定位回傳格式異常的確切原因。", "suggestion": "若不需要嚴格支援所有 Unicode 字元組合,考慮改用更節省記憶體的方式(如 `Intl.Segmenter` 或調整截斷邏輯),或者明確說明此處對記憶體的使用考量。若目的是為了錯誤記錄,或許直接截斷原始字串的長度後確保不要在最後一個字元產生半個代理對即可。",
"is_new": true "is_new": true
}, },
{ {
"level": "warning", "level": "warning",
"role": "Maya", "role": "Maya",
"location": "app/logger.js:1", "problem": "雖然實作了針對多位元組字元的截斷處理邏輯,但目前的測試案例僅使用 ASCII 字元('x'),缺乏對於包含多位元組字元(如 Emoji 或特殊符號)的真實邊界情境驗證,無法確保在截斷邊界處不會產生亂碼或非預期的行為。",
"problem": "整個 logger.js 模組完全沒有測試,無法確保 section、info 與 error 函式是否正確將訊息格式化並寫入標準輸出與標準錯誤。", "suggestion": "請在 app/test/releases.test.js 中新增一個測試案例,使用包含多位元組字元(例如 Emoji 或代理對字元)的長字串作為 fetch 回應內容,並驗證截斷後的內容片段是否完整且無亂碼。",
"suggestion": "app/logger.js 新增測試,模擬 process.stdout 與 process.stderr,驗證輸出的字串格式是否符合預期(例如分隔線寬度、前綴是否正確)。", "location": "app/releases.js:69",
"is_new": false
},
{
"level": "info",
"role": "Leo",
"location": "app/config.js:63",
"problem": "在 `assertGiteaRepositoryFormat` 函式中,當 `value.split('/')` 的長度不為 2 時,拋出的錯誤訊息僅籠統地說「格式錯誤」。若使用者輸入了包含多個斜線或完全沒有斜線的字串,這類訊息對修正環境變數幫助有限。",
"suggestion": "建議區分「格式不符」與「內容不符」的錯誤細節,例如提示「必須為 owner/repo 格式,包含一個斜線」。",
"is_new": true "is_new": true
} }
] ]
+1 -1
View File
@@ -37,4 +37,4 @@ jobs:
uses: https://gitea.jsc.idv.tw/composite-actions/opencode-code-review@${{ vars.ACTION_OPENCODE_CODE_REVIEW_VERSION }} uses: https://gitea.jsc.idv.tw/composite-actions/opencode-code-review@${{ vars.ACTION_OPENCODE_CODE_REVIEW_VERSION }}
with: with:
# 傳入留言用 token (secret),供 action 在 PR 上發表審查留言。 # 傳入留言用 token (secret),供 action 在 PR 上發表審查留言。
comment_token: ${{ secrets.COMMENT_TOKEN }} token: ${{ secrets.TOKEN }}
+25 -2
View File
@@ -55,20 +55,43 @@ function assertHttpUrl(name, value) {
} }
} }
// repository 每段名稱的最大長度與允許字元(字母數字與 . _ -)
const MAX_REPO_NAME_LENGTH = 100;
const REPO_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/;
// 單一 repository 區段是否合法:長度在範圍內、僅含允許字元,且非 . 或 .. 路徑穿越段
Ghost marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🔴 嚴重
審查員:Maya
問題:新增的 assertRepository 函數對 GITEA_REPOSITORY 的檢查邏輯並未測試到所有邊界條件,例如:包含特殊字元(非字母數字點下底線連字號)、極長字串段(> 100 字元)、以及完全空白的 value
建議:請在 app/test/config.test.js 中增加針對非法字元、超過 100 字元的字串段,以及空字串的測試案例,確保 assertRepository 的防禦性功能完全被驗證。

**嚴重等級**:🔴 嚴重 **審查員**:Maya **問題**:新增的 `assertRepository` 函數對 `GITEA_REPOSITORY` 的檢查邏輯並未測試到所有邊界條件,例如:包含特殊字元(非字母數字點下底線連字號)、極長字串段(> 100 字元)、以及完全空白的 `value`。 **建議**:請在 `app/test/config.test.js` 中增加針對非法字元、超過 100 字元的字串段,以及空字串的測試案例,確保 `assertRepository` 的防禦性功能完全被驗證。
Outdated
Review

嚴重等級🟡 警告
審查員:Mage
問題:在 assertRepository 中,parts.every 僅檢查了單一區段長度,未對總體長度(owner/repo)做任何限制。
建議:建議在分割前,先對 value 的總長度進行合理範圍的限制。

**嚴重等級**:🟡 警告 **審查員**:Mage **問題**:在 `assertRepository` 中,`parts.every` 僅檢查了單一區段長度,未對總體長度(`owner/repo`)做任何限制。 **建議**:建議在分割前,先對 `value` 的總長度進行合理範圍的限制。
function isValidRepoSegment(part) {
Ghost marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🔴 嚴重
審查員:Mage
問題:在 assertRepository 函數中,part !== '.' && part !== '..' 的檢查雖然能防止路徑穿越,但正規表達式 ^[A-Za-z0-9._-]+$ 仍然允許 . 開頭或結尾的檔案名稱(例如 .foofoo.),這在某些檔案系統中可能導致意外行為或被視為隱藏檔案,且未明確禁止僅由 . 組成的名稱(儘管後面有手動排除)。
建議:建議調整正規表達式,禁止名稱以點號開頭或結尾,例如使用 /^[A-Za-z0-9_-]+([.][A-Za-z0-9_-]+)*$/ 來確保點號只能存在於中間。

**嚴重等級**:🔴 嚴重 **審查員**:Mage **問題**:在 `assertRepository` 函數中,`part !== '.' && part !== '..'` 的檢查雖然能防止路徑穿越,但正規表達式 `^[A-Za-z0-9._-]+$` 仍然允許 `.` 開頭或結尾的檔案名稱(例如 `.foo` 或 `foo.`),這在某些檔案系統中可能導致意外行為或被視為隱藏檔案,且未明確禁止僅由 `.` 組成的名稱(儘管後面有手動排除)。 **建議**:建議調整正規表達式,禁止名稱以點號開頭或結尾,例如使用 `/^[A-Za-z0-9_-]+([.][A-Za-z0-9_-]+)*$/` 來確保點號只能存在於中間。
Outdated
Review

嚴重等級🟡 警告
審查員:Leo
問題:在 assertRepository 函式中使用了硬編碼的 100 作為長度限制,這是一個魔術數字,難以維護且無法從環境配置中調整。
建議:建議將 100 抽離為常數(例如 MAX_REPO_NAME_LENGTH),提高可維護性。

**嚴重等級**:🟡 警告 **審查員**:Leo **問題**:在 `assertRepository` 函式中使用了硬編碼的 `100` 作為長度限制,這是一個魔術數字,難以維護且無法從環境配置中調整。 **建議**:建議將 `100` 抽離為常數(例如 `MAX_REPO_NAME_LENGTH`),提高可維護性。
Outdated
Review

嚴重等級🔵 建議
審查員:Bard
問題:正規表達式 /^[A-Za-z0-9._-]+$/ 描述稍顯冗長,且未利用字元類別的特性,略顯不夠優雅。
建議:考慮簡化寫法,或確保該格式驗證邏輯與專案其他地方一致並抽取為常數。

**嚴重等級**:🔵 建議 **審查員**:Bard **問題**:正規表達式 `/^[A-Za-z0-9._-]+$/` 描述稍顯冗長,且未利用字元類別的特性,略顯不夠優雅。 **建議**:考慮簡化寫法,或確保該格式驗證邏輯與專案其他地方一致並抽取為常數。
Outdated
Review

嚴重等級🔵 建議
審查員:Leo
問題:驗證邏輯 parts.every 內包含過多條件,邏輯稍顯複雜,閱讀起來需要花點時間拆解。
建議:將 parts.every 的回呼函數提取為具名函數(例如 isValidPart),以提升代碼的可讀性與未來維護的清晰度。

**嚴重等級**:🔵 建議 **審查員**:Leo **問題**:驗證邏輯 parts.every 內包含過多條件,邏輯稍顯複雜,閱讀起來需要花點時間拆解。 **建議**:將 parts.every 的回呼函數提取為具名函數(例如 isValidPart),以提升代碼的可讀性與未來維護的清晰度。
Review

嚴重等級🔵 建議
審查員:Leo
問題:在 assertGiteaRepositoryFormat 函式中,當 value.split('/') 的長度不為 2 時,拋出的錯誤訊息僅籠統地說「格式錯誤」。若使用者輸入了包含多個斜線或完全沒有斜線的字串,這類訊息對修正環境變數幫助有限。
建議:建議區分「格式不符」與「內容不符」的錯誤細節,例如提示「必須為 owner/repo 格式,包含一個斜線」。

**嚴重等級**:🔵 建議 **審查員**:Leo **問題**:在 `assertGiteaRepositoryFormat` 函式中,當 `value.split('/')` 的長度不為 2 時,拋出的錯誤訊息僅籠統地說「格式錯誤」。若使用者輸入了包含多個斜線或完全沒有斜線的字串,這類訊息對修正環境變數幫助有限。 **建議**:建議區分「格式不符」與「內容不符」的錯誤細節,例如提示「必須為 owner/repo 格式,包含一個斜線」。
return part.length > 0
&& part.length <= MAX_REPO_NAME_LENGTH
Ghost marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🟡 警告
審查員:Rogue
問題:在 every 迴圈中重複宣告與編譯 Regex /^[A-Za-z0-9._-]+$/,若該函數被頻繁呼叫,將造成無謂的 CPU 與記憶體浪費。
建議:將 Regex 移至函數外層宣告為常數。

**嚴重等級**:🟡 警告 **審查員**:Rogue **問題**:在 `every` 迴圈中重複宣告與編譯 Regex `/^[A-Za-z0-9._-]+$/`,若該函數被頻繁呼叫,將造成無謂的 CPU 與記憶體浪費。 **建議**:將 Regex 移至函數外層宣告為常數。
Review

嚴重等級🟡 警告
審查員:Bard
問題:函數 assertRepository 命名較為通用,但其實際行為僅在驗證 Gitea 的 owner/repo 格式。命名未能直接體現其檢查邏輯與該領域規則。
建議:建議重新命名為 assertGiteaRepositoryFormat,讓開發者一眼就能看出該函數在檢查特定的 Gitea 倉庫格式規範。

**嚴重等級**:🟡 警告 **審查員**:Bard **問題**:函數 `assertRepository` 命名較為通用,但其實際行為僅在驗證 Gitea 的 `owner/repo` 格式。命名未能直接體現其檢查邏輯與該領域規則。 **建議**:建議重新命名為 `assertGiteaRepositoryFormat`,讓開發者一眼就能看出該函數在檢查特定的 Gitea 倉庫格式規範。
&& REPO_SEGMENT_PATTERN.test(part)
&& part !== '.' && part !== '..';
}
// 驗證 repository 為 owner/repo 格式(恰兩段,且每段皆為合法區段)
function assertGiteaRepositoryFormat(name, value) {
const parts = value.split('/');
const isRepoFormatValid = parts.length === 2 && parts.every(isValidRepoSegment);
if (!isRepoFormatValid) {
throw new Error(`${name} 格式錯誤,必須為 owner/repo`);
}
}
/** /**
* 從環境變數載入並驗證執行所需的設定。 * 從環境變數載入並驗證執行所需的設定。
* *
* GITEA_SERVER_URL 與 GITEA_REPOSITORY 為必填,未設定時會拋出錯誤;GITEA_SERVER_URL * GITEA_SERVER_URL 與 GITEA_REPOSITORY 為必填,未設定時會拋出錯誤;GITEA_SERVER_URL
* 另需為合法的 http/https URLGITEA_TOKEN 為非必填,未設定時為 null;IS_BETA 會被正規化為布林值。 * 另需為合法的 http/https URLGITEA_REPOSITORY 另需為 owner/repo 格式;
* GITEA_TOKEN 為非必填,未設定時為 null;IS_BETA 會被正規化為布林值。
* *
* @param {Object} [env=process.env] - 環境變數來源物件,預設為 process.env。 * @param {Object} [env=process.env] - 環境變數來源物件,預設為 process.env。
* @returns {{ serverUrl: string, repository: string, token: (string|null), isBeta: boolean }} 已驗證的設定物件。 * @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) { function loadConfig(env = process.env) {
const serverUrl = requireEnv('GITEA_SERVER_URL', env.GITEA_SERVER_URL); const serverUrl = requireEnv('GITEA_SERVER_URL', env.GITEA_SERVER_URL);
assertHttpUrl('GITEA_SERVER_URL', serverUrl); assertHttpUrl('GITEA_SERVER_URL', serverUrl);
const repository = requireEnv('GITEA_REPOSITORY', env.GITEA_REPOSITORY); const repository = requireEnv('GITEA_REPOSITORY', env.GITEA_REPOSITORY);
assertGiteaRepositoryFormat('GITEA_REPOSITORY', repository);
const token = isUnset(env.GITEA_TOKEN) ? null : env.GITEA_TOKEN; const token = isUnset(env.GITEA_TOKEN) ? null : env.GITEA_TOKEN;
const isBeta = normalizeBetaFlag(env.IS_BETA); const isBeta = normalizeBetaFlag(env.IS_BETA);
+13 -2
View File
@@ -3,6 +3,16 @@
// 每頁取得的 release 筆數 // 每頁取得的 release 筆數
const RELEASES_PER_PAGE = 10; const RELEASES_PER_PAGE = 10;
// JSON 解析失敗時,附在錯誤訊息中的回應內容片段最大字元數
const API_ERROR_SNIPPET_LENGTH = 200;
// 以字元(而非 UTF-16 碼元)安全截斷字串至 limit 個字元,避免拆分多位元組字元造成亂碼;
// 先以長度上限粗略截掉過長輸入,避免將可能極大的字串整個陣列化
function truncateString(text, limit) {
const bounded = text.slice(0, limit * 2);
return Array.from(bounded).slice(0, limit).join('');
}
Review

嚴重等級🟡 警告
審查員:Leo
問題truncateString 使用 Array.from(bounded).slice(0, limit).join('') 的方式處理字串截斷,雖然能避免拆分代理對(surrogate pairs),但在處理極大字串(例如 API_ERROR_SNIPPET_LENGTH 很大時)會因為 Array.from 產生巨大的陣列而導致記憶體使用量激增。考慮到這是在解析失敗時處理的錯誤訊息,這種設計可能讓原本就已經吃緊的記憶體狀況雪上加霜。
建議:若不需要嚴格支援所有 Unicode 字元組合,考慮改用更節省記憶體的方式(如 Intl.Segmenter 或調整截斷邏輯),或者明確說明此處對記憶體的使用考量。若目的是為了錯誤記錄,或許直接截斷原始字串的長度後確保不要在最後一個字元產生半個代理對即可。

**嚴重等級**:🟡 警告 **審查員**:Leo **問題**:`truncateString` 使用 `Array.from(bounded).slice(0, limit).join('')` 的方式處理字串截斷,雖然能避免拆分代理對(surrogate pairs),但在處理極大字串(例如 `API_ERROR_SNIPPET_LENGTH` 很大時)會因為 `Array.from` 產生巨大的陣列而導致記憶體使用量激增。考慮到這是在解析失敗時處理的錯誤訊息,這種設計可能讓原本就已經吃緊的記憶體狀況雪上加霜。 **建議**:若不需要嚴格支援所有 Unicode 字元組合,考慮改用更節省記憶體的方式(如 `Intl.Segmenter` 或調整截斷邏輯),或者明確說明此處對記憶體的使用考量。若目的是為了錯誤記錄,或許直接截斷原始字串的長度後確保不要在最後一個字元產生半個代理對即可。
/** /**
* 以分頁方式取得指定 Gitea repo 的所有 release,並回傳合併後的陣列。 * 以分頁方式取得指定 Gitea repo 的所有 release,並回傳合併後的陣列。
* *
6
@@ -63,7 +73,8 @@ async function fetchReleases(baseUrl, options = {}) {
try { try {
pageJson = JSON.parse(text); pageJson = JSON.parse(text);
} catch { } catch {
throw new Error(`release API 回傳資料無法解析 (page=${page})`); const contentSnippet = truncateString(text, API_ERROR_SNIPPET_LENGTH);
throw new Error(`release API 回傳資料無法解析 (page=${page}),回應內容片段:「${contentSnippet}`);
} }
if (pageJson === null) { if (pageJson === null) {
@@ -89,4 +100,4 @@ async function fetchReleases(baseUrl, options = {}) {
return combined; return combined;
} }
module.exports = { RELEASES_PER_PAGE, fetchReleases }; module.exports = { RELEASES_PER_PAGE, truncateString, fetchReleases };
+75 -15
View File
@@ -1,7 +1,8 @@
'use strict'; 'use strict';
const { test } = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { const {
isUnset, isUnset,
requireEnv, requireEnv,
@@ -9,42 +10,43 @@ const {
loadConfig, loadConfig,
} = require('../config'); } = require('../config');
test('isUnset 對未設定值回傳 true', () => { test('isUnset 對 undefined/null/空字串/字面 "null" 視為未設定', () => {
assert.equal(isUnset(undefined), true); assert.equal(isUnset(undefined), true);
assert.equal(isUnset(null), true); assert.equal(isUnset(null), true);
assert.equal(isUnset(''), true); assert.equal(isUnset(''), true);
assert.equal(isUnset('null'), true); assert.equal(isUnset('null'), true);
}); });
test('isUnset 對有效值回傳 false', () => { test('isUnset 對其他非空值視為已設定', () => {
assert.equal(isUnset('value'), false); assert.equal(isUnset('value'), false);
assert.equal(isUnset('false'), false); assert.equal(isUnset('false'), false);
assert.equal(isUnset('0'), false);
}); });
test('requireEnv 未設定值丟出錯誤', () => { test('requireEnv 未設定時拋錯', () => {
assert.throws(() => requireEnv('NAME', ''), /NAME 未設定/); assert.throws(() => requireEnv('FOO', ''), /FOO 未設定/);
assert.throws(() => requireEnv('NAME', 'null'), /NAME 未設定/); assert.throws(() => requireEnv('FOO', undefined), /FOO 未設定/);
}); });
test('requireEnv 對有效值回傳原值', () => { test('requireEnv 有值時回傳原值', () => {
assert.equal(requireEnv('NAME', 'value'), 'value'); assert.equal(requireEnv('FOO', 'bar'), 'bar');
}); });
test('normalizeBetaFlag 僅將字面值 "true" 視為啟用', () => { test('normalizeBetaFlag 僅 "true" 視為啟用', () => {
assert.equal(normalizeBetaFlag('true'), true); assert.equal(normalizeBetaFlag('true'), true);
assert.equal(normalizeBetaFlag('false'), false); assert.equal(normalizeBetaFlag('false'), false);
assert.equal(normalizeBetaFlag('yes'), false);
assert.equal(normalizeBetaFlag(undefined), false); assert.equal(normalizeBetaFlag(undefined), false);
assert.equal(normalizeBetaFlag('null'), false); assert.equal(normalizeBetaFlag('TRUE'), false);
}); });
test('loadConfig 從環境變數組出設定', () => { test('loadConfig 解析完整設定', () => {
const config = loadConfig({ const config = loadConfig({
GITEA_SERVER_URL: 'https://gitea.example.com', GITEA_SERVER_URL: 'https://gitea.example.com',
GITEA_REPOSITORY: 'owner/repo', GITEA_REPOSITORY: 'owner/repo',
GITEA_TOKEN: 'secret', GITEA_TOKEN: 'secret',
IS_BETA: 'true', IS_BETA: 'true',
}); });
assert.deepEqual(config, { assert.deepEqual(config, {
serverUrl: 'https://gitea.example.com', serverUrl: 'https://gitea.example.com',
repository: 'owner/repo', repository: 'owner/repo',
@@ -53,16 +55,74 @@ test('loadConfig 從環境變數組出設定', () => {
}); });
}); });
test('loadConfig 將未提供的 token 正規化為 nullbeta 預設 false', () => { test('loadConfig 缺少 token 視為匿名(null', () => {
const config = loadConfig({ const config = loadConfig({
GITEA_SERVER_URL: 'https://gitea.example.com', GITEA_SERVER_URL: 'https://gitea.example.com',
GITEA_REPOSITORY: 'owner/repo', GITEA_REPOSITORY: 'owner/repo',
GITEA_TOKEN: 'null',
}); });
assert.equal(config.token, null); assert.equal(config.token, null);
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Bard
問題:測試檔案中的迴圈測試(for...of)將多種不同的錯誤案例綁在同一個測試名稱下,一旦其中一個案例失敗,難以快速辨識具體是哪一種格式導致驗證失效。
建議:建議將其拆分為獨立的測試案例,或在 assert.throws 中加入更具描述性的訊息。

**嚴重等級**:🟡 警告 **審查員**:Bard **問題**:測試檔案中的迴圈測試(for...of)將多種不同的錯誤案例綁在同一個測試名稱下,一旦其中一個案例失敗,難以快速辨識具體是哪一種格式導致驗證失效。 **建議**:建議將其拆分為獨立的測試案例,或在 `assert.throws` 中加入更具描述性的訊息。
assert.equal(config.isBeta, false); assert.equal(config.isBeta, false);
}); });
test('loadConfig 缺少必填環境變數時丟出錯誤', () => { test('loadConfig 缺少必填項目時拋錯', () => {
assert.throws(() => loadConfig({ GITEA_REPOSITORY: 'owner/repo' }), /GITEA_SERVER_URL 未設定/); 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 於 GITEA_REPOSITORY 邊界格式(空段/缺斜線/多段)皆拋錯', () => {
for (const repo of ['noslash', 'owner/', '/repo', 'a/b/c', 'owner//repo']) {
assert.throws(() => loadConfig({
GITEA_SERVER_URL: 'https://gitea.example.com',
GITEA_REPOSITORY: repo,
}), /GITEA_REPOSITORY 格式錯誤/, `應拒絕:${repo}`);
}
});
test('loadConfig 於 GITEA_REPOSITORY 含非法字元或超長段時拋錯', () => {
const bad = [
'ow ner/repo',
'owner/re*po',
'owner/re:po',
`${'a'.repeat(101)}/repo`,
];
for (const repo of bad) {
assert.throws(() => loadConfig({
GITEA_SERVER_URL: 'https://gitea.example.com',
GITEA_REPOSITORY: repo,
}), /GITEA_REPOSITORY 格式錯誤/, `應拒絕:${repo}`);
}
});
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');
}); });
@@ -3,7 +3,7 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { main } = require('../app/index'); const { main } = require('../index');
// 安靜的 log 記錄器,避免測試輸出雜訊 // 安靜的 log 記錄器,避免測試輸出雜訊
const silentLog = { section() {}, info() {}, error() {} }; const silentLog = { section() {}, info() {}, error() {} };
+44
View File
@@ -0,0 +1,44 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const logger = require('../logger');
// 暫時攔截指定標準串流('stdout' / 'stderr')的 write,回傳期間內寫出的內容;
// 結束時於 finally 還原原本的 write,避免狀態污染
function captureStream(streamName, fn) {
const stream = process[streamName];
const original = stream.write;
let out = '';
stream.write = (chunk) => { out += chunk; return true; };
try {
fn();
} finally {
stream.write = original;
}
return out;
}
test('section 以主/次分隔線包夾標題輸出至 stdout', () => {
const out = captureStream('stdout', () => 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 = captureStream('stdout', () => logger.info('IS_BETA=false'));
assert.equal(out, '[info] IS_BETA=false\n');
});
test('error 以 [error] 前綴與換行輸出至 stderr', () => {
const out = captureStream('stderr', () => logger.error('GITEA_SERVER_URL 未設定'));
assert.equal(out, '[error] GITEA_SERVER_URL 未設定\n');
});
test('error 僅輸出不終止行程,呼叫後仍可繼續執行', () => {
let reached = false;
captureStream('stderr', () => { logger.error('still alive'); reached = true; });
assert.equal(reached, true);
});
+115 -53
View File
@@ -1,70 +1,132 @@
'use strict'; 'use strict';
const { test, afterEach } = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { RELEASES_PER_PAGE, fetchReleases } = require('../releases');
const realFetch = globalThis.fetch; const { fetchReleases, truncateString } = require('../releases');
afterEach(() => { // 以可控的假回應替換全域 fetch,並於結束後還原
globalThis.fetch = realFetch; function withFetch(handler, run) {
}); const original = globalThis.fetch;
globalThis.fetch = handler;
function mockResponse(items) { return Promise.resolve()
return { ok: true, text: async () => JSON.stringify(items) }; .then(run)
.finally(() => {
globalThis.fetch = original;
});
} }
test('fetchReleases 逐頁讀取直到不足一頁', async () => { // 建立模擬 Response 物件
const fullPage = Array.from({ length: RELEASES_PER_PAGE }, (_, i) => ({ id: i })); const jsonResponse = (body, ok = true) => ({
const lastPage = [{ id: 100 }, { id: 101 }]; ok,
const requested = []; text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
globalThis.fetch = async (url) => { });
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' }]);
},
);
});
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Bard
問題:與 config.test.js 相比,這裡的 jsonResponse 輔助函式定義在測試檔案上方,風格雖然簡潔,但若未來有更多測試檔案需要類似的 mock 工具,建議統一抽離至 test/support/ 之類的資料夾中,保持測試主體清爽。
建議:建立 app/test/support/mock.js 將這類常用的測試輔助函式集中管理。

**嚴重等級**:🔵 建議 **審查員**:Bard **問題**:與 `config.test.js` 相比,這裡的 `jsonResponse` 輔助函式定義在測試檔案上方,風格雖然簡潔,但若未來有更多測試檔案需要類似的 mock 工具,建議統一抽離至 `test/support/` 之類的資料夾中,保持測試主體清爽。 **建議**:建立 `app/test/support/mock.js` 將這類常用的測試輔助函式集中管理。
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')); const page = Number(new URL(url).searchParams.get('page'));
requested.push(page); seen.push(page);
return mockResponse(page === 1 ? fullPage : lastPage); return jsonResponse(pages[page]);
}; },
async () => {
const releases = await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'); const releases = await fetchReleases('https://gitea.example.com/api');
assert.equal(releases.length, 11);
assert.equal(releases.length, RELEASES_PER_PAGE + lastPage.length); assert.deepEqual(seen, [1, 2]);
assert.deepEqual(requested, [1, 2]); },
);
}); });
test('fetchReleases 在空陣列頁面停止', async () => { test('fetchReleases 對非 2xx 回應拋錯', async () => {
globalThis.fetch = async () => mockResponse([]); await withFetch(
const releases = await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'); async () => jsonResponse('Not Found', false),
assert.deepEqual(releases, []); async () => {
});
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( await assert.rejects(
() => fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'), () => fetchReleases('https://gitea.example.com/api'),
/release API 請求失敗/, /release API 請求失敗/,
); );
}); },
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 在 null 回應時視為無更多資料', async () => { test('fetchReleases 對無法解析的 JSON 回應拋錯', async () => {
globalThis.fetch = async () => ({ ok: true, text: async () => 'null' }); await withFetch(
const releases = await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'); async () => jsonResponse('{ this is not valid json', true),
assert.deepEqual(releases, []); async () => {
await assert.rejects(
() => fetchReleases('https://gitea.example.com/api'),
/回傳資料無法解析/,
);
},
);
});
test('truncateString 以字元截斷且不拆分多位元組字元(emoji 代理對)', () => {
Ghost marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🟡 警告
審查員:Maya
問題:新增的 JSON 解析錯誤處理邏輯中,對於回應內容片段(contentSnippet)的生成與截斷行為並未進行驗證。目前的測試僅檢查錯誤訊息是否包含特定字串,未確保截斷邏輯在邊界條件(如超長回應、包含特殊字元)下是否正確。
建議:建議在 app/test/releases.test.js 中增加針對無法解析 JSON 的測試案例,並具體斷言錯誤訊息中包含預期的片段內容,特別是需要測試超長字串截斷是否符合預期。

**嚴重等級**:🟡 警告 **審查員**:Maya **問題**:新增的 JSON 解析錯誤處理邏輯中,對於回應內容片段(contentSnippet)的生成與截斷行為並未進行驗證。目前的測試僅檢查錯誤訊息是否包含特定字串,未確保截斷邏輯在邊界條件(如超長回應、包含特殊字元)下是否正確。 **建議**:建議在 `app/test/releases.test.js` 中增加針對無法解析 JSON 的測試案例,並具體斷言錯誤訊息中包含預期的片段內容,特別是需要測試超長字串截斷是否符合預期。
const emojis = '😀'.repeat(25);
const result = truncateString(emojis, 10);
// 應得到完整 10 個 emoji(以 code point 計),不殘留落單的代理碼元
assert.equal(Array.from(result).length, 10);
assert.equal(result, '😀'.repeat(10));
assert.ok(!/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(result), '不應殘留落單的高代理碼元');
});
test('fetchReleases 解析失敗時截斷過長的回應片段', async () => {
const huge = `{${'x'.repeat(5000)}`;
await withFetch(
async () => jsonResponse(huge, true),
async () => {
await assert.rejects(
() => fetchReleases('https://gitea.example.com/api'),
(err) => {
const match = err.message.match(/片段:「([\s\S]*)」$/);
assert.ok(match, '錯誤訊息應包含回應內容片段');
assert.ok(Array.from(match[1]).length <= 200, '片段應截斷至 200 字元內');
return true;
},
);
},
);
});
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');
},
);
}); });
+43 -58
View File
@@ -1,7 +1,8 @@
'use strict'; 'use strict';
const { test } = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { const {
compareVersionArrays, compareVersionArrays,
parseStableVersions, parseStableVersions,
@@ -11,91 +12,75 @@ const {
calculateVersion, calculateVersion,
} = require('../version'); } = 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.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.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 = [ const releases = [
{ tag_name: 'v1.2.3' }, rel('v1.2.3'),
{ tag_name: '2.0.0' }, rel('2.0.0'),
{ tag_name: 'v1.3.0-beta.1' }, rel('v1.0.0-beta.1'),
{ tag_name: 'nightly' }, rel('latest'),
{ tag_name: null }, { name: '沒有 tag_name' },
null,
]; ];
assert.deepEqual(parseStableVersions(releases), [[1, 2, 3], [2, 0, 0]]); assert.deepEqual(parseStableVersions(releases), [[1, 2, 3], [2, 0, 0]]);
}); });
test('parseStableVersions 對非陣列回傳空陣列', () => { test('latestStableVersion 無資料時回傳 0.0.0', () => {
assert.deepEqual(parseStableVersions(null), []);
assert.deepEqual(parseStableVersions(undefined), []);
});
test('latestStableVersion 在無資料時回傳 0.0.0', () => {
assert.equal(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 取最新的穩定版(含跨區段比較)', () => { test('latestStableVersion 取出最大穩定版', () => {
assert.equal(latestStableVersion([ const releases = [rel('v1.2.3'), rel('v1.9.9'), rel('v1.2.9'), rel('v0.5.0-beta.2')];
{ tag_name: 'v1.9.0' }, assert.equal(latestStableVersion(releases), '1.9.9');
{ tag_name: 'v1.10.0' },
{ tag_name: 'v1.2.3' },
]), '1.10.0');
}); });
test('nextReleaseVersion patch 進位', () => { test('nextReleaseVersion patch 進位', () => {
assert.equal(nextReleaseVersion('0.0.0'), '0.0.1'); assert.equal(nextReleaseVersion('0.0.0'), '0.0.1');
assert.equal(nextReleaseVersion('1.2.3'), '1.2.4'); 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'); 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'); assert.equal(nextReleaseVersion('1.9.9'), '2.0.0');
}); });
test('nextBetaNumber 在查無對應 beta 時回傳 1', () => { test('nextBetaNumber 無對應 beta 時回傳 1', () => {
assert.equal(nextBetaNumber([], '1.2.4'), 1); assert.equal(nextBetaNumber([], '0.0.1'), 1);
assert.equal(nextBetaNumber([{ tag_name: 'v9.9.9-beta.5' }], '1.2.4'), 1); assert.equal(nextBetaNumber([rel('v0.0.1')], '0.0.1'), 1);
assert.equal(nextBetaNumber(null, '1.2.4'), 1);
}); });
test('nextBetaNumber 取對應 beta 流水號最大值 + 1', () => { test('nextBetaNumber 取最大流水號加一', () => {
const releases = [ const releases = [rel('v1.3.0-beta.1'), rel('v1.3.0-beta.3'), rel('v1.3.0-beta.2')];
{ tag_name: 'v1.2.4-beta.1' }, assert.equal(nextBetaNumber(releases, '1.3.0'), 4);
{ 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('calculateVersion 計算下一個穩定版', () => { test('calculateVersion 無 release 的穩定版', () => {
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 時的預設值', () => {
assert.deepEqual(calculateVersion([], false), { latest: '0.0.0', version: '0.0.1' }); 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' }); 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' });
});
-75
View File
@@ -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/);
});
-105
View File
@@ -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');
},
);
});
-85
View File
@@ -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' });
});