diff --git a/app/test/config.test.js b/app/test/config.test.js new file mode 100644 index 0000000..cd1975c --- /dev/null +++ b/app/test/config.test.js @@ -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') +}) diff --git a/app/test/gitea-client.test.js b/app/test/gitea-client.test.js index 1a70a9c..c6b82c7 100644 --- a/app/test/gitea-client.test.js +++ b/app/test/gitea-client.test.js @@ -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 () => 'error', + }) + 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') diff --git a/app/test/main.test.js b/app/test/main.test.js new file mode 100644 index 0000000..00e8d74 --- /dev/null +++ b/app/test/main.test.js @@ -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/) +}) diff --git a/app/test/releases.test.js b/app/test/releases.test.js index b438545..6c88821 100644 --- a/app/test/releases.test.js +++ b/app/test/releases.test.js @@ -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']) +}) diff --git a/app/test/tags.test.js b/app/test/tags.test.js index d88abb2..debbc1f 100644 --- a/app/test/tags.test.js +++ b/app/test/tags.test.js @@ -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']) +}) diff --git a/app/test/validate.test.js b/app/test/validate.test.js index d4b39d9..b715c7d 100644 --- a/app/test/validate.test.js +++ b/app/test/validate.test.js @@ -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/) +})