internal/gitea:API 用戶端、認證與輸出基礎(#5)

- internal/gitea:僅標準庫的 HTTP 用戶端;ListAll 以 limit=50 逐頁抓滿
- 認證選序 --token → TEAI_TOKEN → tea 組態(--config/TEA_CONFIG/預設路徑);
  token 不入輸出、日誌與錯誤訊息
- 共用輸出 --output json|table(預設 json)、--timeout(預設 30s)
- CLI 結束碼 0/2/3:用法錯誤 → 2、API 錯誤(*gitea.ErrAPI)→ 3
- 單元測試:認證選序、tea 組態解析、分頁、逾時/401/5xx 錯誤映射、輸出

實作 issue #5
This commit is contained in:
max
2026-09-10 08:20:08 +08:00
parent 14edef066b
commit 6951817313
8 changed files with 1442 additions and 16 deletions
+123
View File
@@ -0,0 +1,123 @@
// exitcode_test.go 驗證結束碼映射:用法錯誤 → 2、API 錯誤 → 3、其他 → 1,
// 以及全域選項剖析(README「輸出與結束碼」「全域介面」)。
package cli
import (
"strings"
"testing"
"gitea.alterminal.com/alterminal/teai/internal/gitea"
)
func TestExitCodeMapping(t *testing.T) {
cases := []struct {
name string
err error
want ExitCode
}{
{"usage error", &ErrUsage{Msg: "bad flag"}, ExitUsage},
{"api error 401", &gitea.ErrAPI{StatusCode: 401, Op: "GET /api/v1/user"}, ExitAPI},
{"api error transport", &gitea.ErrAPI{Op: "GET /api/v1/user", Err: errString("dial tcp")}, ExitAPI},
{"wrapped api error", wrapped(&gitea.ErrAPI{StatusCode: 500}), ExitAPI},
{"other error", errString("boom"), ExitInternal},
{"nil maps to OK", nil, ExitOK},
}
for _, tc := range cases {
if got := exitCodeFor(tc.err); got != tc.want {
t.Errorf("%s: exitCodeFor = %d, want %d", tc.name, got, tc.want)
}
}
}
func TestRunExitCodes(t *testing.T) {
t.Run("unknown command exits 2", func(t *testing.T) {
_, _, code := run("no-such")
if code != 2 {
t.Fatalf("code = %d, want 2", code)
}
})
t.Run("unknown global option exits 2", func(t *testing.T) {
_, _, code := run("--wat", "version")
if code != 2 {
t.Fatalf("code = %d, want 2", code)
}
})
t.Run("missing option value exits 2", func(t *testing.T) {
_, _, code := run("--output")
if code != 2 {
t.Fatalf("code = %d, want 2", code)
}
})
t.Run("invalid output value exits 2", func(t *testing.T) {
_, _, code := run("--output=yaml", "version")
if code != 2 {
t.Fatalf("code = %d, want 2", code)
}
})
t.Run("invalid timeout exits 2", func(t *testing.T) {
_, _, code := run("--timeout=soon", "version")
if code != 2 {
t.Fatalf("code = %d, want 2", code)
}
})
t.Run("version still works with globals", func(t *testing.T) {
stdout, _, code := run("--output=table", "--timeout=10s", "version")
if code != 0 || !strings.Contains(stdout, "teai version ") {
t.Fatalf("code = %d stdout = %q", code, stdout)
}
})
}
func TestParseGlobals(t *testing.T) {
g := defaultGlobals()
rest, err := parseGlobals(&g, []string{
"--url", "https://example.com",
"--token=tok",
"--config", "/tmp/c.yml",
"--output", "table",
"--timeout", "45s",
"next",
})
if err != nil {
t.Fatal(err)
}
if len(rest) != 1 || rest[0] != "next" {
t.Fatalf("rest = %v, want [next]", rest)
}
if g.URL != "https://example.com" || g.Token != "tok" || g.ConfigPath != "/tmp/c.yml" {
t.Fatalf("globals = %+v", g)
}
if g.Output != gitea.FormatTable {
t.Fatalf("output = %v, want table", g.Output)
}
if g.Timeout.String() != "45s" {
t.Fatalf("timeout = %s", g.Timeout)
}
}
func TestParseGlobalsStopsAtCommand(t *testing.T) {
g := defaultGlobals()
rest, err := parseGlobals(&g, []string{"version", "--output", "table"})
if err != nil {
t.Fatal(err)
}
// 命令之後的選項屬於命令,不屬於全域。
if len(rest) != 3 || rest[0] != "version" {
t.Fatalf("rest = %v", rest)
}
}
// errString 把字串轉成 error(測試輔助)。
type errStr string
func (e errStr) Error() string { return string(e) }
func errString(s string) error { return errStr(s) }
// wrapped 包一層 error(測試 errors.As 穿透)。
type wrappedErr struct{ inner error }
func (w wrappedErr) Error() string { return "wrapped: " + w.inner.Error() }
func (w wrappedErr) Unwrap() error { return w.inner }
func wrapped(inner error) error { return wrappedErr{inner} }