package gitea_test // The transport is tested against httptest, never against a tracker: a test // that needs a server somewhere is a test nobody runs. // // Every fixture builds a throwaway project in a temp directory and points the // project walk at it with CLAUDE_PROJECT_DIR. Without that the walk falls // through to the working directory — which during a test run is this repository // — and a request dump would land in the developer's own project. Nothing here // reads a login file either, but KETTLE_CONFIG_HOME is redirected all the same, // so a run can neither read nor overwrite the developer's own tokens. import ( "encoding/json" "errors" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strconv" "strings" "testing" "git.noodles.cam/claude-skills/marketplace/cli/internal/config" "git.noodles.cam/claude-skills/marketplace/cli/internal/gitea" "git.noodles.cam/claude-skills/marketplace/cli/internal/wire" ) // newProject makes an initialized project and points the walk at it. Returns // the project root. func newProject(t *testing.T) string { t.Helper() dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, ".kettle"), 0o755); err != nil { t.Fatal(err) } t.Setenv("CLAUDE_PROJECT_DIR", dir) t.Setenv(config.EnvHome, filepath.Join(dir, "config")) return dir } func newClient(t *testing.T, url string) *gitea.Client { t.Helper() c, err := gitea.New(&config.Resolved{URL: url, Token: "s3cret", Owner: "acme", Repo: "widgets"}) if err != nil { t.Fatalf("New: %v", err) } return c } func writeJSON(t *testing.T, w http.ResponseWriter, v any) { t.Helper() w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(v); err != nil { t.Errorf("encoding the fake response: %v", err) } } // A list endpoint is followed to the last page and no further: the short page // ends it, and the page after that is never asked for. func TestPaginationFollowsToTheLastPage(t *testing.T) { newProject(t) var asked []string var auth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { asked = append(asked, r.URL.RequestURI()) auth = r.Header.Get("Authorization") page, _ := strconv.Atoi(r.URL.Query().Get("page")) limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) n := limit if page == 3 { n = 7 // the short page } else if page > 3 { n = 0 } out := []map[string]any{} for i := 0; i < n; i++ { out = append(out, map[string]any{"id": (page-1)*limit + i + 1, "body": "hello"}) } writeJSON(t, w, out) })) defer srv.Close() got, err := newClient(t, srv.URL).ListComments(42) if err != nil { t.Fatalf("ListComments: %v", err) } if len(got) != 107 { t.Errorf("got %d comments, want 107 (50 + 50 + 7)", len(got)) } if len(asked) != 3 { t.Errorf("made %d requests (%v), want 3 — a short page is the last one", len(asked), asked) } if got[0].ID != 1 || got[106].ID != 107 { t.Errorf("pages arrived out of order: first %d, last %d", got[0].ID, got[106].ID) } // Gitea's own scheme, and what the CLI this replaces sent. if auth != "token s3cret" { t.Errorf("Authorization was %q, want %q", auth, "token s3cret") } want := "/api/v1/repos/acme/widgets/issues/42/comments?page=1&limit=50" if asked[0] != want { t.Errorf("first request was %s, want %s", asked[0], want) } } // A non-2xx carries the status AND the body, because the status alone has never // told anybody which of the four things that answer 422 actually happened. func TestErrorNamesTheStatusAndTheBody(t *testing.T) { newProject(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) io.WriteString(w, `{"message":"label already exists","url":"https://example.test/docs"}`) })) defer srv.Close() _, err := newClient(t, srv.URL).GetIssue(7) if err == nil { t.Fatal("a 422 returned no error") } var apiErr *gitea.APIError if !errors.As(err, &apiErr) { t.Fatalf("error is %T, want *gitea.APIError: %v", err, err) } if apiErr.Status != http.StatusUnprocessableEntity { t.Errorf("Status is %d, want 422", apiErr.Status) } if !gitea.StatusIs(err, http.StatusUnprocessableEntity) { t.Error("StatusIs did not recognize its own error") } for _, want := range []string{"422", "label already exists", "GET", "/api/v1/repos/acme/widgets/issues/7"} { if !strings.Contains(err.Error(), want) { t.Errorf("the error does not mention %q:\n%s", want, err) } } // The token is in a header, so quoting the URL is safe — and it had better // stay that way. if strings.Contains(err.Error(), "s3cret") { t.Errorf("the error quotes the token:\n%s", err) } } // A request body is filed in the scratchpad, which is a SIBLING of the store // and never inside it. A call that touches no issue must not materialize an // issue directory. func TestPayloadLandsBesideTheStoreAndNeverInIt(t *testing.T) { root := newProject(t) var sent []byte srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sent, _ = io.ReadAll(r.Body) writeJSON(t, w, map[string]any{ "number": 42, "id": 5, "html_url": "https://example.test/acme/widgets/issues/42"}) })) defer srv.Close() body := " a & b" got, err := newClient(t, srv.URL).CreateIssue( wire.IssueRequest{Title: wire.Set("wire sqlc"), Body: wire.Set(body)}, "issue-wire-sqlc") if err != nil { t.Fatalf("CreateIssue: %v", err) } if got.Number != 42 { t.Errorf("got issue #%d, want #42", got.Number) } path := filepath.Join(root, ".kettle", "payload", "issue-wire-sqlc.json") raw, err := os.ReadFile(path) if err != nil { t.Fatalf("the request body was not filed at %s: %v", path, err) } if string(raw) != string(sent) { t.Errorf("the filed body is not the body that was sent:\nfiled: %s\nsent: %s", raw, sent) } // A dump escaped to \u003c is unreadable exactly when it is being read. if !strings.Contains(string(raw), " a & b") { t.Errorf("the dump escaped the markup it was meant to preserve:\n%s", raw) } // The whole reason the scratchpad is a sibling. if _, err := os.Stat(filepath.Join(root, ".kettle", "issues")); !os.IsNotExist(err) { t.Errorf("writing a request body materialized the issue store (%v)", err) } // A namespaced name must not climb out of the scratchpad. if _, err := newClient(t, srv.URL).CreateLabel(wire.LabelRequest{Name: "type/bug", Color: "#ee0701"}); err != nil { t.Fatalf("CreateLabel: %v", err) } if _, err := os.Stat(filepath.Join(root, ".kettle", "payload", "label-type-bug.json")); err != nil { t.Errorf("a label request body was not filed under a safe name: %v", err) } if _, err := os.Stat(filepath.Join(root, ".kettle", "payload", "type")); !os.IsNotExist(err) { t.Error("a label name with a slash in it made a directory inside the scratchpad") } } // A run that sends no body leaves no directory behind — the scratchpad is // created by the first write and only then. func TestAReadOnlyCallCreatesNoScratchpad(t *testing.T) { root := newProject(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeJSON(t, w, map[string]any{"number": 42}) })) defer srv.Close() if _, err := newClient(t, srv.URL).GetIssue(42); err != nil { t.Fatalf("GetIssue: %v", err) } if _, err := os.Stat(filepath.Join(root, ".kettle", "payload")); !os.IsNotExist(err) { t.Errorf("a read created the payload directory (%v)", err) } } // The milestone filter is re-checked on the client, because Gitea silently // ignores one it cannot resolve and answers with the whole backlog. Pull // requests go the same way. func TestListIssuesRechecksWhatTheServerIgnored(t *testing.T) { newProject(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/milestones") { writeJSON(t, w, []map[string]any{{"id": 3, "title": "v1"}, {"id": 9, "title": "later"}}) return } if r.URL.Query().Get("page") != "1" { writeJSON(t, w, []map[string]any{}) return } writeJSON(t, w, []map[string]any{ {"number": 1, "title": "in the milestone", "milestone": map[string]any{"id": 3, "title": "v1"}}, {"number": 2, "title": "another milestone", "milestone": map[string]any{"id": 9, "title": "later"}}, {"number": 3, "title": "no milestone at all"}, {"number": 4, "title": "a pull request", "milestone": map[string]any{"id": 3, "title": "v1"}, "pull_request": map[string]any{"merged": false}}, }) })) defer srv.Close() got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{Milestone: "v1", Limit: 50}) if err != nil { t.Fatalf("ListIssues: %v", err) } if got.Milestone != "v1" { t.Errorf("resolved milestone is %q, want v1", got.Milestone) } if len(got.Issues) != 1 || got.Issues[0].Number != 1 { t.Fatalf("got %d issue(s) %v, want only #1 — the backlog was not re-filtered", len(got.Issues), got.Issues) } if _, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{Milestone: "typo", Limit: 50}); err == nil { t.Error("an unknown milestone was accepted — that reads as a milestone with the whole backlog in it") } else if !strings.Contains(err.Error(), "have: v1 (id 3)") { t.Errorf("the error does not say what the repo actually has: %v", err) } } // A Keep predicate that rejects everything must not turn a bounded read into a // walk of the whole tracker, and coming up short is reported rather than // answered in silence. func TestListIssuesStopsAtThePageBudget(t *testing.T) { newProject(t) pages := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { pages++ limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) out := []map[string]any{} for i := 0; i < limit; i++ { out = append(out, map[string]any{"number": pages*100 + i, "state": "closed"}) } writeJSON(t, w, out) })) defer srv.Close() got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{ Limit: 2, Keep: func(i *wire.Issue) bool { return i.State == "open" }, }) if err != nil { t.Fatalf("ListIssues: %v", err) } if pages != gitea.PageSlack { t.Errorf("read %d page(s), want %d — one ideal page times the slack", pages, gitea.PageSlack) } if got.Warning == "" { t.Error("stopped short of the limit and said nothing about it") } // Everything enumerated comes back even though none of it counted: a caller // with something to say about the ones that did not still can. if len(got.Issues) != gitea.PageSlack*2 { t.Errorf("got %d issue(s), want every payload that was enumerated", len(got.Issues)) } } // A Keep-bounded read stops the moment the budget is full: the page after the // one that completed it is never requested. func TestListIssuesStopsAtTheLimit(t *testing.T) { newProject(t) pages := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { pages++ limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) out := []map[string]any{} for i := 0; i < limit; i++ { out = append(out, map[string]any{"number": pages*100 + i, "state": "open"}) } writeJSON(t, w, out) })) defer srv.Close() got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{ Limit: 2, Keep: func(i *wire.Issue) bool { return i.State == "open" }, }) if err != nil { t.Fatalf("ListIssues: %v", err) } if pages != 1 { t.Errorf("read %d page(s), want 1 — the budget was full after the first", pages) } if len(got.Issues) != 2 || got.Warning != "" { t.Errorf("got %d issue(s), warning %q; want 2 and no warning", len(got.Issues), got.Warning) } } // A dependency endpoint the instance does not have is "no dependencies", not a // failed pull. A dead connection still is one. func TestDependenciesToleratesAnInstanceWithoutThem(t *testing.T) { newProject(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "not implemented", http.StatusNotImplemented) })) defer srv.Close() got, err := newClient(t, srv.URL).DependencyKeys(42) if err != nil || len(got) != 0 { t.Errorf("DependencyKeys = %v, %v; want no keys and no error", got, err) } srv.Close() if _, err := newClient(t, srv.URL).Dependencies(42); err == nil { t.Error("a dead connection was reported as an instance without dependency support") } } // A half-filled configuration is refused here rather than at the first 401, // because a 401 names nothing an operator can act on. func TestNewRefusesAHalfFilledConfiguration(t *testing.T) { for _, tc := range []struct { what string cfg config.Resolved want string }{ {"no url", config.Resolved{Token: "t", Owner: "a", Repo: "b"}, config.EnvURL}, {"no token", config.Resolved{URL: "u", Owner: "a", Repo: "b"}, "kettle auth add"}, {"no repo", config.Resolved{URL: "u", Token: "t"}, "kettle init --repo"}, } { _, err := gitea.New(&tc.cfg) if err == nil { t.Errorf("%s: accepted", tc.what) continue } if !strings.Contains(err.Error(), tc.want) { t.Errorf("%s: the error does not name the fix (%q): %v", tc.what, tc.want, err) } } } // The layering rule, from this side. The transport knows numbers, logins, HTTP // and JSON; the domain knows none of those, and neither may reach the other. // // The bridge is out too, and for a reason of its own: it is the layer that // translates between the two, so it sits ABOVE both. A transport that imported // it would be a transport that knows what an issue is, one indirection later — // and the protocol both of them share, internal/wire, exists precisely so that // neither has to reach for the other to name a payload. func TestTransportDoesNotImportTheDomain(t *testing.T) { out, err := exec.Command("go", "list", "-deps", ".").Output() if err != nil { t.Fatalf("go list: %v", err) } for _, dep := range strings.Fields(string(out)) { switch { case strings.HasSuffix(dep, "/internal/issue"): t.Errorf("the transport imports %s — what an issue IS is not a transport concept", dep) case strings.HasSuffix(dep, "/internal/mapping"): t.Errorf("the transport imports %s — translating is a layer of its own, and it sits above this one", dep) } } }