test(release-cleanup): 補齊 cleanup/config/main 與新驗證函式測試

新增 cleanupReleases、cleanupOrphanTags 的失敗路徑與分類測試、
loadConfig 環境變數驗證測試、main 整合測試,以及 requireUrl/
requireRepository 與 content-type 檢查測試。測試共 41 項全數通過。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-06-26 10:55:45 +08:00
co-authored by Claude Opus 4.8
parent 154ab032bf
commit 7c225d4122
6 changed files with 295 additions and 6 deletions
+58
View File
@@ -0,0 +1,58 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { loadConfig } from '../config.js'
const base = {
GITEA_SERVER_URL: 'https://gitea.example.com',
GITEA_REPOSITORY: 'owner/repo',
KEEP_COUNT: '2',
}
test('loadConfig 在缺少 GITEA_SERVER_URL 時丟出', () => {
assert.throws(
() => loadConfig({ GITEA_REPOSITORY: 'owner/repo', KEEP_COUNT: '2' }),
/GITEA_SERVER_URL is required/,
)
})
test('loadConfig 在 KEEP_COUNT 非整數時丟出', () => {
assert.throws(
() => loadConfig({ ...base, KEEP_COUNT: 'abc' }),
/non-negative integer/,
)
})
test('loadConfig 在 GITEA_SERVER_URL 非合法 URL 時丟出', () => {
assert.throws(
() => loadConfig({ ...base, GITEA_SERVER_URL: 'not a url' }),
/must be a valid URL/,
)
})
test('loadConfig 在 GITEA_REPOSITORY 含路徑穿越時丟出', () => {
assert.throws(
() => loadConfig({ ...base, GITEA_REPOSITORY: '../evil' }),
/owner\/repo without path traversal/,
)
})
test('loadConfig 在有效輸入時回傳完整設定物件', () => {
const cfg = loadConfig(base)
assert.equal(cfg.serverUrl, 'https://gitea.example.com')
assert.equal(cfg.repository, 'owner/repo')
assert.equal(cfg.token, null)
assert.equal(cfg.keepCount, 2)
assert.equal(
cfg.releaseApiUrl,
'https://gitea.example.com/api/v1/repos/owner/repo/releases',
)
assert.equal(
cfg.tagApiUrl,
'https://gitea.example.com/api/v1/repos/owner/repo/tags',
)
})
test('loadConfig 在提供 token 時保留 token 值', () => {
const cfg = loadConfig({ ...base, GITEA_TOKEN: 'secret' })
assert.equal(cfg.token, 'secret')
})
+43 -3
View File
@@ -1,4 +1,4 @@
import { test, beforeEach, afterEach } from 'node:test'
import { test, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { GiteaClient } from '../gitea-client.js'
@@ -8,6 +8,17 @@ afterEach(() => {
globalThis.fetch = realFetch
})
// 建立模擬的 JSON 回應(含 content-type 標頭)
function jsonResponse(data) {
return {
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => data,
text: async () => JSON.stringify(data),
}
}
test('建構子在有 token 時帶上 Authorization 標頭', () => {
const client = new GiteaClient({ token: 'abc' })
assert.equal(client.headers.Authorization, 'token abc')
@@ -28,7 +39,7 @@ test('fetchAllPages 逐頁讀取直到空陣列', async () => {
globalThis.fetch = async (url) => {
const page = new URL(url).searchParams.get('page')
requested.push(page)
return { ok: true, json: async () => pages[page] }
return jsonResponse(pages[page])
}
const client = new GiteaClient({})
@@ -41,8 +52,23 @@ test('fetchAllPages 逐頁讀取直到空陣列', async () => {
assert.deepEqual(requested, ['1', '2', '3'])
})
test('fetchAllPages 帶上逾時 signal', async () => {
let sawSignal = false
globalThis.fetch = async (_url, opts) => {
sawSignal = opts && typeof opts.signal === 'object' && opts.signal !== null
return jsonResponse([])
}
const client = new GiteaClient({})
await client.fetchAllPages('https://example.com/api')
assert.equal(sawSignal, true)
})
test('fetchAllPages 在 HTTP 錯誤時丟出例外', async () => {
globalThis.fetch = async () => ({ ok: false, status: 500 })
globalThis.fetch = async () => ({
ok: false,
status: 500,
text: async () => 'boom',
})
const client = new GiteaClient({})
await assert.rejects(
() => client.fetchAllPages('https://example.com/api'),
@@ -50,6 +76,20 @@ test('fetchAllPages 在 HTTP 錯誤時丟出例外', async () => {
)
})
test('fetchAllPages 在回應非 JSON 時丟出例外', async () => {
globalThis.fetch = async () => ({
ok: true,
status: 200,
headers: { get: () => 'text/html' },
text: async () => '<html>error</html>',
})
const client = new GiteaClient({})
await assert.rejects(
() => client.fetchAllPages('https://example.com/api'),
/non-JSON content-type/,
)
})
test('deleteResource 回傳 HTTP 狀態碼', async () => {
globalThis.fetch = async (url, opts) => {
assert.equal(opts.method, 'DELETE')
+43
View File
@@ -0,0 +1,43 @@
import { test, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { main } from '../index.js'
const realFetch = globalThis.fetch
const savedEnv = { ...process.env }
afterEach(() => {
globalThis.fetch = realFetch
process.env = { ...savedEnv }
})
function setEnv(env) {
for (const key of ['GITEA_SERVER_URL', 'GITEA_REPOSITORY', 'KEEP_COUNT', 'GITEA_TOKEN']) {
delete process.env[key]
}
Object.assign(process.env, env)
}
test('main 在環境變數有效且 API 正常時順利完成', async () => {
setEnv({
GITEA_SERVER_URL: 'https://gitea.example.com',
GITEA_REPOSITORY: 'owner/repo',
KEEP_COUNT: '2',
})
// 所有清單端點皆回空陣列(無 release、無 tag)
globalThis.fetch = async () => ({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => [],
})
await main() // 不應拋出
})
test('main 在必填環境變數缺失時 reject(供整合層級驗證錯誤處理)', async () => {
setEnv({
GITEA_REPOSITORY: 'owner/repo',
KEEP_COUNT: '2',
})
await assert.rejects(() => main(), /GITEA_SERVER_URL is required/)
})
+72 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { selectReleasesToDelete } from '../releases.js'
import { selectReleasesToDelete, cleanupReleases } from '../releases.js'
const releases = [
{ id: 1, tag_name: 'v1.0.0', created_at: '2024-01-01T00:00:00Z' },
@@ -32,3 +32,74 @@ test('selectReleasesToDelete 不改動原陣列', () => {
selectReleasesToDelete(releases, 1)
assert.deepEqual(releases, original)
})
test('selectReleasesToDelete 對無效 created_at 視為最舊,不產生 NaN 排序', () => {
const list = [
{ id: 1, tag_name: 'good', created_at: '2024-05-01T00:00:00Z' },
{ id: 2, tag_name: 'bad', created_at: 'not-a-date' },
{ id: 3, tag_name: 'empty' },
]
const toDelete = selectReleasesToDelete(list, 1)
// 最新的 good 被保留;兩筆無效日期視為最舊,落入待刪除
assert.equal(toDelete.length, 2)
assert.ok(!toDelete.some((r) => r.tag_name === 'good'))
})
// 建立可記錄刪除呼叫的假 client
function fakeClient(releases, statusFor = () => 204) {
const deleted = []
return {
deleted,
async fetchAllPages() {
return releases
},
async deleteResource(url) {
deleted.push(url)
return statusFor(url)
},
}
}
test('cleanupReleases 刪除超出保留數的舊 release', async () => {
const client = fakeClient([
{ id: 1, tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' },
{ id: 2, tag_name: 'v2', name: 'n2', created_at: '2024-02-01T00:00:00Z' },
{ id: 3, tag_name: 'v3', name: 'n3', created_at: '2024-03-01T00:00:00Z' },
])
await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 })
// 保留最新 v3,刪除 v2 與 v1
assert.deepEqual(client.deleted, ['http://x/releases/2', 'http://x/releases/1'])
})
test('cleanupReleases 在未超過保留數時不刪除', async () => {
const client = fakeClient([
{ id: 1, tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' },
])
await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 2 })
assert.equal(client.deleted.length, 0)
})
test('cleanupReleases 在 deleteResource 回傳非 204 時仍繼續處理其餘項目', async () => {
const client = fakeClient(
[
{ id: 1, tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' },
{ id: 2, tag_name: 'v2', name: 'n2', created_at: '2024-02-01T00:00:00Z' },
{ id: 3, tag_name: 'v3', name: 'n3', created_at: '2024-03-01T00:00:00Z' },
],
(url) => (url.endsWith('/2') ? 500 : 204), // v2 刪除失敗
)
await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 })
// 即使 v2 失敗(500),v1 仍被嘗試刪除
assert.deepEqual(client.deleted, ['http://x/releases/2', 'http://x/releases/1'])
})
test('cleanupReleases 略過沒有 id 的 release', async () => {
const client = fakeClient([
{ id: 1, tag_name: 'v3', name: 'n3', created_at: '2024-03-01T00:00:00Z' },
{ id: null, tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' },
{ id: 2, tag_name: 'v2', name: 'n2', created_at: '2024-02-01T00:00:00Z' },
])
await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 })
// 保留最新 v3;待刪除為 v2 與 (無 id 的) v1 → 無 id 者被略過
assert.deepEqual(client.deleted, ['http://x/releases/2'])
})
+49 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { categorizeTags } from '../tags.js'
import { categorizeTags, cleanupOrphanTags } from '../tags.js'
const tags = [
{ name: 'v3.0.0' },
@@ -38,3 +38,51 @@ test('categorizeTags 在沒有任何 release 指定時刪除所有具名 tag', (
['v3.0.0', 'v2.0.0', 'v1.0.0'],
)
})
// 依 URL 回傳對應清單的假 client(releases 與 tags 兩種端點)
function fakeClient({ releases, tagList }) {
const deleted = []
return {
deleted,
async fetchAllPages(url) {
return url.includes('/releases') ? releases : tagList
},
async deleteResource(url) {
deleted.push(url)
return 204
},
}
}
const config = {
releaseApiUrl: 'http://x/releases',
tagApiUrl: 'http://x/tags',
}
test('cleanupOrphanTags 刪除孤立 tag、保留被指定的、略過無名稱', async () => {
const client = fakeClient({
releases: [{ tag_name: 'v2.0.0' }],
tagList: [{ name: 'v2.0.0' }, { name: 'v1.0.0' }, { name: '' }],
})
await cleanupOrphanTags(client, config)
// v2.0.0 仍被 release 指定 → 保留;v1.0.0 孤立 → 刪除;'' 無名稱 → 略過
assert.deepEqual(client.deleted, ['http://x/tags/v1.0.0'])
})
test('cleanupOrphanTags 在所有 tag 都被指定時不刪除', async () => {
const client = fakeClient({
releases: [{ tag_name: 'v2.0.0' }, { tag_name: 'v1.0.0' }],
tagList: [{ name: 'v2.0.0' }, { name: 'v1.0.0' }],
})
await cleanupOrphanTags(client, config)
assert.equal(client.deleted.length, 0)
})
test('cleanupOrphanTags 在沒有任何 release 時刪除所有具名 tag', async () => {
const client = fakeClient({
releases: [],
tagList: [{ name: 'v2.0.0' }, { name: 'v1.0.0' }],
})
await cleanupOrphanTags(client, config)
assert.deepEqual(client.deleted, ['http://x/tags/v2.0.0', 'http://x/tags/v1.0.0'])
})
+30 -1
View File
@@ -1,6 +1,12 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { isEmptyOrNull, requireValue, requireInteger } from '../validate.js'
import {
isEmptyOrNull,
requireValue,
requireInteger,
requireUrl,
requireRepository,
} from '../validate.js'
test('isEmptyOrNull 對空值回傳 true', () => {
assert.equal(isEmptyOrNull(''), true)
@@ -35,3 +41,26 @@ test('requireInteger 拒絕非整數或負數', () => {
assert.throws(() => requireInteger('KEEP_COUNT', '1.5'), /non-negative integer/)
assert.throws(() => requireInteger('KEEP_COUNT', 'abc'), /non-negative integer/)
})
test('requireUrl 接受 http/https URL', () => {
assert.doesNotThrow(() => requireUrl('URL', 'https://gitea.example.com'))
assert.doesNotThrow(() => requireUrl('URL', 'http://localhost:3000'))
})
test('requireUrl 拒絕非法 URL 或非 http(s) 協定', () => {
assert.throws(() => requireUrl('URL', 'not a url'), /must be a valid URL/)
assert.throws(() => requireUrl('URL', 'ftp://example.com'), /http or https/)
assert.throws(() => requireUrl('URL', 'file:///etc/passwd'), /http or https/)
})
test('requireRepository 接受合法 owner/repo', () => {
assert.doesNotThrow(() => requireRepository('REPO', 'owner/repo'))
assert.doesNotThrow(() => requireRepository('REPO', 'my-org/my_repo.git'))
})
test('requireRepository 拒絕路徑穿越或格式錯誤', () => {
assert.throws(() => requireRepository('REPO', '../evil'), /owner\/repo/)
assert.throws(() => requireRepository('REPO', 'owner/repo/extra'), /owner\/repo/)
assert.throws(() => requireRepository('REPO', 'noslash'), /owner\/repo/)
assert.throws(() => requireRepository('REPO', 'owner/..'), /owner\/repo/)
})