// 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") // 對照 README「輸出與結束碼」:用法錯誤=2(#10)。 if code != 2 { t.Errorf("缺 --repo 應回 2(README 用法錯誤),得到 %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...) // 對照 README「輸出與結束碼」:用法錯誤=2(#10)。 if code != 2 { t.Errorf("%s 未帶 --yes 應回 2(README 用法錯誤),得到 %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") // 對照 README「輸出與結束碼」:用法錯誤=2(#10)。 if code != 2 || !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) } }