package cmd_test // `kettle api` end to end: the real binary, in a throwaway project, against a // fake that records what it was asked for and answers with bytes. // // What is worth proving here is not that HTTP works — internal/gitea has that // against httptest — but the four things this command decides on its own: which // verb goes out, what the endpoint resolves to, that the answer reaches stdout // unchanged, and that a deletion does not happen because a model typed it. // // Every helper is named `ap…` so it cannot collide with the two fakes already in // this package. The version handshake is pullVersionRoute's, because a fake that // does not answer it is a fake no command can build a client against. import ( "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "git.noodles.cam/claude-skills/marketplace/cli/internal/config" ) // apCall is one request as the fake saw it. type apCall struct { Method string URI string Body string Auth string } // apTracker answers everything with the same little JSON object and remembers // what it was asked. A status can be armed for the one test that wants a // refusal. type apTracker struct { mu sync.Mutex calls []apCall status int answer string } func (tr *apTracker) ServeHTTP(w http.ResponseWriter, r *http.Request) { if pullVersionRoute(w, r) { return } raw, _ := io.ReadAll(r.Body) tr.mu.Lock() tr.calls = append(tr.calls, apCall{ Method: r.Method, URI: r.URL.RequestURI(), Body: string(raw), Auth: r.Header.Get("Authorization"), }) status, answer := tr.status, tr.answer tr.mu.Unlock() w.Header().Set("Content-Type", "application/json") if status == 0 { status = http.StatusOK } if answer == "" { answer = `{"tag_name":"v0.2.0"}` } w.WriteHeader(status) io.WriteString(w, answer) } func (tr *apTracker) apCalls() []apCall { tr.mu.Lock() defer tr.mu.Unlock() return append([]apCall{}, tr.calls...) } // apEnv starts the fake and returns the environment that points the binary at // it — the same shape a CI run uses, and a credential home that is a temp // directory so no fixture can read the developer's own tokens. func apEnv(t *testing.T, tr *apTracker) []string { t.Helper() srv := httptest.NewServer(tr) t.Cleanup(srv.Close) return []string{ config.EnvURL + "=" + srv.URL, config.EnvToken + "=t0ken", config.EnvRepo + "=owner/repo", config.EnvHome + "=" + t.TempDir(), } } // A read: GET by default, the placeholders filled from the project, and the // server's bytes on stdout with nothing done to them. func TestAPIGetsAndPrintsWhatCameBack(t *testing.T) { dir := newProject(t) tr := &apTracker{answer: `{"tag_name":"v0.2.0","draft":false}`} r := runWith(t, dir, apEnv(t, tr), "", "api", "repos/{owner}/{repo}/releases?limit=50") if r.code != 0 { t.Fatalf("exit %d:\n%s", r.code, r.out()) } if strings.TrimSpace(r.stdout) != `{"tag_name":"v0.2.0","draft":false}` { t.Errorf("stdout is not the server's bytes:\n%q", r.stdout) } calls := tr.apCalls() if len(calls) != 1 { t.Fatalf("%d request(s) went out, want 1 — one invocation is one request: %v", len(calls), calls) } if calls[0].Method != http.MethodGet { t.Errorf("method was %s, want GET", calls[0].Method) } if calls[0].URI != "/api/v1/repos/owner/repo/releases?limit=50" { t.Errorf("endpoint resolved to %s", calls[0].URI) } if calls[0].Auth != "token t0ken" { t.Errorf("Authorization was %q — Gitea's scheme is the word token", calls[0].Auth) } } // A body from a file: POST is implied by having one, the bytes arrive as they // were written, and the transport files a copy in the project's scratchpad. func TestAPIPostsTheFileItWasGivenAndFilesIt(t *testing.T) { dir := newProject(t) tr := &apTracker{} body := `{"tag_name":"v0.2.0","body":"## Changes\n\nwith ` + "`code`" + ` in it"}` path := filepath.Join(dir, "release.json") if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } r := runWith(t, dir, apEnv(t, tr), "", "api", "--data", "@"+path, "repos/{owner}/{repo}/releases") if r.code != 0 { t.Fatalf("exit %d:\n%s", r.code, r.out()) } calls := tr.apCalls() if len(calls) != 1 || calls[0].Method != http.MethodPost { t.Fatalf("want one POST, got %v", calls) } if calls[0].Body != body { t.Errorf("the server got\n%s\nwant\n%s", calls[0].Body, body) } // The scratchpad is the transport's, and it holds what went out whether or // not the caller named the file. entries, err := os.ReadDir(filepath.Join(dir, ".kettle", "payload")) if err != nil || len(entries) != 1 { t.Fatalf("the request body was not filed under .kettle/payload/ (%v, %v)", entries, err) } filed, err := os.ReadFile(filepath.Join(dir, ".kettle", "payload", entries[0].Name())) if err != nil { t.Fatal(err) } if !strings.Contains(string(filed), "v0.2.0") { t.Errorf("the filed body is not the one that was sent:\n%s", filed) } } // --field is the small-body form. Every value is a string, and the object it // builds is what goes on the wire. func TestAPIFieldsBuildAJSONObject(t *testing.T) { dir := newProject(t) tr := &apTracker{} r := runWith(t, dir, apEnv(t, tr), "", "api", "--field", "title=Wire sqlc", "--field", "head=feat/x", "repos/{owner}/{repo}/pulls") if r.code != 0 { t.Fatalf("exit %d:\n%s", r.code, r.out()) } var got map[string]any if err := json.Unmarshal([]byte(tr.apCalls()[0].Body), &got); err != nil { t.Fatalf("the body is not JSON: %v (%s)", err, tr.apCalls()[0].Body) } if got["title"] != "Wire sqlc" || got["head"] != "feat/x" { t.Errorf("the fields did not arrive: %v", got) } } // A path that spells another repository out in full is left alone: that is how // one project reaches another's releases, and why there is no --repo flag. func TestAPILeavesAFullyNamedRepositoryAlone(t *testing.T) { dir := newProject(t) tr := &apTracker{} mustRunWith(t, dir, apEnv(t, tr), "api", "repos/other-owner/other-repo/releases") if got := tr.apCalls()[0].URI; got != "/api/v1/repos/other-owner/other-repo/releases" { t.Errorf("the project's own repository was substituted into a path that named one: %s", got) } } // Outside a project there is nothing to run against, and the failure says which // command makes one — never a connection error, and never a guess at a tracker. func TestAPIOutsideAProjectNamesInit(t *testing.T) { dir := t.TempDir() tr := &apTracker{} r := runWith(t, dir, apEnv(t, tr), "", "api", "user") if r.code != 1 { t.Fatalf("exit %d, want 1:\n%s", r.code, r.out()) } if !strings.Contains(r.stderr, "no .kettle/ found") { t.Errorf("the failure does not name what was searched:\n%s", r.stderr) } if len(tr.apCalls()) != 0 { t.Error("a request went out from a directory that is not a project") } } // A refusal is an exit 1 that quotes the status and what the server said — // which is the only thing that tells four different 422s apart. func TestAPIReportsTheStatusAndTheBodyOnAFailure(t *testing.T) { dir := newProject(t) tr := &apTracker{status: http.StatusNotFound, answer: `{"message":"release does not exist"}`} r := runWith(t, dir, apEnv(t, tr), "", "api", "--status", "repos/{owner}/{repo}/releases/9") if r.code != 1 { t.Fatalf("exit %d, want 1:\n%s", r.code, r.out()) } for _, want := range []string{"404", "release does not exist"} { if !strings.Contains(r.stderr, want) { t.Errorf("stderr does not mention %q:\n%s", want, r.stderr) } } if strings.Contains(r.stdout, "release does not exist") { t.Errorf("a failed body was printed as though it were an answer:\n%s", r.stdout) } } // A deletion is an operator's decision. Without --yes nothing is sent at all — // the refusal comes before the request, not after it. func TestAPIDeleteNeedsYes(t *testing.T) { dir := newProject(t) tr := &apTracker{} env := apEnv(t, tr) r := runWith(t, dir, env, "", "api", "-X", "DELETE", "repos/{owner}/{repo}/releases/12") if r.code != 1 || !strings.Contains(r.stderr, "--yes") { t.Fatalf("a DELETE without --yes must be refused by name:\n%s", r.out()) } if len(tr.apCalls()) != 0 { t.Fatal("the request went out anyway — the gate is before the socket, or it is not a gate") } mustRunWith(t, dir, env, "api", "-X", "DELETE", "--yes", "repos/{owner}/{repo}/releases/12") calls := tr.apCalls() if len(calls) != 1 || calls[0].Method != http.MethodDelete { t.Errorf("--yes did not let the deletion through: %v", calls) } if calls[0].Body != "" { t.Errorf("a DELETE carried a body: %q", calls[0].Body) } } // A method this does not send is refused before anything is resolved: an // unquoted endpoint that lost a word to the shell must not go out as a verb. func TestAPIRefusesAMethodItDoesNotSend(t *testing.T) { dir := newProject(t) tr := &apTracker{} r := runWith(t, dir, apEnv(t, tr), "", "api", "-X", "HEAD", "user") if r.code != 1 || !strings.Contains(r.stderr, "GET, POST, PUT, PATCH or DELETE") { t.Errorf("an unsupported method was not named:\n%s", r.out()) } if len(tr.apCalls()) != 0 { t.Error("a request went out for a method this does not send") } } // mustRunWith is mustRun with an environment. func mustRunWith(t *testing.T, dir string, env []string, args ...string) result { t.Helper() r := runWith(t, dir, env, "", args...) if r.code != 0 { t.Fatalf("kettle %v exited %d:\n%s", args, r.code, r.out()) } return r }