新增 fetchAllPages 在 fetch 拋一般網路錯誤時向外拋出的測試,以及 selectReleasesToDelete 對空陣列回傳空陣列的測試。測試共 67 項全數通過。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
176 lines
5.2 KiB
JavaScript
176 lines
5.2 KiB
JavaScript
import { test, afterEach } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { GiteaClient } from '../gitea-client.js'
|
|
|
|
const realFetch = globalThis.fetch
|
|
|
|
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')
|
|
})
|
|
|
|
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 jsonResponse(pages[page])
|
|
}
|
|
|
|
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 帶上逾時 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,
|
|
text: async () => 'boom',
|
|
})
|
|
const client = new GiteaClient({})
|
|
await assert.rejects(
|
|
() => client.fetchAllPages('https://example.com/api'),
|
|
/HTTP 500/,
|
|
)
|
|
})
|
|
|
|
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('fetchAllPages 在回應為非陣列 JSON 時丟出例外', async () => {
|
|
globalThis.fetch = async () => jsonResponse({ message: 'error object' })
|
|
const client = new GiteaClient({})
|
|
await assert.rejects(
|
|
() => client.fetchAllPages('https://example.com/api'),
|
|
/non-array JSON payload/,
|
|
)
|
|
})
|
|
|
|
test('fetchAllPages 在 fetch 因逾時拋出 AbortError 時向外拋出', async () => {
|
|
globalThis.fetch = async () => {
|
|
const err = new Error('The operation was aborted due to timeout')
|
|
err.name = 'AbortError'
|
|
throw err
|
|
}
|
|
const client = new GiteaClient({})
|
|
await assert.rejects(
|
|
() => client.fetchAllPages('https://example.com/api'),
|
|
(err) => err.name === 'AbortError',
|
|
)
|
|
})
|
|
|
|
test('fetchAllPages 在 fetch 拋出一般網路錯誤時向外拋出', async () => {
|
|
globalThis.fetch = async () => {
|
|
throw new TypeError('fetch failed: ECONNREFUSED')
|
|
}
|
|
const client = new GiteaClient({})
|
|
await assert.rejects(
|
|
() => client.fetchAllPages('https://example.com/api'),
|
|
/fetch failed|ECONNREFUSED/,
|
|
)
|
|
})
|
|
|
|
test('fetchAllPages 在頁數超過 MAX_PAGES 時中止以避免無限迴圈', async () => {
|
|
// 永遠回傳非空陣列,模擬 API 不以空陣列結尾的異常情形
|
|
globalThis.fetch = async () => jsonResponse([{ id: 1 }])
|
|
const client = new GiteaClient({})
|
|
await assert.rejects(
|
|
() => client.fetchAllPages('https://example.com/api'),
|
|
/exceeded MAX_PAGES/,
|
|
)
|
|
})
|
|
|
|
test('fetchAllPages 的錯誤訊息會清理控制字元並限制長度(sanitizeBody)', async () => {
|
|
const nasty = 'line1\nline2\r\tinjected' + 'x'.repeat(500)
|
|
globalThis.fetch = async () => ({
|
|
ok: false,
|
|
status: 500,
|
|
text: async () => nasty,
|
|
})
|
|
const client = new GiteaClient({})
|
|
let message = ''
|
|
try {
|
|
await client.fetchAllPages('https://example.com/api')
|
|
} catch (error) {
|
|
message = error.message
|
|
}
|
|
// 控制字元(換行、Tab 等)已被移除
|
|
assert.doesNotMatch(message, /[\u0000-\u001F]/)
|
|
// 回應內容片段受 200 字元上限約束(原始有 500 個 x)
|
|
assert.ok((message.match(/x/g) || []).length <= 200)
|
|
})
|
|
|
|
test('fetchAllPages 在第 MAX_PAGES 頁回傳空陣列時正常結束(不拋例外)', async () => {
|
|
let page = 0
|
|
globalThis.fetch = async () => {
|
|
page += 1
|
|
// 前 999 頁有資料,第 1000 頁(MAX_PAGES)回空陣列,應正常結束
|
|
return jsonResponse(page < 1000 ? [{ id: page }] : [])
|
|
}
|
|
const client = new GiteaClient({})
|
|
const items = await client.fetchAllPages('https://example.com/api')
|
|
assert.equal(items.length, 999)
|
|
})
|
|
|
|
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)
|
|
})
|