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:
co-authored by
Claude Opus 4.8
parent
154ab032bf
commit
7c225d4122
@@ -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')
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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
@@ -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'])
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user