From 9b9ab33a53a6439f7a10c54e7cdddf3ad0c703f6 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 04:44:47 +0000 Subject: [PATCH] =?UTF-8?q?test(issue-create):=20=E8=A6=86=E8=93=8B?= =?UTF-8?q?=E5=BB=BA=E7=AB=8B=E8=AD=B0=E9=A1=8C=E8=88=87=E8=A9=A6=E8=B7=91?= =?UTF-8?q?=E9=A0=90=E8=A6=BD=E7=9A=84=E5=A5=91=E7=B4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 沿用既有接縫:子行程執行、stub server 錄下每一筆請求。涵蓋標籤名稱解析、 未知標籤中止、不碰 labels 的寫入端點、同標題不重建、前後空白視為同一顆, 以及寫入型腳本一樣跑滿前置檢查。 試跑的部分特別驗「預覽要忠實」:預覽的 body 必須看得出標籤會被貼上、標籤 錯字在試跑就該擋下、同名議題已存在時預覽不得預告要建立議題,且全程不發出 任何寫入請求。 Co-Authored-By: Claude Opus 5 (1M context) --- test/issue-create.test.js | 329 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 test/issue-create.test.js diff --git a/test/issue-create.test.js b/test/issue-create.test.js new file mode 100644 index 0000000..2a868a5 --- /dev/null +++ b/test/issue-create.test.js @@ -0,0 +1,329 @@ +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 { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; + +const REPO = 'plugins/tea-sdlc'; +const TITLE = '把口語需求轉成結構化需求議題'; + +/** 建議題要帶 body,寫成檔案傳進去,避免長 markdown 擠在命令列上 */ +function writeBody(text = '## 總覽\n\n一句話說明這件事在做什麼。\n') { + mkdirSync(tmpRoot, { recursive: true }); + const dir = mkdtempSync(join(tmpRoot, 'body-')); + const path = join(dir, 'requirement.md'); + writeFileSync(path, text); + return path; +} + +/** 預設情境:repo 裡已經有兩個標籤,且沒有任何同名議題 */ +function defaultRoutes(overrides = {}) { + return healthyRoutes(REPO, { + [`GET /api/v1/repos/${REPO}/issues`]: { status: 200, body: [] }, + [`POST /api/v1/repos/${REPO}/issues`]: (req) => ({ + status: 201, + body: { + number: 42, + title: req.body.title, + html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/42`, + labels: (req.body.labels ?? []).map((id) => ({ id, name: `label-${id}` })), + }, + }), + ...overrides, + }); +} + +async function withStub(t, overrides = {}) { + return withStubGitea(t, defaultRoutes(overrides)); +} + +// ── 建立議題 ─────────────────────────────────────────────────────── + +test('建立議題並回傳編號與網址', async (t) => { + const stub = await withStub(t); + + const { code, json } = await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody()], + { env: envFor(stub) }, + ); + + assert.equal(code, 0); + assert.equal(json.ok, true); + assert.equal(json.data.number, 42); + assert.equal(json.data.title, TITLE); + assert.equal(json.data.url, `https://gitea.jsc.idv.tw/${REPO}/issues/42`); + assert.equal(json.data.created, true); +}); + +test('body 由檔案讀入,原樣送出不做加工', async (t) => { + const stub = await withStub(t); + const body = '## 總覽\n\n多行的\n\n內容。\n'; + + await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody(body)], + { env: envFor(stub) }, + ); + + const post = stub.requests.find((r) => r.method === 'POST'); + assert.equal(post.body.body, body); + assert.equal(post.body.title, TITLE); +}); + +test('body 檔案不存在時,帶著路徑失敗且不碰 Gitea', async (t) => { + const stub = await withStub(t); + + const { code, json } = await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', '/nonexistent/body.md'], + { env: envFor(stub) }, + ); + + assert.equal(code, 1); + assert.equal(json.error.code, 'BODY_FILE_MISSING'); + assert.match(json.error.message, /\/nonexistent\/body\.md/); + assert.equal(stub.requests.length, 0); +}); + +test('缺少必填 flag 時失敗', async (t) => { + const stub = await withStub(t); + + const { json } = await runScript('issue-create.js', ['--repo', REPO], { env: envFor(stub) }); + + assert.equal(json.error.code, 'MISSING_FLAG'); +}); + +// ── 標籤:只能挑既有的 ───────────────────────────────────────────── + +test('以名稱指定標籤,送出時換成既有標籤的 id', async (t) => { + const stub = await withStub(t); + + const { json } = await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--labels', 'ready-for-agent'], + { env: envFor(stub) }, + ); + + assert.equal(json.ok, true, JSON.stringify(json)); + const post = stub.requests.find((r) => r.method === 'POST'); + assert.deepEqual(post.body.labels, [55]); +}); + +test('多個標籤以逗號分隔', async (t) => { + const stub = await withStub(t); + + await runScript( + 'issue-create.js', + [ + '--repo', REPO, '--title', TITLE, '--body-file', writeBody(), + '--labels', 'ready-for-agent,進行中', + ], + { env: envFor(stub) }, + ); + + const post = stub.requests.find((r) => r.method === 'POST'); + assert.deepEqual(post.body.labels, [55, 56]); +}); + +test('指定不存在的標籤時中止,並列出可選的標籤', async (t) => { + const stub = await withStub(t); + + const { code, json } = await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--labels', 'needs-triage'], + { env: envFor(stub) }, + ); + + assert.equal(code, 1); + assert.equal(json.error.code, 'UNKNOWN_LABEL'); + assert.match(json.error.message, /needs-triage/); + assert.match(json.error.message, /ready-for-agent/, '訊息要列出可選的標籤'); +}); + +test('不建立標籤:沒有任何請求寫到 labels 端點', async (t) => { + const stub = await withStub(t); + + await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--labels', 'ready-for-agent'], + { env: envFor(stub) }, + ); + + const labelWrites = stub.requests.filter( + (r) => r.path.endsWith('/labels') && r.method !== 'GET', + ); + assert.deepEqual(labelWrites, []); +}); + +test('未指定標籤時不查標籤,也不在請求裡帶 labels', async (t) => { + const stub = await withStub(t); + + await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody()], + { env: envFor(stub) }, + ); + + assert.equal(stub.requests.some((r) => r.path.endsWith('/labels')), false); + const post = stub.requests.find((r) => r.method === 'POST'); + assert.equal('labels' in post.body, false); +}); + +// ── 冪等查重 ─────────────────────────────────────────────────────── + +test('同標題議題已存在時不重建,回傳既有那一顆', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/issues`]: { + status: 200, + body: [{ number: 9, title: TITLE, html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/9` }], + }, + }); + + const { code, json } = await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody()], + { env: envFor(stub) }, + ); + + assert.equal(code, 0); + assert.equal(json.data.created, false); + assert.equal(json.data.number, 9); + assert.equal(stub.requests.some((r) => r.method === 'POST'), false, '不得重建議題'); +}); + +test('標題只差前後空白仍視為同一顆,不重建', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/issues`]: { + status: 200, + body: [{ number: 9, title: ` ${TITLE} `, html_url: 'https://example.com/9' }], + }, + }); + + const { json } = await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody()], + { env: envFor(stub) }, + ); + + assert.equal(json.data.created, false); + assert.equal(json.data.number, 9); +}); + +test('查重看的是 open 與 closed 兩種狀態', async (t) => { + const stub = await withStub(t); + + await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody()], + { env: envFor(stub) }, + ); + + const lookup = stub.requests.find((r) => r.method === 'GET' && r.path.endsWith('/issues')); + assert.equal(lookup.query.state, 'all'); +}); + +// ── --dry-run ───────────────────────────────────────────────────── + +test('--dry-run 印出將發出的請求,但一個字都不寫進 Gitea', async (t) => { + const stub = await withStub(t); + + const { code, json } = await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--dry-run'], + { env: envFor(stub) }, + ); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.deepEqual( + json.data.requests.map((r) => `${r.method} ${r.path}`), + [`POST /repos/${REPO}/issues`], + ); + assert.equal( + stub.requests.some((r) => r.method !== 'GET'), + false, + '試跑可以讀,但不得發出任何寫入請求', + ); +}); + +test('--dry-run 的預覽忠實反映將送出的 body,標籤已換成 id', async (t) => { + const stub = await withStub(t); + const body = '## 總覽\n\n要送出去的內容。\n'; + + const { json } = await runScript( + 'issue-create.js', + [ + '--repo', REPO, '--title', TITLE, '--body-file', writeBody(body), + '--labels', 'ready-for-agent', '--dry-run', + ], + { env: envFor(stub) }, + ); + + const planned = json.data.requests[0].body; + assert.equal(planned.title, TITLE); + assert.equal(planned.body, body); + assert.deepEqual(planned.labels, [55], '預覽要看得出標籤會被貼上,而不是整個消失'); + assert.deepEqual(json.data.labels, ['ready-for-agent']); +}); + +test('--dry-run 就抓得到標籤錯字,不必等到實跑', async (t) => { + const stub = await withStub(t); + + const { code, json } = await runScript( + 'issue-create.js', + [ + '--repo', REPO, '--title', TITLE, '--body-file', writeBody(), + '--labels', 'needs-triage', '--dry-run', + ], + { env: envFor(stub) }, + ); + + assert.equal(code, 1); + assert.equal(json.error.code, 'UNKNOWN_LABEL'); +}); + +test('--dry-run 遇到同名議題時,如實顯示實跑會是 no-op', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/issues`]: { + status: 200, + body: [{ number: 9, title: TITLE, html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/9` }], + }, + }); + + const { code, json } = await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--dry-run'], + { env: envFor(stub) }, + ); + + assert.equal(code, 0); + assert.deepEqual(json.data.requests, [], '已存在就不該預告要建立議題'); + assert.equal(json.data.existing.number, 9); +}); + +// ── 前置檢查仍然生效 ─────────────────────────────────────────────── + +test('寫入型腳本一樣跑前置檢查:時間追蹤沒開就中止', async (t) => { + 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 }, + }, + }, + }); + + const { code, json } = await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody()], + { env: envFor(stub) }, + ); + + assert.equal(code, 1); + assert.equal(json.error.code, 'TIME_TRACKER_OFF'); + assert.equal(stub.requests.some((r) => r.method === 'POST'), false); +});