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
+124
View File
@@ -0,0 +1,124 @@
/**
* 認證來源:預設沿用使用者既有的 `tea login`,不另外要求設定一組 token。
* TEA_SDLC_API_BASE / TEA_SDLC_TOKEN 只是給測試與 CI 的覆寫出口。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { runScript, tmpRoot } from './helpers/run-script.js';
import { startStubGitea, healthyRoutes } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc';
/** 寫一份 tea 風格的 config.yml 到暫存目錄,回傳路徑 */
function writeTeaConfig(logins) {
mkdirSync(tmpRoot, { recursive: true });
const dir = mkdtempSync(join(tmpRoot, 'tea-'));
const path = join(dir, 'config.yml');
const body = logins
.map((l) =>
[
` - name: ${l.name}`,
` url: ${l.url}`,
` token: ${l.token}`,
` default: ${l.default ?? false}`,
` ssh_key: ""`,
` user: ${l.user ?? 'tester'}`,
].join('\n'),
)
.join('\n');
writeFileSync(path, `logins:\n${body}\npreferences:\n editor: false\n`);
return path;
}
async function withStub(t) {
const stub = await startStubGitea(healthyRoutes(REPO));
t.after(() => stub.close());
return stub;
}
/** stub 的 base 是 .../api/v1,tea config 記的是站台根網址 */
const siteUrl = (stub) => stub.base.replace(/\/api\/v1$/, '');
test('沒有環境變數覆寫時,採用 tea 設定中 default 為真的登入', async (t) => {
const stub = await withStub(t);
const config = writeTeaConfig([
{ name: 'OTHER', url: 'https://gitea.example.com', token: 'wrong-token' },
{ name: 'JSC', url: siteUrl(stub), token: 'config-token', default: true },
]);
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], {
env: { TEA_SDLC_CONFIG: config },
});
assert.equal(code, 0);
assert.equal(json.ok, true);
assert.equal(stub.requests[0].authorization, 'token config-token');
});
test('--host 指定站台時,挑出對應的那一組登入', async (t) => {
const stub = await withStub(t);
const config = writeTeaConfig([
{ name: 'JSC', url: siteUrl(stub), token: 'jsc-token' },
{ name: 'HP', url: 'https://gitea.example.com', token: 'hp-token', default: true },
]);
const { code, json } = await runScript(
'labels-list.js',
['--repo', REPO, '--host', siteUrl(stub)],
{ env: { TEA_SDLC_CONFIG: config } },
);
assert.equal(code, 0, JSON.stringify(json));
assert.equal(stub.requests[0].authorization, 'token jsc-token');
});
test('--host 指定的站台沒有對應登入時,明確說是哪個站台缺登入', async (t) => {
const config = writeTeaConfig([{ name: 'JSC', url: 'https://gitea.example.com', token: 't', default: true }]);
const { code, json } = await runScript(
'labels-list.js',
['--repo', REPO, '--host', 'https://gitea.missing.tw'],
{ env: { TEA_SDLC_CONFIG: config } },
);
assert.equal(code, 1);
assert.equal(json.error.code, 'LOGIN_INVALID');
assert.match(json.error.message, /gitea\.missing\.tw/);
});
test('tea 設定檔不存在時,訊息指向 tea login add', async (t) => {
const { json } = await runScript('labels-list.js', ['--repo', REPO], {
env: { TEA_SDLC_CONFIG: join(tmpRoot, 'definitely-not-here', 'config.yml') },
});
assert.equal(json.error.code, 'LOGIN_INVALID');
assert.match(json.error.message, /tea login add/);
});
test('設定檔存在但沒有任何登入時,同樣指向 tea login add', async (t) => {
mkdirSync(tmpRoot, { recursive: true });
const dir = mkdtempSync(join(tmpRoot, 'tea-'));
const path = join(dir, 'config.yml');
writeFileSync(path, 'logins: []\npreferences:\n editor: false\n');
const { json } = await runScript('labels-list.js', ['--repo', REPO], {
env: { TEA_SDLC_CONFIG: path },
});
assert.equal(json.error.code, 'LOGIN_INVALID');
assert.match(json.error.message, /tea login add/);
});
test('只有一組登入且未標 default 時,就用那一組', async (t) => {
const stub = await withStub(t);
const config = writeTeaConfig([{ name: 'ONLY', url: siteUrl(stub), token: 'only-token' }]);
const { code } = await runScript('labels-list.js', ['--repo', REPO], {
env: { TEA_SDLC_CONFIG: config },
});
assert.equal(code, 0);
assert.equal(stub.requests[0].authorization, 'token only-token');
});