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 assert from 'node:assert/strict'
|
||||||
import { GiteaClient } from '../gitea-client.js'
|
import { GiteaClient } from '../gitea-client.js'
|
||||||
|
|
||||||
@@ -8,6 +8,17 @@ afterEach(() => {
|
|||||||
globalThis.fetch = realFetch
|
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 標頭', () => {
|
test('建構子在有 token 時帶上 Authorization 標頭', () => {
|
||||||
const client = new GiteaClient({ token: 'abc' })
|
const client = new GiteaClient({ token: 'abc' })
|
||||||
assert.equal(client.headers.Authorization, 'token abc')
|
assert.equal(client.headers.Authorization, 'token abc')
|
||||||
@@ -28,7 +39,7 @@ test('fetchAllPages 逐頁讀取直到空陣列', async () => {
|
|||||||
globalThis.fetch = async (url) => {
|
globalThis.fetch = async (url) => {
|
||||||
const page = new URL(url).searchParams.get('page')
|
const page = new URL(url).searchParams.get('page')
|
||||||
requested.push(page)
|
requested.push(page)
|
||||||
return { ok: true, json: async () => pages[page] }
|
return jsonResponse(pages[page])
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new GiteaClient({})
|
const client = new GiteaClient({})
|
||||||
@@ -41,8 +52,23 @@ test('fetchAllPages 逐頁讀取直到空陣列', async () => {
|
|||||||
assert.deepEqual(requested, ['1', '2', '3'])
|
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 () => {
|
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({})
|
const client = new GiteaClient({})
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => client.fetchAllPages('https://example.com/api'),
|
() => 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 () => {
|
test('deleteResource 回傳 HTTP 狀態碼', async () => {
|
||||||
globalThis.fetch = async (url, opts) => {
|
globalThis.fetch = async (url, opts) => {
|
||||||
assert.equal(opts.method, 'DELETE')
|
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 { test } from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { selectReleasesToDelete } from '../releases.js'
|
import { selectReleasesToDelete, cleanupReleases } from '../releases.js'
|
||||||
|
|
||||||
const releases = [
|
const releases = [
|
||||||
{ id: 1, tag_name: 'v1.0.0', created_at: '2024-01-01T00:00:00Z' },
|
{ id: 1, tag_name: 'v1.0.0', created_at: '2024-01-01T00:00:00Z' },
|
||||||
@@ -32,3 +32,74 @@ test('selectReleasesToDelete 不改動原陣列', () => {
|
|||||||
selectReleasesToDelete(releases, 1)
|
selectReleasesToDelete(releases, 1)
|
||||||
assert.deepEqual(releases, original)
|
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 { test } from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { categorizeTags } from '../tags.js'
|
import { categorizeTags, cleanupOrphanTags } from '../tags.js'
|
||||||
|
|
||||||
const tags = [
|
const tags = [
|
||||||
{ name: 'v3.0.0' },
|
{ name: 'v3.0.0' },
|
||||||
@@ -38,3 +38,51 @@ test('categorizeTags 在沒有任何 release 指定時刪除所有具名 tag', (
|
|||||||
['v3.0.0', 'v2.0.0', 'v1.0.0'],
|
['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 { test } from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
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', () => {
|
test('isEmptyOrNull 對空值回傳 true', () => {
|
||||||
assert.equal(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', '1.5'), /non-negative integer/)
|
||||||
assert.throws(() => requireInteger('KEEP_COUNT', 'abc'), /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