refactor(release-cleanup): 將清理邏輯由 bash 改寫為 Node.js #5
@@ -0,0 +1,60 @@
|
||||
import { test, beforeEach, afterEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { GiteaClient } from '../gitea-client.js'
|
||||
|
||||
const realFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch
|
||||
})
|
||||
|
||||
test('建構子在有 token 時帶上 Authorization 標頭', () => {
|
||||
const client = new GiteaClient({ token: 'abc' })
|
||||
assert.equal(client.headers.Authorization, 'token abc')
|
||||
})
|
||||
|
||||
test('建構子在無 token 時不帶 Authorization 標頭', () => {
|
||||
const client = new GiteaClient({})
|
||||
assert.equal(client.headers.Authorization, undefined)
|
||||
})
|
||||
|
||||
test('fetchAllPages 逐頁讀取直到空陣列', async () => {
|
||||
const pages = {
|
||||
1: [{ id: 1 }, { id: 2 }],
|
||||
2: [{ id: 3 }],
|
||||
3: [],
|
||||
}
|
||||
const requested = []
|
||||
globalThis.fetch = async (url) => {
|
||||
const page = new URL(url).searchParams.get('page')
|
||||
requested.push(page)
|
||||
return { ok: true, json: async () => pages[page] }
|
||||
}
|
||||
|
||||
|
Ghost marked this conversation as resolved
|
||||
const client = new GiteaClient({})
|
||||
const items = await client.fetchAllPages('https://example.com/api')
|
||||
|
||||
assert.deepEqual(
|
||||
items.map((i) => i.id),
|
||||
[1, 2, 3],
|
||||
)
|
||||
assert.deepEqual(requested, ['1', '2', '3'])
|
||||
})
|
||||
|
||||
test('fetchAllPages 在 HTTP 錯誤時丟出例外', async () => {
|
||||
globalThis.fetch = async () => ({ ok: false, status: 500 })
|
||||
const client = new GiteaClient({})
|
||||
await assert.rejects(
|
||||
() => client.fetchAllPages('https://example.com/api'),
|
||||
/HTTP 500/,
|
||||
)
|
||||
})
|
||||
|
||||
test('deleteResource 回傳 HTTP 狀態碼', async () => {
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
assert.equal(opts.method, 'DELETE')
|
||||
return { status: 204 }
|
||||
}
|
||||
const client = new GiteaClient({})
|
||||
assert.equal(await client.deleteResource('https://example.com/api/1'), 204)
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { selectReleasesToDelete } from '../releases.js'
|
||||
|
||||
const releases = [
|
||||
{ id: 1, tag_name: 'v1.0.0', created_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 3, tag_name: 'v3.0.0', created_at: '2024-03-01T00:00:00Z' },
|
||||
{ id: 2, tag_name: 'v2.0.0', created_at: '2024-02-01T00:00:00Z' },
|
||||
]
|
||||
|
||||
test('selectReleasesToDelete 保留最新的 keepCount 筆,刪除其餘', () => {
|
||||
const toDelete = selectReleasesToDelete(releases, 2)
|
||||
assert.equal(toDelete.length, 1)
|
||||
assert.equal(toDelete[0].tag_name, 'v1.0.0')
|
||||
})
|
||||
|
||||
test('selectReleasesToDelete 在數量不超過保留數時回傳空陣列', () => {
|
||||
assert.deepEqual(selectReleasesToDelete(releases, 3), [])
|
||||
assert.deepEqual(selectReleasesToDelete(releases, 5), [])
|
||||
})
|
||||
|
||||
test('selectReleasesToDelete 在 keepCount 為 0 時刪除全部(由新到舊)', () => {
|
||||
const toDelete = selectReleasesToDelete(releases, 0)
|
||||
assert.deepEqual(
|
||||
toDelete.map((r) => r.tag_name),
|
||||
['v3.0.0', 'v2.0.0', 'v1.0.0'],
|
||||
)
|
||||
})
|
||||
|
||||
test('selectReleasesToDelete 不改動原陣列', () => {
|
||||
const original = [...releases]
|
||||
selectReleasesToDelete(releases, 1)
|
||||
assert.deepEqual(releases, original)
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { categorizeTags } from '../tags.js'
|
||||
|
||||
const tags = [
|
||||
{ name: 'v3.0.0' },
|
||||
{ name: 'v2.0.0' },
|
||||
{ name: 'v1.0.0' },
|
||||
{ name: '' },
|
||||
]
|
||||
|
||||
test('categorizeTags 保留仍被 release 指定的 tag', () => {
|
||||
const result = categorizeTags(tags, ['v3.0.0', 'v2.0.0'])
|
||||
const byName = Object.fromEntries(
|
||||
result.map(({ tag, action }) => [tag.name, action]),
|
||||
)
|
||||
assert.equal(byName['v3.0.0'], 'keep')
|
||||
assert.equal(byName['v2.0.0'], 'keep')
|
||||
})
|
||||
|
||||
test('categorizeTags 將未指定 release 的 tag 標記為刪除', () => {
|
||||
const result = categorizeTags(tags, ['v3.0.0'])
|
||||
const v1 = result.find(({ tag }) => tag.name === 'v1.0.0')
|
||||
assert.equal(v1.action, 'delete')
|
||||
})
|
||||
|
||||
test('categorizeTags 將無名稱的 tag 標記為略過', () => {
|
||||
const result = categorizeTags(tags, [])
|
||||
const empty = result.find(({ tag }) => tag.name === '')
|
||||
assert.equal(empty.action, 'skip')
|
||||
})
|
||||
|
||||
test('categorizeTags 在沒有任何 release 指定時刪除所有具名 tag', () => {
|
||||
const result = categorizeTags(tags, [])
|
||||
const deletes = result.filter(({ action }) => action === 'delete')
|
||||
assert.deepEqual(
|
||||
deletes.map(({ tag }) => tag.name),
|
||||
['v3.0.0', 'v2.0.0', 'v1.0.0'],
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { isEmptyOrNull, requireValue, requireInteger } from '../validate.js'
|
||||
|
||||
test('isEmptyOrNull 對空值回傳 true', () => {
|
||||
assert.equal(isEmptyOrNull(''), true)
|
||||
assert.equal(isEmptyOrNull(null), true)
|
||||
assert.equal(isEmptyOrNull(undefined), true)
|
||||
assert.equal(isEmptyOrNull('null'), true)
|
||||
})
|
||||
|
||||
test('isEmptyOrNull 對有效值回傳 false', () => {
|
||||
assert.equal(isEmptyOrNull('value'), false)
|
||||
assert.equal(isEmptyOrNull('0'), false)
|
||||
assert.equal(isEmptyOrNull(0), false)
|
||||
})
|
||||
|
||||
test('requireValue 對空值丟出錯誤', () => {
|
||||
assert.throws(() => requireValue('NAME', ''), /NAME is required/)
|
||||
assert.throws(() => requireValue('NAME', 'null'), /NAME is required/)
|
||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Maya
**問題**:雖然有 `requireInteger` 的測試,但沒有測試當 `KEEP_COUNT` 為 `0` 時的邊界情況。雖然 0 在邏輯上可能是允許的,但這對於清理邏輯來說是個關鍵的邊界。
**建議**:明確測試 `requireInteger('KEEP_COUNT', '0')` 並斷言其不應拋出錯誤,確保系統允許保留 0 個成品的配置。
|
||||
})
|
||||
|
||||
test('requireValue 對有效值不丟出錯誤', () => {
|
||||
assert.doesNotThrow(() => requireValue('NAME', 'value'))
|
||||
})
|
||||
|
||||
test('requireInteger 接受非負整數', () => {
|
||||
assert.doesNotThrow(() => requireInteger('KEEP_COUNT', '0'))
|
||||
assert.doesNotThrow(() => requireInteger('KEEP_COUNT', '10'))
|
||||
assert.doesNotThrow(() => requireInteger('KEEP_COUNT', 5))
|
||||
})
|
||||
|
||||
test('requireInteger 拒絕非整數或負數', () => {
|
||||
assert.throws(() => requireInteger('KEEP_COUNT', '-1'), /non-negative integer/)
|
||||
assert.throws(() => requireInteger('KEEP_COUNT', '1.5'), /non-negative integer/)
|
||||
assert.throws(() => requireInteger('KEEP_COUNT', 'abc'), /non-negative integer/)
|
||||
})
|
||||
Reference in New Issue
Block a user
嚴重等級:🔴 嚴重
審查員:Maya
問題:測試中雖然有 fetchAllPages 的功能測試,但對於 MAX_PAGES 的邊界情況,測試只用了 globalThis.fetch 永遠回傳非空陣列,這是一個快樂路徑的極端變體。如果 API 剛好在第 MAX_PAGES 頁回傳空陣列,測試並未驗證客戶端是否能正確處理並停止。
建議:請增加一個測試案例,模擬當 API 恰好在第 MAX_PAGES 次請求時回傳空陣列的情境,確認客戶端能成功結束,而非拋出例外。