Files
marketplace/cli/internal/gitea/client_test.go
T
naudachu 9480e48312 feat: add the kettle CLI, replacing the plugin's Python scripts
The plugin resolved its issue store from `__file__`, which put it inside a
versioned plugin cache: issues written from one project were invisible from the
next, and `origin: local` files — the only copy of that work by definition —
were stranded a version bump at a time. The walk that answers "which directory
is the project" was written three times over, and in a linked worktree the three
disagreed. Both are runtime failures rather than logic ones, so the fix is a
compiled binary: one walk, imported rather than re-derived, and a layering rule
the build graph enforces instead of a grep.

Seven packages, knowledge flowing one way. `project` answers which directory is
the project and depends on nothing. `issue` is the domain — format, taxonomy,
validation, checkboxes, dependency graph, the store, eviction — offline, with no
tracker in it. `wire` holds the protocol shapes. `gitea` is the transport,
`mapping` the bridge, `config` the credentials, `cmd` the command tree. Four
tests hold the boundaries, each failing on a real mistake rather than a naming
convention.

The marker moves to `.kettle/` and the login pin moves out of the harness's
settings file into `.kettle/config.yaml`, which pins a login by NAME; the tokens
live in one file per machine, mode 0600, outside every working tree. That
retires the PreToolUse guard hook entirely — the binary holds its own
credentials, so a command running under a login nobody chose is not expressible
rather than caught.

`kettle init` migrates an older `tmp/issues` or `.tea/issues` store in, as a
move: a store left behind at an old path is one somebody edits by accident
months later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:05:39 +05:00

397 lines
14 KiB
Go

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 := "<!-- kettle:id wire-sqlc --> 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), "<!-- kettle:id wire-sqlc --> 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)
}
}
}