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,234 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runScript, pathWithOnly } from './helpers/run-script.js';
|
||||
import { startStubGitea, healthyRoutes } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
|
||||
async function withStub(t, overrides = {}) {
|
||||
const stub = await startStubGitea(healthyRoutes(REPO, overrides));
|
||||
t.after(() => stub.close());
|
||||
return stub;
|
||||
}
|
||||
|
||||
const envFor = (stub) => ({ TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' });
|
||||
|
||||
// ── 第一層:執行環境 ────────────────────────────────────────────────
|
||||
|
||||
test('第一層:PATH 上沒有 git 時中止,錯誤訊息指名 git', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], {
|
||||
env: envFor(stub),
|
||||
path: pathWithOnly(['tea']),
|
||||
});
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'ENV_MISSING');
|
||||
assert.match(json.error.message, /git/);
|
||||
assert.equal(stub.requests.length, 0, '環境不通過就不該發請求');
|
||||
});
|
||||
|
||||
test('第一層:PATH 上沒有 tea 時中止,錯誤訊息指名 tea 並給出安裝指引', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], {
|
||||
env: envFor(stub),
|
||||
path: pathWithOnly(['git']),
|
||||
});
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'ENV_MISSING');
|
||||
assert.match(json.error.message, /tea/);
|
||||
});
|
||||
|
||||
// ── 第二層:Gitea 登入 ─────────────────────────────────────────────
|
||||
|
||||
test('第二層:token 無效時中止,訊息指向 tea login', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
'GET /api/v1/user': { status: 401, body: { message: 'unauthorized' } },
|
||||
});
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'LOGIN_INVALID');
|
||||
assert.match(json.error.message, /tea login/);
|
||||
});
|
||||
|
||||
test('第二層:找不到任何登入資訊時中止', async (t) => {
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], {
|
||||
env: { TEA_SDLC_API_BASE: '', TEA_SDLC_TOKEN: '', TEA_SDLC_CONFIG: '/nonexistent/tea.yml' },
|
||||
});
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'LOGIN_INVALID');
|
||||
});
|
||||
|
||||
// ── 第三層:issues unit 寫入權 ─────────────────────────────────────
|
||||
|
||||
test('第三層:repo 不存在或讀不到時,錯誤碼與權限問題分得開', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}`]: { status: 404, body: { message: 'not found' } },
|
||||
});
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'REPO_NOT_FOUND');
|
||||
});
|
||||
|
||||
test('第三層:repo 關閉議題功能時中止,訊息指出開啟位置', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}`]: {
|
||||
status: 200,
|
||||
body: {
|
||||
has_issues: false,
|
||||
permissions: { admin: false, push: true, pull: true },
|
||||
internal_tracker: { enable_time_tracker: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'ISSUES_UNIT_OFF');
|
||||
assert.match(json.error.message, /Settings/);
|
||||
});
|
||||
|
||||
test('第三層:對 issues unit 實測寫入權,被擋下時中止', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`PATCH /api/v1/repos/${REPO}/issues/0`]: { status: 403, body: { message: 'forbidden' } },
|
||||
});
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'NO_ISSUE_WRITE');
|
||||
assert.match(json.error.message, /issues/i);
|
||||
});
|
||||
|
||||
test('第三層:不能只看 permissions.push——push 為真但 issues unit 被擋,仍須失敗', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}`]: {
|
||||
status: 200,
|
||||
body: {
|
||||
has_issues: true,
|
||||
// team 的 unit 權限可獨立於 repo 的 push 權限,所以這裡是真的也不算數
|
||||
permissions: { admin: false, push: true, pull: true },
|
||||
internal_tracker: { enable_time_tracker: true },
|
||||
},
|
||||
},
|
||||
[`PATCH /api/v1/repos/${REPO}/issues/0`]: { status: 403, body: { message: 'forbidden' } },
|
||||
});
|
||||
|
||||
const { json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(json.error.code, 'NO_ISSUE_WRITE');
|
||||
});
|
||||
|
||||
test('第三層:探針打在不存在的議題 index 0,確保無副作用', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
const probes = stub.requests.filter((r) => r.method === 'PATCH');
|
||||
assert.equal(probes.length, 1);
|
||||
assert.equal(probes[0].path, `/api/v1/repos/${REPO}/issues/0`);
|
||||
assert.deepEqual(probes[0].body, {}, '探針不得挾帶任何要寫入的欄位');
|
||||
});
|
||||
|
||||
// ── 第四層:時間追蹤 ───────────────────────────────────────────────
|
||||
|
||||
test('第四層:時間追蹤未開啟時中止,訊息指出 Enable Time Tracker 的位置', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}`]: {
|
||||
status: 200,
|
||||
body: {
|
||||
has_issues: true,
|
||||
permissions: { admin: false, push: true, pull: true },
|
||||
internal_tracker: { enable_time_tracker: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'TIME_TRACKER_OFF');
|
||||
assert.match(json.error.message, /Settings → Advanced Settings → Enable Time Tracker/);
|
||||
});
|
||||
|
||||
// ── 四層之間 ───────────────────────────────────────────────────────
|
||||
|
||||
test('四層的錯誤碼兩兩相異,呼叫端分得出是哪一層壞了', async (t) => {
|
||||
const cases = [
|
||||
{
|
||||
code: 'ENV_MISSING',
|
||||
run: async () => {
|
||||
const stub = await withStub(t);
|
||||
return runScript('labels-list.js', ['--repo', REPO], {
|
||||
env: envFor(stub),
|
||||
path: pathWithOnly(['git']),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'LOGIN_INVALID',
|
||||
run: async () => {
|
||||
const stub = await withStub(t, {
|
||||
'GET /api/v1/user': { status: 401, body: {} },
|
||||
});
|
||||
return runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'NO_ISSUE_WRITE',
|
||||
run: async () => {
|
||||
const stub = await withStub(t, {
|
||||
[`PATCH /api/v1/repos/${REPO}/issues/0`]: { status: 403, body: {} },
|
||||
});
|
||||
return runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'TIME_TRACKER_OFF',
|
||||
run: async () => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}`]: {
|
||||
status: 200,
|
||||
body: {
|
||||
has_issues: true,
|
||||
permissions: { admin: true, push: true, pull: true },
|
||||
internal_tracker: { enable_time_tracker: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
return runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const seen = [];
|
||||
for (const { code, run } of cases) {
|
||||
const { json } = await run();
|
||||
assert.equal(json.error.code, code);
|
||||
seen.push(json.error.code);
|
||||
}
|
||||
assert.equal(new Set(seen).size, seen.length);
|
||||
});
|
||||
|
||||
test('前一層失敗就停手,不會再往下打', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
'GET /api/v1/user': { status: 401, body: {} },
|
||||
});
|
||||
|
||||
await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.deepEqual(
|
||||
stub.requests.map((r) => r.path),
|
||||
['/api/v1/user'],
|
||||
'登入檢查沒過就不該再查 repo 或標籤',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user