refactor(release-cleanup): 將清理邏輯由 bash 改寫為 Node.js #5
@@ -5,9 +5,17 @@ import { isEmptyOrNull, requireValue, requireInteger } from './validate.js'
|
|||||||
import { section, info, warn } from './logger.js'
|
import { section, info, warn } from './logger.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 從環境變數載入設定並完成驗證。
|
* 從環境變數載入設定並完成驗證,作為整個清理流程的設定來源。
|
||||||
* @param {NodeJS.ProcessEnv} env 環境變數來源,預設為 process.env
|
*
|
||||||
* @returns 包含 API 位址、token、保留數量等資訊的設定物件
|
* 讀取 `GITEA_SERVER_URL`、`GITEA_REPOSITORY`、`KEEP_COUNT`、`GITEA_TOKEN`,
|
||||||
|
* 其中前三者為必填(`KEEP_COUNT` 另須為非負整數);`GITEA_TOKEN` 為選填,
|
||||||
|
Ghost marked this conversation as resolved
|
|||||||
|
* 為空時改以匿名方式呼叫 API。驗證過程會將參數印出(token 以 `[redacted]` 遮蔽)。
|
||||||
|
*
|
||||||
|
* @param {NodeJS.ProcessEnv} env 環境變數來源,預設為 `process.env`,可於測試時注入假值
|
||||||
|
* @returns {{serverUrl: string, repository: string, token: string|null, keepCount: number, releaseApiUrl: string, tagApiUrl: string}}
|
||||||
|
* 設定物件:`token` 在環境變數為空時為 `null`;`keepCount` 為數值;
|
||||||
|
* `releaseApiUrl`/`tagApiUrl` 為依 server 與 repository 組出的 API 位址。
|
||||||
|
* @throws {Error} 任一必填項缺漏,或 `KEEP_COUNT` 非非負整數時拋出(由 [[validate]] 的檢查函式丟出)
|
||||||
*/
|
*/
|
||||||
export function loadConfig(env = process.env) {
|
export function loadConfig(env = process.env) {
|
||||||
section('參數檢查')
|
section('參數檢查')
|
||||||
|
Ghost marked this conversation as resolved
Outdated
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Assassin
**問題**:GITEA_SERVER_URL 缺乏嚴格的格式驗證與協定強制。攻擊者若控制此環境變數,可將其設定為任意惡意伺服器(例如 http://attacker.com),引發 SSRF 攻擊或在沒有 HTTPS 的情況下,導致傳輸過程中 GITEA_TOKEN 被竊取。
**建議**:應使用 Node.js 的 URL 類別驗證 GITEA_SERVER_URL 格式是否合法,並強制使用 HTTPS 協定。若可能,建議實作 allowed host 的白名單機制。
|
|||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
// 與 Gitea API 溝通的 HTTP 客戶端,封裝認證標頭、分頁讀取與刪除請求。
|
// 與 Gitea API 溝通的 HTTP 客戶端,封裝認證標頭、分頁讀取與刪除請求。
|
||||||
// 對應原本 entrypoint.sh 的 fetch_all_pages 與 curl DELETE 呼叫。
|
// 對應原本 entrypoint.sh 的 fetch_all_pages 與 curl DELETE 呼叫。
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 與 Gitea REST API 溝通的輕量 HTTP 客戶端,負責帶上認證標頭、分頁讀取清單與發出刪除請求。
|
||||||
|
* 取代原 bash 版本以 `curl`/`jq` 進行的 API 操作。
|
||||||
|
*/
|
||||||
export class GiteaClient {
|
export class GiteaClient {
|
||||||
/**
|
/**
|
||||||
* @param {{ token?: string | null }} options 認證設定;有 token 時帶上 Authorization 標頭
|
* 建立客戶端;有 token 時於後續所有請求帶上 `Authorization: token <token>` 標頭,
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Leo
**問題**:目前 `MAX_PAGES` 是寫死在程式碼中的常數,未來如果 API 規格變更或是特殊儲存庫的 Release 數量激增,維護者需要進程式碼修改,且這在不同的環境下可能需要不同的上限。
**建議**:建議將 `MAX_PAGES` 改為透過環境變數傳入,並設定一個合理的預設值,增加部署時的彈性。
|
|||||||
|
* 無 token(或為 `null`)時以匿名方式呼叫。
|
||||||
|
* @param {{ token?: string | null }} [options] 認證設定
|
||||||
*/
|
*/
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Maya
**問題**:sanitizeBody 函式負責 API 回應的字串清理與格式化,但目前缺乏直接的單元測試,無法確保正規表示式能正確處理所有控制字元以及長度限制。
**建議**:請在 app/test/gitea-client.test.js 中新增 sanitizeBody 的單元測試,務必包含正常字串、包含控制字元的字串、空字串/null 值、以及超過 200 字元的極端案例。
|
|||||||
constructor({ token = null } = {}) {
|
constructor({ token = null } = {}) {
|
||||||
this.headers = {}
|
this.headers = {}
|
||||||
@@ -13,9 +19,14 @@ export class GiteaClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 逐頁讀取分頁式清單 API,直到回傳空陣列為止,合併成單一陣列。
|
* 逐頁讀取分頁式清單 API(每頁以 `?page=N` 由 1 遞增),直到某頁回傳空陣列(或非陣列)為止,
|
||||||
* @param {string} baseUrl 不含 query string 的 API 位址
|
* 將所有頁面項目合併成單一陣列回傳。
|
||||||
* @returns {Promise<any[]>} 所有頁面合併後的項目
|
*
|
||||||
|
* 注意:終止條件依賴「空陣列代表最後一頁」的假設;若 API 不以空陣列結尾則迴圈不會自然停止。
|
||||||
|
Ghost marked this conversation as resolved
Outdated
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Leo
**問題**:HTTP 請求 (fetch) 未設定逾時時間,若 API 伺服器回應緩慢或網路阻塞,可能導致此 Actions 執行過程長時間卡死,造成除錯與排程上的困難。
**建議**:建議使用 `AbortController` 為所有 `fetch` 請求設定合理的逾時限制(例如 30 秒),並正確處理 `AbortError`。
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Bard
**問題**:在 `GiteaClient` 建構子中,`this.headers` 初始化時僅簡單檢查 `token` 是否存在,且假設所有請求都適用這組標頭。雖然目前專案單純,但若未來擴充需針對不同 API 採取不同標頭時,此處結構會稍顯死板。
**建議**:考慮將產生 header 的邏輯抽離成一個內部 private 函式(如 `_getHeaders()`),即便現在很簡單,也能增加未來的彈性。
|
|||||||
|
*
|
||||||
|
* @param {string} baseUrl 不含 query string 的 API 位址(本方法會自行附加 `?page=N`)
|
||||||
|
* @returns {Promise<any[]>} 所有頁面合併後的項目陣列;無資料時為空陣列
|
||||||
|
Ghost marked this conversation as resolved
Outdated
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Mage
**問題**:在 `fetchAllPages` 中未檢查 API 回傳的內容是否符合預期格式(除了 Array 檢查)。若 API 回傳非 JSON 格式的內容(例如 HTML 錯誤頁面),`res.json()` 會拋出 SyntaxError,且未被目前邏輯中的 `try-catch` 明確攔截處理,會導致程式在 catch 區塊中直接結束並輸出錯誤訊息,缺乏更細緻的除錯資訊。
**建議**:在解析 JSON 前,應先判斷 `res.headers.get('content-type')` 是否包含 `application/json`,若非 JSON,應將 `res.text()` 的內容一併在錯誤訊息中輸出,方便排查。
|
|||||||
|
* @throws {Error} 任一頁回應 HTTP 非 2xx(`!res.ok`)時拋出,訊息含 URL 與狀態碼
|
||||||
*/
|
*/
|
||||||
async fetchAllPages(baseUrl) {
|
async fetchAllPages(baseUrl) {
|
||||||
|
Ghost marked this conversation as resolved
Outdated
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Mage
**問題**:在 `fetchAllPages` 的 `while` 迴圈中直接使用 `await fetch` 而沒有設定逾時(timeout)。若 Gitea API 伺服器因負載過重導致回應緩慢或掛起,該容器進程將會永久卡死,無法釋放資源或失敗重試。
**建議**:建議使用 `AbortController` 設定合理的逾時時間(例如:`{ signal: AbortSignal.timeout(30000) }`),確保網路呼叫在預期時間內未完成時能主動拋出例外。
|
|||||||
const all = []
|
const all = []
|
||||||
@@ -42,9 +53,9 @@ export class GiteaClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
Ghost marked this conversation as resolved
Outdated
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Mage
**問題**:fetchAllPages 的迴圈終止條件僅檢查 `!Array.isArray(items) || items.length === 0`。若 API 因為某種狀況(如認證過期或 API 內部錯誤)回傳了非陣列格式的 JSON 錯誤訊息,會直接視為「最後一頁」而提前終止迴圈,導致回傳不完整的清單且未報錯。
**建議**:應在確認 res.ok 為 true 的前提下,嚴格要求 items 為陣列。若 res.ok 為 true 但 items 不為陣列,應拋出錯誤而非將其視為終止訊號。
|
|||||||
/**
|
/**
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Mage
**問題**:await res.json() 若遇到 API 回傳格式不符的內容(即使 content-type 為 application/json),會直接拋出 SyntaxError,且因為此處未以 try-catch 包裹,會導致程式崩潰並遺失錯誤發生的 URL 上下文。
**建議**:將 await res.json() 包裹在 try-catch 區塊中,解析失敗時捕捉錯誤並拋出包含當前請求 URL 的明確錯誤訊息。
|
|||||||
* 對指定資源發出 DELETE 請求。
|
* 對指定資源發出 DELETE 請求,僅回傳 HTTP 狀態碼供呼叫端判斷成敗(不檢查 ok、不解析回應內容)。
|
||||||
* @param {string} url 目標資源位址
|
* @param {string} url 目標資源位址
|
||||||
* @returns {Promise<number>} HTTP 狀態碼
|
* @returns {Promise<number>} 回應的 HTTP 狀態碼(成功刪除通常為 204)
|
||||||
*/
|
*/
|
||||||
|
Ghost marked this conversation as resolved
Outdated
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Bard
**問題**:在 `fetchAllPages` 中使用 `all.push(...items)` 展開處理分頁資料,若 API 回傳的項目數量龐大(例如數千筆),極可能觸發「超過呼叫堆疊最大長度」(Maximum call stack size exceeded)導致程式崩潰,這對長期運行的工具而言是一大隱憂。
**建議**:建議改用簡單的迴圈 `for (const item of items) { all.push(item); }` 來逐一加入項目,這能完美規避展開運算子在處理大型陣列時的堆疊風險,讓程式運行得更加穩健優雅。
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Bard
**問題**:fetchAllPages 採展開運算子處理分頁資料,面對龐大項目數量可能導致 Maximum call stack size exceeded 錯誤,並伴隨無窮迴圈風險 (缺少 MAX_PAGES 限制)。
**建議**:改用簡單的 for...of 迴圈逐一 push 項目以規避堆疊風險,並務必加入 MAX_PAGES 常數作為安全斷點,防止 API 異常導致無限迴圈與資源耗盡。
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Bard
**問題**:fetchAllPages 採展開運算子處理分頁資料,面對龐大項目數量可能導致 Maximum call stack size exceeded 錯誤,並伴隨無窮迴圈風險 (缺少 MAX_PAGES 限制)。
**建議**:改用簡單的 for...of 迴圈逐一 push 項目以規避堆疊風險,並務必加入 MAX_PAGES 常數作為安全斷點,防止 API 異常導致無限迴圈與資源耗盡。
|
|||||||
async deleteResource(url) {
|
async deleteResource(url) {
|
||||||
const res = await fetch(url, { method: 'DELETE', headers: this.headers })
|
const res = await fetch(url, { method: 'DELETE', headers: this.headers })
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { section, info, success, fail, warn } from './logger.js'
|
|||||||
import { isEmptyOrNull } from './validate.js'
|
import { isEmptyOrNull } from './validate.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 依建立時間由新到舊排序,保留最新的 keepCount 筆,回傳其餘待刪除的成品。
|
* 依 `created_at` 建立時間由新到舊排序,保留最新的 `keepCount` 筆,回傳其餘(較舊)待刪除的成品。
|
||||||
* 純函式,方便單元測試。
|
* 以展開運算子複製陣列後再排序,不會改動傳入的原陣列。為純函式,方便單元測試。
|
||||||
* @param {any[]} releases 成品清單
|
* @param {any[]} releases 成品清單,每個元素預期含有 `created_at` 欄位
|
||||||
* @param {number} keepCount 要保留的筆數
|
* @param {number} keepCount 要保留的最新筆數;為 0 時代表全部刪除
|
||||||
* @returns {any[]} 需要刪除的成品(較舊者)
|
* @returns {any[]} 需要刪除的成品陣列(較舊者),依新到舊排序
|
||||||
*/
|
*/
|
||||||
export function selectReleasesToDelete(releases, keepCount) {
|
export function selectReleasesToDelete(releases, keepCount) {
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Leo
**問題**:使用 `new Date(release.created_at)` 進行排序時,若 `created_at` 格式非預期或為空,會導致 `NaN` 並造成排序異常,可能無法正確刪除舊版本。
**建議**:建議在排序邏輯中增加對 `created_at` 的有效性檢查,若無效則給予預設值(例如:`new Date(0)`),確保排序結果可預期。
|
|||||||
const sorted = [...releases].sort(
|
const sorted = [...releases].sort(
|
||||||
@@ -19,9 +19,15 @@ export function selectReleasesToDelete(releases, keepCount) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Leo
**問題**:在 `selectReleasesToDelete` 函式中,對於 `Date.parse` 無法解析的日期直接視為 `0`,這在資料清理邏輯中是一個隱晦的行為,未來維護者可能不清楚為什麼無效日期會優先被刪除。
**建議**:應明確記錄無效日期的處理方式(例如記錄 warning),或在 `Date.parse` 失敗時,應考慮給予一個明確的邏輯(例如拋出錯誤或放到特定排序位置),以減少不可預期的副作用。
|
|||||||
/**
|
/**
|
||||||
|
Ghost marked this conversation as resolved
Outdated
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Maya
**問題**:cleanupReleases 是執行刪除舊成品的核心函式,但目前缺乏針對 API 呼叫失敗(如 GET 失敗、DELETE 失敗)的測試,無法驗證錯誤處理邏輯是否如預期運作。
**建議**:使用測試框架搭配 mock 伺服器回應,補齊 cleanupReleases 的測試案例,特別是驗證當 deleteResource 回傳非 204 狀態碼時,系統是否正確記錄錯誤並繼續或中斷。
|
|||||||
* 讀取成品清單,刪除超出保留數量的舊版本成品。
|
* 讀取全部成品清單,刪除超出保留數量的舊版本成品。
|
||||||
* @param {import('./gitea-client.js').GiteaClient} client
|
*
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Rogue
**問題**:為了排序而進行了多次 map 操作,先將陣列 map 為物件陣列,排序後又 map 回原始物件陣列。這在大數據集下會造成多次 O(n) 的陣列分配與垃圾回收壓力,浪費 CPU 與記憶體週期。
**建議**:排序時應嘗試減少陣列中間狀態的產生,例如考慮使用原地排序(若不介意原陣列變更)或簡化 mapping 的邏輯。
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Mage
**問題**:在 `selectReleasesToDelete` 的排序邏輯中,若多個成品具有完全相同的 `created_at` 時間戳,目前的排序行為依賴於 JavaScript 引擎對 `sort()` 的實作(在某些情況下可能不穩定),導致保留與刪除的成品選擇具有不確定性。
**建議**:建議在排序邏輯中加入次要的排序鍵值(如 `id` 或 `tag_name`)作為比較依據(例如:若時間相同,則比較 ID 大小),以確保排序結果在時間相同時仍具有決定性。
|
|||||||
* @param {ReturnType<import('./config.js').loadConfig>} config
|
* 流程:取得所有 release → 若總數不超過 `keepCount` 則直接結束(無需清理)→
|
||||||
|
* 否則以 [[selectReleasesToDelete]] 取出待刪除清單,逐筆刪除(略過沒有 `id` 的項目),
|
||||||
|
* 依回應狀態碼 204 判定成功與否並輸出結果。
|
||||||
|
*
|
||||||
|
* @param {import('./gitea-client.js').GiteaClient} client 用於讀取與刪除的 Gitea 客戶端
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Bard
**問題**:在 `cleanupReleases` 函式中,參數 `config` 是 `ReturnType<import('./config.js').loadConfig>`,這種型別定義方式雖然準確,但過於冗長且與實作細節耦合過深,影響程式碼的可讀性與簡潔度。
**建議**:建議在 `app/config.js` 定義並匯出型別註解(JSDoc @typedef),然後在此處直接使用該別名,提升整體程式碼的可讀性。
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Bard
**問題**:config 參數型別定義方式雖然準確,但過於冗長且與實作細節耦合過深,影響程式碼的可讀性與簡潔度。
**建議**:建議在 `app/config.js` 定義並匯出型別註解(JSDoc @typedef),然後在各處直接使用該別名。
|
|||||||
|
* @param {ReturnType<import('./config.js').loadConfig>} config 設定物件,使用其 `releaseApiUrl` 與 `keepCount`
|
||||||
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
export async function cleanupReleases(client, config) {
|
export async function cleanupReleases(client, config) {
|
||||||
section('取得成品資訊')
|
section('取得成品資訊')
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { section, info, success, fail, warn } from './logger.js'
|
|||||||
import { isEmptyOrNull } from './validate.js'
|
import { isEmptyOrNull } from './validate.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 將 tag 分類為保留、刪除或略過(無名稱)。
|
* 將每個 tag 分類為保留、刪除或略過三類,判斷優先序為:無名稱 → skip、仍被指定 → keep、其餘 → delete。
|
||||||
* 仍被任一 release 指定的 tag 予以保留,其餘視為孤立 tag 待刪除。
|
*
|
||||||
* 純函式,方便單元測試。
|
* 先把 `releaseTagNames` 收進 `Set` 以利 O(1) 比對;名稱經 [[isEmptyOrNull]] 判定為空者標為 `skip`,
|
||||||
* @param {any[]} tags tag 清單
|
* 仍被任一 release 指定者標為 `keep`,其餘視為孤立 tag 標為 `delete`。為純函式,無副作用,方便單元測試。
|
||||||
* @param {Iterable<string>} releaseTagNames 仍被 release 指定的 tag 名稱
|
*
|
||||||
* @returns {{ tag: any, action: 'keep' | 'delete' | 'skip' }[]}
|
* @param {any[]} tags tag 清單,每個元素預期含有 `name` 欄位
|
||||||
|
* @param {Iterable<string>} releaseTagNames 仍被 release 指定的 tag 名稱集合
|
||||||
|
* @returns {{ tag: any, action: 'keep' | 'delete' | 'skip' }[]} 與輸入等長且保持原順序的分類結果
|
||||||
*/
|
*/
|
||||||
export function categorizeTags(tags, releaseTagNames) {
|
export function categorizeTags(tags, releaseTagNames) {
|
||||||
const keep = new Set(releaseTagNames)
|
const keep = new Set(releaseTagNames)
|
||||||
@@ -27,9 +29,15 @@ export function categorizeTags(tags, releaseTagNames) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重新讀取成品清單以取得仍被指定的 tag,再刪除未指定 release 的孤立 tag。
|
* 重新讀取成品清單以取得仍被指定的 tag,再刪除未被任何 release 指定的孤立 tag。
|
||||||
* @param {import('./gitea-client.js').GiteaClient} client
|
*
|
||||||
* @param {ReturnType<import('./config.js').loadConfig>} config
|
* 流程:重新抓取全部 release(反映刪除舊版本後的最新狀態)並取出其 `tag_name` 集合 →
|
||||||
|
* 抓取全部 tag → 以 [[categorizeTags]] 分類,對 `skip` 警告略過、`keep` 保留,
|
||||||
|
Ghost marked this conversation as resolved
Outdated
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Maya
**問題**:cleanupOrphanTags 涉及多次 API 交互,目前缺乏測試驗證邏輯,無法確保在 API 請求失敗或標籤分類錯誤時系統的行為一致性。
**建議**:補齊 cleanupOrphanTags 的測試案例,需模擬 fetchAllPages 回傳內容,並驗證對於不同 action(keep/delete/skip)的處理流程是否正確。
|
|||||||
|
* 對 `delete` 發出刪除並依狀態碼 204 判定成功與否。
|
||||||
|
*
|
||||||
|
* @param {import('./gitea-client.js').GiteaClient} client 用於讀取與刪除的 Gitea 客戶端
|
||||||
|
* @param {ReturnType<import('./config.js').loadConfig>} config 設定物件,使用其 `releaseApiUrl` 與 `tagApiUrl`
|
||||||
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
export async function cleanupOrphanTags(client, config) {
|
export async function cleanupOrphanTags(client, config) {
|
||||||
section('刪除未指定 release 的 tag')
|
section('刪除未指定 release 的 tag')
|
||||||
|
|||||||
嚴重等級:🟡 警告
審查員:Maya
問題:loadConfig 函式雖有呼叫驗證邏輯,但缺乏針對環境變數異常情境(如必填欄位缺失、KEEP_COUNT 非整數)的單元測試,無法確保配置載入流程的穩定性。
建議:補齊 app/test/config.test.js,測試當 process.env 缺少必要參數或 KEEP_COUNT 為無效數字時,loadConfig 是否會正確拋出錯誤。