package cmd_test // The transport, end to end: the real binary, run as a subprocess against a // throwaway project, talking to an httptest server that speaks enough of the // Gitea REST API to answer it. // // Enough and no more. What is worth proving here is not that JSON round-trips — // internal/mapping has tests for that, without a server anywhere — but the two // rules that cost work when they are wrong: a confirmed push takes the local file // with it, and an unconfirmed one does not touch it. // // The repository is always owner/repo, and the credentials arrive through // KETTLE_URL / KETTLE_TOKEN / KETTLE_REPO, which is also what a CI run does. // KETTLE_CONFIG_HOME points at a temp directory so no fixture can read or // overwrite the developer's own tokens. import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "testing" "git.noodles.cam/claude-skills/marketplace/cli/internal/config" "git.noodles.cam/claude-skills/marketplace/cli/internal/wire" ) // -------------------------------------------------------------------------- // the fake tracker // -------------------------------------------------------------------------- // pullFakeGitea is a Gitea instance with one repository in it, owner/repo. type pullFakeGitea struct { mu sync.Mutex issues map[int]*wire.Issue deps map[int][]int comments map[int][]wire.Comment labels map[string]int64 next int // writesFail makes every issue create and edit answer 500 — the failure a // push has to survive without losing a file. writesFail bool } func pullNewGitea() *pullFakeGitea { return &pullFakeGitea{ issues: map[int]*wire.Issue{}, deps: map[int][]int{}, comments: map[int][]wire.Comment{}, labels: map[string]int64{}, } } // pullAdd puts an issue in the tracker the way the web UI would: it is there // before this project ever hears about it. func (g *pullFakeGitea) pullAdd(p wire.Issue) { g.mu.Lock() defer g.mu.Unlock() if p.State == "" { p.State = "open" } p.HTMLURL = pullURL(p.Number) g.issues[p.Number] = &p if p.Number > g.next { g.next = p.Number } } func (g *pullFakeGitea) pullIssue(n int) wire.Issue { g.mu.Lock() defer g.mu.Unlock() if p := g.issues[n]; p != nil { return *p } return wire.Issue{} } func (g *pullFakeGitea) pullRetitle(n int, title string) { g.mu.Lock() defer g.mu.Unlock() g.issues[n].Title = title } func (g *pullFakeGitea) pullBlocks(blocked int, blockers ...int) { g.mu.Lock() defer g.mu.Unlock() g.deps[blocked] = append(g.deps[blocked], blockers...) } func pullURL(n int) string { return fmt.Sprintf("https://git.example.com/owner/repo/issues/%d", n) } var ( pullIssueRoute = regexp.MustCompile(`^issues/(\d+)$`) pullSubRoute = regexp.MustCompile(`^issues/(\d+)/(dependencies|comments|labels)$`) ) func (g *pullFakeGitea) ServeHTTP(w http.ResponseWriter, r *http.Request) { g.mu.Lock() defer g.mu.Unlock() path, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/owner/repo/") if !ok { http.Error(w, `{"message":"no such repository"}`, http.StatusNotFound) return } switch { case path == "labels" && r.Method == http.MethodGet: out := []wire.Label{} for name, id := range g.labels { out = append(out, wire.Label{ID: id, Name: name}) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) pullJSON(w, out) case path == "labels" && r.Method == http.MethodPost: var req wire.LabelRequest pullDecode(r, &req) id := int64(1000 + len(g.labels)) g.labels[req.Name] = id pullJSON(w, wire.Label{ID: id, Name: req.Name, Color: req.Color, Exclusive: req.Exclusive}) case path == "milestones" && r.Method == http.MethodGet: pullJSON(w, []wire.Milestone{}) case path == "issues" && r.Method == http.MethodPost: if g.writesFail { http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError) return } var req wire.IssueRequest pullDecode(r, &req) g.next++ p := &wire.Issue{ Number: g.next, Title: pullStr(req.Title), Body: pullStr(req.Body), State: "open", HTMLURL: pullURL(g.next), Labels: g.pullLabelsFor(req.Labels), } g.issues[p.Number] = p pullJSON(w, p) case path == "issues" && r.Method == http.MethodGet: g.pullList(w, r) case pullIssueRoute.MatchString(path): n := pullNumber(pullIssueRoute, path) p := g.issues[n] if p == nil { http.Error(w, `{"message":"no such issue"}`, http.StatusNotFound) return } if r.Method == http.MethodPatch { if g.writesFail { http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError) return } var req wire.IssueRequest pullDecode(r, &req) if req.Title != nil { p.Title = *req.Title } if req.Body != nil { p.Body = *req.Body } if req.State != nil { p.State = *req.State } if req.Labels != nil { p.Labels = g.pullLabelsFor(req.Labels) } } pullJSON(w, p) case pullSubRoute.MatchString(path): m := pullSubRoute.FindStringSubmatch(path) n, _ := strconv.Atoi(m[1]) switch { case m[2] == "dependencies" && r.Method == http.MethodGet: out := []wire.Issue{} for _, d := range g.deps[n] { if p := g.issues[d]; p != nil { out = append(out, *p) } } pullJSON(w, out) case m[2] == "dependencies" && r.Method == http.MethodPost: var req struct { Index int `json:"index"` } pullDecode(r, &req) g.deps[n] = append(g.deps[n], req.Index) w.WriteHeader(http.StatusCreated) case m[2] == "comments" && r.Method == http.MethodGet: out := g.comments[n] if out == nil { out = []wire.Comment{} } pullJSON(w, out) case m[2] == "labels" && r.Method == http.MethodPut: var req struct { Labels []int64 `json:"labels"` } pullDecode(r, &req) g.issues[n].Labels = g.pullLabelsFor(&req.Labels) pullJSON(w, g.issues[n].Labels) default: http.Error(w, `{"message":"not implemented"}`, http.StatusNotFound) } default: http.Error(w, `{"message":"not implemented: `+path+`"}`, http.StatusNotFound) } } // pullList is the filtered listing, paginated the way the client asks for it. func (g *pullFakeGitea) pullList(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() state, page, limit := q.Get("state"), 1, 50 if v, err := strconv.Atoi(q.Get("page")); err == nil && v > 0 { page = v } if v, err := strconv.Atoi(q.Get("limit")); err == nil && v > 0 { limit = v } var want []string if v := q.Get("labels"); v != "" { want = strings.Split(v, ",") } numbers := make([]int, 0, len(g.issues)) for n := range g.issues { numbers = append(numbers, n) } sort.Ints(numbers) out := []wire.Issue{} for _, n := range numbers { p := g.issues[n] if state != "" && state != "all" && p.State != state { continue } has := map[string]bool{} for _, l := range p.Labels { has[l.Name] = true } missing := false for _, l := range want { missing = missing || !has[l] } if missing { continue } out = append(out, *p) } start := (page - 1) * limit if start > len(out) { start = len(out) } end := start + limit if end > len(out) { end = len(out) } pullJSON(w, out[start:end]) } func (g *pullFakeGitea) pullLabelsFor(ids *[]int64) []wire.Label { if ids == nil { return nil } byID := map[int64]string{} for name, id := range g.labels { byID[id] = name } var out []wire.Label for _, id := range *ids { if name, ok := byID[id]; ok { out = append(out, wire.Label{ID: id, Name: name}) } } return out } func pullNumber(re *regexp.Regexp, path string) int { n, _ := strconv.Atoi(re.FindStringSubmatch(path)[1]) return n } func pullDecode(r *http.Request, into any) { _ = json.NewDecoder(r.Body).Decode(into) } func pullJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) } func pullStr(p *string) string { if p == nil { return "" } return *p } // pullEnv starts the fake and returns the environment that points the binary at // it. The credential home is a temp directory: a test run may neither read nor // overwrite the developer's own tokens. func pullEnv(t *testing.T, g *pullFakeGitea) []string { t.Helper() srv := httptest.NewServer(g) t.Cleanup(srv.Close) return []string{ config.EnvURL + "=" + srv.URL, config.EnvToken + "=t0ken", config.EnvRepo + "=owner/repo", config.EnvHome + "=" + t.TempDir(), } } func pullStore(dir string) string { return filepath.Join(dir, ".kettle", "issues") } func pullRead(t *testing.T, path string) string { t.Helper() raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(raw) } func pullExists(path string) bool { _, err := os.Stat(path) return err == nil } // -------------------------------------------------------------------------- // push // -------------------------------------------------------------------------- // The rule the whole design rests on: once the tracker has the issue, the // tracker IS the issue, and the local copy goes — sidecars included. func TestPushCreatesTheIssueAndTakesTheLocalCopyWithIt(t *testing.T) { dir := newProject(t) g := pullNewGitea() env := pullEnv(t, g) mustRun(t, dir, "new", "--type", "task", "--title", "Wire sqlc into the appclick layer") const id = "wire-sqlc-into-the-appclick-layer" store := pullStore(dir) sidecar := filepath.Join(store, id+".comments.md") if err := os.WriteFile(sidecar, []byte("# thread\n"), 0o644); err != nil { t.Fatal(err) } r := runWith(t, dir, env, "", "push") if r.code != 0 { t.Fatalf("push exited %d:\n%s", r.code, r.out()) } // The number and the URL lead: in a moment they are the only address the // issue has. if !strings.Contains(r.stdout, "created "+id+" #1 "+pullURL(1)) { t.Errorf("the receipt does not say where the issue lives now:\n%s", r.stdout) } if pullExists(filepath.Join(store, id+".md")) { t.Error("the local file survived a confirmed push — what is in the store is what has not left") } if pullExists(sidecar) { t.Error("the sidecar was left behind; every file under the slug goes") } // The ledger is what makes the slug come back, so it has to hold the number. ledger := pullRead(t, filepath.Join(store, ".remote.json")) if !strings.Contains(ledger, `"owner/repo#1": "`+id+`"`) { t.Errorf("the ledger does not index the number:\n%s", ledger) } // And the slug travelled up in the body, which is what survives a lost ledger. if body := g.pullIssue(1).Body; !strings.Contains(body, "") { t.Errorf("the id marker did not go up with the issue:\n%s", body) } if !strings.HasPrefix(g.pullIssue(1).Body, "