/** * 假的 Gitea:測試時用 TEA_SDLC_API_BASE 指向它,腳本便不會碰到真的伺服器。 * 路由以 "METHOD /path" 為鍵,值可以是 {status, body} 或一個接 (req) 的函式。 * 每一筆進來的請求都會被記錄,讓測試可以斷言「腳本到底發了哪些請求」。 */ import { createServer } from 'node:http'; /** * 啟動一台假 Gitea。 * @param {Record} routes 路由表,鍵為 "METHOD /path" * @returns {Promise<{base: string, requests: object[], close: Function}>} */ export async function startStubGitea(routes = {}) { /** 收到的請求,依序記錄;測試靠它斷言請求次數與內容 */ const requests = []; const server = createServer((req, res) => { const chunks = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8'); const url = new URL(req.url, 'http://127.0.0.1'); const key = `${req.method} ${url.pathname}`; requests.push({ method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams), authorization: req.headers.authorization ?? null, body: raw ? safeParse(raw) : null, }); const route = routes[key]; if (route === undefined) { res.writeHead(404, { 'content-type': 'application/json' }); res.end(JSON.stringify({ message: `stub has no route for ${key}` })); return; } const { status = 200, body = null } = typeof route === 'function' ? route(requests.at(-1)) : route; res.writeHead(status, { 'content-type': 'application/json' }); res.end(body === null ? '' : JSON.stringify(body)); }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address(); return { base: `http://127.0.0.1:${port}/api/v1`, requests, close: () => new Promise((resolve) => server.close(resolve)), }; } /** * 一台「什麼都正常」的 Gitea 的路由表,測試再以 overrides 局部覆寫成想要的壞掉樣子。 * @param {string} repo 目標 repo,格式 owner/name * @param {Record} overrides 要覆寫的路由 */ export function healthyRoutes(repo, overrides = {}) { return { 'GET /api/v1/user': { status: 200, body: { login: 'tester' } }, [`GET /api/v1/repos/${repo}`]: { status: 200, body: { full_name: repo, has_issues: true, permissions: { admin: false, push: true, pull: true }, internal_tracker: { enable_time_tracker: true }, }, }, // 寫入權探針:有 issues 寫入權時,Gitea 會通過權限中介層後才發現議題不存在 [`PATCH /api/v1/repos/${repo}/issues/0`]: { status: 404, body: { message: 'issue does not exist' } }, [`GET /api/v1/repos/${repo}/labels`]: { status: 200, body: [ { id: 55, name: 'ready-for-agent', color: '0e8a16', description: '可交給 agent 處理' }, { id: 56, name: '進行中', color: 'fbca04', description: '' }, ], }, ...overrides, }; } function safeParse(raw) { try { return JSON.parse(raw); } catch { return raw; } } /** * 啟動假 Gitea 並登記在測試結束時關掉,省去每支測試各寫一次 close。 * @param {object} t node:test 的 TestContext * @param {Record} routes */ export async function withStubGitea(t, routes) { const stub = await startStubGitea(routes); t.after(() => stub.close()); return stub; } /** 把腳本指向這台假 Gitea 的環境變數 */ export const stubEnv = (stub) => ({ TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' }); /** * 找出腳本真正發出的那一個 PATCH。 * 前置檢查對 `issues/0` 的探針也是 PATCH,但它打在一顆不存在的議題上、不改動任何東西, * 不該被當成腳本的寫入(見 lib.js 的 checkIssueWrite)。 * @returns {object|undefined} 沒發出寫入時為 undefined */ export function patchOf(stub) { return stub.requests.find((r) => r.method === 'PATCH' && !r.path.endsWith('/issues/0')); }