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:
2026-09-17 04:26:51 +00:00
co-authored by Claude Opus 5
parent 48fd69cfde
commit 28dec83498
8 changed files with 937 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { runScript } from './helpers/run-script.js';
import { startStubGitea, healthyRoutes } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc';
/** 讓每個測試都拿到一台乾淨的假 Gitea,並在結束後關掉 */
async function withStub(t, overrides = {}) {
const stub = await startStubGitea(healthyRoutes(REPO, overrides));
t.after(() => stub.close());
return stub;
}
function envFor(stub) {
return { TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' };
}
test('列出目標 repo 的既有標籤,輸出單行 JSON 且 exit 0', async (t) => {
const stub = await withStub(t);
const { code, json, stdout } = await runScript('labels-list.js', ['--repo', REPO], {
env: envFor(stub),
});
assert.equal(code, 0);
assert.equal(json.ok, true);
assert.deepEqual(
json.data.labels.map((l) => l.name),
['ready-for-agent', '進行中'],
);
assert.equal(stdout.trimEnd().includes('\n'), false, '輸出必須是單行');
});
test('輸出帶上 repo 與標籤數,供下游不必再數一次', async (t) => {
const stub = await withStub(t);
const { json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.equal(json.data.repo, REPO);
assert.equal(json.data.count, 2);
});
test('帶上 Authorization,且只對 Gitea 發出讀取請求', async (t) => {
const stub = await withStub(t);
await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
const labelCalls = stub.requests.filter((r) => r.path.endsWith('/labels'));
assert.equal(labelCalls.length, 1);
assert.equal(labelCalls[0].method, 'GET');
assert.equal(labelCalls[0].authorization, 'token stub-token');
});
test('不具備建立標籤的能力:沒有任何請求會寫到 labels 端點', async (t) => {
const stub = await withStub(t);
await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
const writesToLabels = stub.requests.filter(
(r) => r.path.endsWith('/labels') && r.method !== 'GET',
);
assert.deepEqual(writesToLabels, []);
});
test('不具備建立標籤的能力:看起來像要建標籤的 flag 一律被拒', async (t) => {
const stub = await withStub(t);
const { code, json } = await runScript(
'labels-list.js',
['--repo', REPO, '--create', 'needs-triage'],
{ env: envFor(stub) },
);
assert.equal(code, 1);
assert.equal(json.ok, false);
assert.equal(json.error.code, 'UNKNOWN_FLAG');
assert.equal(stub.requests.length, 0, '參數錯誤時不該碰 Gitea');
});
test('空標籤清單不是錯誤', async (t) => {
const stub = await withStub(t, { [`GET /api/v1/repos/${REPO}/labels`]: { status: 200, body: [] } });
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.equal(code, 0);
assert.deepEqual(json.data.labels, []);
assert.equal(json.data.count, 0);
});
test('Gitea 回非預期狀態碼時,帶著狀態碼失敗而不是硬吞', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/labels`]: { status: 500, body: { message: 'boom' } },
});
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.equal(code, 1);
assert.equal(json.error.code, 'HTTP_ERROR');
assert.match(json.error.message, /500/);
});