test(腳本契約): 以子行程與 stub server 驗證契約與四層前置檢查
測試一律以子行程執行腳本、比對 stdout 的單行 JSON 與 exit code,因為那正是 七個平台共用的實際呼叫方式,不會因內部重構而破碎。 Gitea 以本機 stub server 替身並錄下每一筆請求,藉此斷言「腳本到底發了哪些 請求」——包含 --dry-run 不得發出任何請求、前一層檢查沒過就不再往下打、 寫入權探針不得挾帶任何要寫入的欄位。git 則在 .tmp/ 下的臨時 repo 跑真實 指令,比 mock 可信且成本低。 lib-exports.test.js 是唯一直接 import lib 的例外:冪等查重與 git 執行點在 CLI 邊界上還沒有消費者,檔頭已註明等 #4 與 #10 落地後即可縮小或移除。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 假的 Gitea:測試時用 TEA_SDLC_API_BASE 指向它,腳本便不會碰到真的伺服器。
|
||||
* 路由以 "METHOD /path" 為鍵,值可以是 {status, body} 或一個接 (req) 的函式。
|
||||
* 每一筆進來的請求都會被記錄,讓測試可以斷言「腳本到底發了哪些請求」。
|
||||
*/
|
||||
import { createServer } from 'node:http';
|
||||
|
||||
/**
|
||||
* 啟動一台假 Gitea。
|
||||
* @param {Record<string, object|Function>} 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<string, object|Function>} 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user