import { test } from 'node:test' import assert from 'node:assert/strict' import { selectReleasesToDelete, cleanupReleases } 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 對空陣列回傳空陣列', () => { assert.deepEqual(selectReleasesToDelete([], 1), []) assert.deepEqual(selectReleasesToDelete([], 0), []) }) 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 在 created_at 相同時以 id 決定順序(具決定性)', () => { const same = '2024-01-01T00:00:00Z' const list = [ { id: 1, created_at: same }, { id: 3, created_at: same }, { id: 2, created_at: same }, ] const toDelete = selectReleasesToDelete(list, 1) // 保留 id 最大者(3),刪除 id 2 與 1(順序固定) assert.deepEqual( toDelete.map((r) => r.id), [2, 1], ) }) test('selectReleasesToDelete 不改動原陣列', () => { const original = [...releases] 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,不發出刪除請求', async () => { const client = fakeClient([ { id: 1, tag_name: 'v2', name: 'n2', created_at: '2024-02-01T00:00:00Z' }, { id: '../evil', tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' }, ]) await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 }) // 保留最新(id=1);待刪除者 id 非整數 → 被略過,不會送出任何刪除 assert.deepEqual(client.deleted, []) }) test('cleanupReleases 在刪除回傳非 204 時記錄錯誤至 stderr', 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' }, ], () => 500, ) const realWrite = process.stderr.write const chunks = [] process.stderr.write = (chunk) => { chunks.push(String(chunk)) return true } try { await cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 }) } finally { process.stderr.write = realWrite } assert.match(chunks.join(''), /\[ERR\]/) }) test('cleanupReleases 對含 null/異常結構的 release 不拋例外且照常處理正常項目', async () => { const client = fakeClient([ { id: 3, tag_name: 'v3', name: 'n3', created_at: '2024-03-01T00:00:00Z' }, null, { created_at: '2024-02-01T00:00:00Z' }, // 缺 id/tag_name { id: 1, tag_name: 'v1', name: 'n1', created_at: '2024-01-01T00:00:00Z' }, ]) await assert.doesNotReject(() => cleanupReleases(client, { releaseApiUrl: 'http://x/releases', keepCount: 1 }), ) // 保留最新(id=3);待刪除者中僅 id=1 為有效正整數 id,其餘(null、缺 id)被略過 assert.deepEqual(client.deleted, ['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']) })