Files
teai/internal/cli/dailyops_commands_test.go
alex 7badd9e46d internal/dailyops + cli:日常操作命令 issues/pulls/labels/milestones/releases/repos/api(#8)
- internal/gitea:用戶端支援寫入動詞(DoJSON/DoRaw,共用 do 核心)
- internal/dailyops:七組日常操作的 API 層(issues/pulls/labels/milestones/releases/repos/api)
- internal/cli:子命令接線;寫入類一律 --yes 閘門(未確認不發請求、exit 2)
- teai pulls 相容 #6 工作流用法(無子命令);有子命令走日常操作
- 修正 dispatch:命令旗標(如 --has-work、--comments)不再被誤判為未知全域選項
- teai api 支援 path 內含查詢字串
- 單元測試(httptest 假伺服器)+真實站點交叉驗證(讀取命令輸出對照 Gitea API;寫入回路 create→comment→close 於 alex/.profile 驗證後清理)
2026-09-10 09:19:03 +08:00

290 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// dailyops_commands_test.go — #8 的驗收測試:以 httptest 假伺服器驗證
// issues/pulls/labels/milestones/releases/repos/api 的讀取行為,與
// 寫入命令的 --yes 閘門(未帶 --yes 不得發出任何請求)。
package cli
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// opsTestServer 建立假 Gitea API 伺服器與對應的執行輔助。
func opsTestServer(t *testing.T, handler http.HandlerFunc) func(args ...string) (string, string, int) {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return func(args ...string) (string, string, int) {
full := append([]string{"--url", srv.URL}, args...)
return runCLI(full...) // 套件級輔助(見下)
}
}
// runCLI 是 cli_test.go `run` 的別名,避免與各測試內的區域變數 run 混淆。
func runCLI(args ...string) (string, string, int) {
return run(args...)
}
// TestIssuesListReadsOpenIssuesOnly 驗證 issues list 帶 state=open 與
// type=issues(排除 PR),輸出 JSON 陣列。
func TestIssuesListReadsOpenIssuesOnly(t *testing.T) {
var gotQuery string
run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
fmt.Fprint(w, `[{"number":8,"title":"日常操作","state":"open","user":{"login":"ceo"},"assignees":[{"login":"alex"}],"updated_at":"2026-09-10T08:13:00Z"}]`)
})
stdout, _, code := run("issues", "list", "--repo", "alterminal/teai")
if code != 0 {
t.Fatalf("issues list 應回 0,得到 %d", code)
}
if !strings.Contains(gotQuery, "state=open") || !strings.Contains(gotQuery, "type=issues") {
t.Errorf("查詢參數應含 state=open 與 type=issues,得到 %q", gotQuery)
}
if !strings.Contains(stdout, `"number":8`) || !strings.Contains(stdout, `"author":"ceo"`) {
t.Errorf("輸出應含 number 與 author 欄位,得到:%s", stdout)
}
}
// TestIssuesListRequiresRepo 驗證 --repo 必填。
func TestIssuesListRequiresRepo(t *testing.T) {
run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("不應發出任何請求")
})
_, stderr, code := run("issues", "list")
if code != int(ExitUsage) {
t.Errorf("缺 --repo 應回 ExitUsage,得到 %d", code)
}
if !strings.Contains(stderr, "--repo") {
t.Errorf("錯誤訊息應提及 --repo,得到:%s", stderr)
}
}
// TestIssuesViewWithComments 驗證 view --comments 連留言輸出。
func TestIssuesViewWithComments(t *testing.T) {
var paths []string
run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
switch {
case strings.HasSuffix(r.URL.Path, "/issues/8"):
fmt.Fprint(w, `{"number":8,"title":"日常操作","state":"open","user":{"login":"ceo"},"body":"內文","html_url":"https://x/8","labels":[],"updated_at":"t"}`)
case strings.HasSuffix(r.URL.Path, "/comments"):
fmt.Fprint(w, `[{"body":"開始處理","created_at":"t1","user":{"login":"alex"}}]`)
default:
fmt.Fprint(w, `[]`)
}
})
stdout, _, code := run("issues", "view", "--repo", "alterminal/teai", "--comments", "8")
if code != 0 {
t.Fatalf("view --comments 應回 0,得到 %d", code)
}
if len(paths) != 2 || !strings.HasSuffix(paths[1], "/issues/8/comments") {
t.Errorf("應依序呼叫 issue 與 comments 端點,得到 %v", paths)
}
if !strings.Contains(stdout, `"body":"開始處理"`) {
t.Errorf("輸出應含留言內容,得到:%s", stdout)
}
}
// TestWriteCommandsRequireYes 驗證所有寫入命令未帶 --yes 時:
// 回 ExitUsage 且不發出任何 HTTP 請求(issue 驗收條件)。
func TestWriteCommandsRequireYes(t *testing.T) {
cases := []struct {
name string
args []string
}{
{"issues create", []string{"issues", "create", "--repo", "a/b", "--title", "t"}},
{"issues close", []string{"issues", "close", "--repo", "a/b", "1"}},
{"issues comment", []string{"issues", "comment", "--repo", "a/b", "--body", "x", "1"}},
{"pulls create", []string{"pulls", "create", "--repo", "a/b", "--head", "h", "--title", "t"}},
{"pulls merge", []string{"pulls", "merge", "--repo", "a/b", "1"}},
{"pulls comment", []string{"pulls", "comment", "--repo", "a/b", "--body", "x", "1"}},
{"labels create", []string{"labels", "create", "--repo", "a/b", "--name", "n"}},
{"milestones create", []string{"milestones", "create", "--repo", "a/b", "--title", "t"}},
{"api POST", []string{"api", "--method", "POST", "/repos/a/b/issues"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("%s:未帶 --yes 不得發出請求(%s %s)", tc.name, r.Method, r.URL.Path)
})
_, stderr, code := run(tc.args...)
if code != int(ExitUsage) {
t.Errorf("%s 未帶 --yes 應回 ExitUsage(2),得到 %d", tc.name, code)
}
if !strings.Contains(stderr, "--yes") {
t.Errorf("%s 錯誤訊息應提及 --yes,得到:%s", tc.name, stderr)
}
})
}
}
// TestIssuesCreateWithYes 驗證帶 --yes 時 POST 正確 payload。
func TestIssuesCreateWithYes(t *testing.T) {
var gotMethod, gotPath, gotBody string
run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) {
gotMethod, gotPath = r.Method, r.URL.Path
buf := make([]byte, 512)
n, _ := r.Body.Read(buf)
gotBody = string(buf[:n])
fmt.Fprint(w, `{"number":15,"title":"新 issue","html_url":"https://x/15"}`)
})
stdout, _, code := run("issues", "create", "--repo", "alterminal/teai", "--title", "新 issue", "--body", "說明", "--assignees", "alex,ceo", "--yes")
if code != 0 {
t.Fatalf("create --yes 應回 0,得到 %d", code)
}
if gotMethod != "POST" || !strings.HasSuffix(gotPath, "/repos/alterminal/teai/issues") {
t.Errorf("應 POST /repos/alterminal/teai/issues,得到 %s %s", gotMethod, gotPath)
}
if !strings.Contains(gotBody, `"assignees":["alex","ceo"]`) {
t.Errorf("body 應含 assignees,得到:%s", gotBody)
}
if !strings.Contains(stdout, `"number":15`) {
t.Errorf("輸出應含新 issue 編號,得到:%s", stdout)
}
}
// TestPullsListAndSubcommands 驗證 pulls list 與 pulls(無子命令)可並存。
func TestPullsListAndSubcommands(t *testing.T) {
var gotPath string
run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
fmt.Fprint(w, `[{"number":13,"title":"PR","state":"open","user":{"login":"alex"},"head":{"ref":"feat"},"base":{"ref":"main"},"updated_at":"t"}]`)
})
stdout, _, code := run("pulls", "list", "--repo", "alterminal/teai")
if code != 0 {
t.Fatalf("pulls list 應回 0,得到 %d", code)
}
if !strings.HasSuffix(gotPath, "/repos/alterminal/teai/pulls") {
t.Errorf("應呼叫 /repos/alterminal/teai/pulls,得到 %s", gotPath)
}
if !strings.Contains(stdout, `"head":"feat"`) || !strings.Contains(stdout, `"base":"main"`) {
t.Errorf("輸出應含 head/base 欄位,得到:%s", stdout)
}
}
// TestLabelsMilestonesReleasesList 驗證三個清單命令打對端點並輸出。
func TestLabelsMilestonesReleasesList(t *testing.T) {
cases := []struct {
args []string
path string
resp string
wantOut string
}{
{
args: []string{"labels", "list", "--repo", "a/b"},
path: "/api/v1/repos/a/b/labels",
resp: `[{"name":"enhancement","color":"#84b6eb","description":""}]`,
wantOut: `"name":"enhancement"`,
},
{
args: []string{"milestones", "list", "--repo", "a/b"},
path: "/api/v1/repos/a/b/milestones",
resp: `[{"title":"v1","state":"open","description":"","due_on":""}]`,
wantOut: `"title":"v1"`,
},
{
args: []string{"releases", "list", "--repo", "a/b"},
path: "/api/v1/repos/a/b/releases",
resp: `[{"tag_name":"v1.0.0","name":"首個發佈","draft":false,"prerelease":false,"published_at":"t"}]`,
wantOut: `"tag_name":"v1.0.0"`,
},
{
args: []string{"repos", "list", "--org", "alterminal"},
path: "/api/v1/orgs/alterminal/repos",
resp: `[{"full_name":"alterminal/teai","private":false,"empty":false,"updated_at":"t","html_url":"u"}]`,
wantOut: `"full_name":"alterminal/teai"`,
},
}
for _, tc := range cases {
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
var gotPath string
run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
fmt.Fprint(w, tc.resp)
})
stdout, _, code := run(tc.args...)
if code != 0 {
t.Fatalf("%v 應回 0,得到 %d", tc.args, code)
}
if gotPath != tc.path {
t.Errorf("應呼叫 %s,得到 %s", tc.path, gotPath)
}
if !strings.Contains(stdout, tc.wantOut) {
t.Errorf("輸出應含 %s,得到:%s", tc.wantOut, stdout)
}
})
}
}
// TestAPIDirectPassthrough 驗證 teai api 原樣輸出、GET 免 --yes、
// POST 未帶 --yes 被擋。
func TestAPIDirectPassthrough(t *testing.T) {
var gotMethod string
run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
fmt.Fprint(w, `{"version":"1.27.0"}`)
})
stdout, _, code := run("api", "/version")
if code != 0 || gotMethod != "GET" {
t.Fatalf("GET /version 應回 0,得到 %d (%s)", code, gotMethod)
}
if strings.TrimSpace(stdout) != `{"version":"1.27.0"}` {
t.Errorf("api 應原樣輸出,得到:%s", stdout)
}
_, stderr, code := run("api", "--method", "POST", "/version")
if code != int(ExitUsage) || !strings.Contains(stderr, "--yes") {
t.Errorf("POST 未帶 --yes 應回 2 並提及 --yes,得到 %d:%s", code, stderr)
}
}
// TestAPIDirectQueryInPath 驗證 path 內含 ?query 時查詢字串正確傳遞。
func TestAPIDirectQueryInPath(t *testing.T) {
var gotQuery, gotPath string
run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) {
gotPath, gotQuery = r.URL.Path, r.URL.RawQuery
fmt.Fprint(w, `[]`)
})
_, _, code := run("api", "/repos/a/b/issues?state=closed&limit=10")
if code != 0 {
t.Fatalf("api 帶查詢字串應回 0,得到 %d", code)
}
if gotPath != "/api/v1/repos/a/b/issues" || gotQuery != "state=closed&limit=10" {
t.Errorf("應剝離查詢字串,得到 path=%s query=%s", gotPath, gotQuery)
}
}
// TestIssuesCommentPostsBody 驗證 comment --yes 送出 body。
func TestIssuesCommentPostsBody(t *testing.T) {
var gotPath, gotBody string
run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 512)
n, _ := r.Body.Read(buf)
gotPath, gotBody = r.URL.Path, string(buf[:n])
fmt.Fprint(w, `{"body":"留言","created_at":"t","user":{"login":"alex"}}`)
})
_, _, code := run("issues", "comment", "--repo", "alterminal/teai", "--body", "留言", "--yes", "8")
if code != 0 {
t.Fatalf("comment --yes 應回 0,得到 %d", code)
}
if !strings.HasSuffix(gotPath, "/issues/8/comments") || !strings.Contains(gotBody, `"body":"留言"`) {
t.Errorf("應 POST comments 端點帶 body,得到 %s %s", gotPath, gotBody)
}
}
// TestExtractGlobalsAllowsCommandFlags 驗證命令旗標與全域選項可交錯。
func TestExtractGlobalsAllowsCommandFlags(t *testing.T) {
run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"login":"alex"}`)
})
stdout, _, code := run("whoami", "--output", "table")
if code != 0 {
t.Fatalf("whoami --output table 應回 0,得到 %d", code)
}
if !strings.Contains(stdout, "alex") {
t.Errorf("table 輸出應含 alex,得到:%s", stdout)
}
}