Files
marketplace/cli/internal/cmd/sync_write_test.go
T
naudachu 1239fdee70 refactor: move the transport onto the official Gitea SDK
The transport was hand-rolled net/http against the REST API. The payload shapes
were ours, in internal/wire, which meant every field Gitea learned was a field
somebody here had to notice; and "does this instance have issue dependencies?"
had to be guessed from a status code, because a 404 from a missing route and a
404 from a missing issue look alike.

The SDK settles both. The shapes are maintained by the people who maintain the
server, and the client negotiates the server's version when it is built, so the
dependency endpoint is now gated on `>= 1.20.0` — verified against the release
where the route appears, not assumed. Below the gate nothing is requested at all.

internal/wire keeps what the SDK has no answer for: addressing. The SDK takes an
owner, a name and an int64 and never parses, while `42`, `#42`, `owner/repo#42`
and an issue URL are four spellings of one address, all four are what somebody
has in hand, and Key is what the ledger is keyed by. The payload structs go.

Four things that had to survive the move, and did:

- request bodies still land in .kettle/payload/, now via an http.RoundTripper on
  the client the SDK is given — which is better than before, because it files
  every request rather than the ones a call site remembered to name;
- errors still carry the HTTP status AND the response body, and a decode failure
  on a 2xx is deliberately not an APIError, so the dependency probe cannot read
  a bad decode as "feature missing";
- the number -> slug ledger is untouched, entries still outlive the files they
  name;
- Client.For(repo) still re-points at another repository for one call.

What it cost, written down in AGENTS.md where it happened. internal/mapping's
layering test was a fact about the import graph — nothing in its closure could
open a socket — and the SDK ships its types and its client in one package, so
the test now asserts what is still true: the bridge performs no I/O, checked on
direct imports plus a grep for time.Now. A run makes one extra request before it
does anything. Gitea's issue edit endpoint carries no labels, so a push whose
labels changed needs a second call; push makes it and says so. go.mod requires
go 1.26, which the SDK sets and which is now the floor for building this binary.

internal/issue and internal/project are byte-identical. The domain did not
notice, which is the whole argument for the layering.

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

647 lines
21 KiB
Go

package cmd_test
// The four commands that WRITE — comment, close, labels, sync-evict — end to
// end: the real binary, in a throwaway project, against an httptest server
// speaking enough of the Gitea API to answer them.
//
// A fake tracker rather than a mocked client, because what these commands are
// trusted to get right is exactly the part a mock would stand in for: what goes
// out, and what is believed about the answer. `sync-evict` deletes files on the
// strength of a payload, so the payload has to come off a socket.
//
// Every helper here is named `wr…` so it cannot collide with the read side's.
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
const (
wrRepo = "kettle/tests"
wrToken = "s3cr3t-token"
wrWhen = "2026-08-11T12:00:00Z"
)
// --------------------------------------------------------------------------
// the fake tracker
// --------------------------------------------------------------------------
type wrLabel struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
Exclusive bool `json:"exclusive"`
}
type wrIssue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
HTMLURL string `json:"html_url"`
UpdatedAt string `json:"updated_at"`
}
type wrUser struct {
Login string `json:"login"`
}
type wrComment struct {
ID int64 `json:"id"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
User wrUser `json:"user"`
CreatedAt string `json:"created_at"`
}
var (
wrIssuePath = regexp.MustCompile(`^issues/(\d+)$`)
wrCommentPath = regexp.MustCompile(`^issues/(\d+)/comments$`)
wrLabelPath = regexp.MustCompile(`^labels/(\d+)$`)
)
// wrTracker is one repository on a pretend Gitea. It records every call, so a
// test can assert that a dry run sent nothing and that a second bootstrap wrote
// nothing.
type wrTracker struct {
mu sync.Mutex
labels []wrLabel
issues map[int]*wrIssue
comments map[int][]wrComment
broken map[int]bool // numbers whose GET answers 500
calls []string
next int64
}
func wrNewTracker() *wrTracker {
return &wrTracker{
issues: map[int]*wrIssue{},
comments: map[int][]wrComment{},
broken: map[int]bool{},
next: 100,
}
}
func (tr *wrTracker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
tr.mu.Lock()
defer tr.mu.Unlock()
// The version handshake, answered before the log: it is not a call any
// command made, and counting it would make every "how many requests did
// that send" assertion in this file one out.
if pullVersionRoute(w, r) {
return
}
tr.calls = append(tr.calls, r.Method+" "+r.URL.Path)
// The scheme Gitea uses and the client sends: the word `token`.
if r.Header.Get("Authorization") != "token "+wrToken {
http.Error(w, `{"message":"token required"}`, http.StatusUnauthorized)
return
}
path, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/"+wrRepo+"/")
if !ok {
http.Error(w, `{"message":"no such repository"}`, http.StatusNotFound)
return
}
switch {
case path == "labels" && r.Method == http.MethodGet:
wrJSON(w, tr.labels)
case path == "labels" && r.Method == http.MethodPost:
var req wrLabel
wrDecode(r, &req)
tr.next++
req.ID = tr.next
tr.labels = append(tr.labels, req)
wrJSON(w, req)
case r.Method == http.MethodPatch && wrLabelPath.MatchString(path):
id, _ := strconv.ParseInt(wrLabelPath.FindStringSubmatch(path)[1], 10, 64)
var req wrLabel
wrDecode(r, &req)
for i := range tr.labels {
if tr.labels[i].ID == id {
req.ID = id
tr.labels[i] = req
wrJSON(w, req)
return
}
}
http.Error(w, `{"message":"no such label"}`, http.StatusNotFound)
case wrIssuePath.MatchString(path):
n, _ := strconv.Atoi(wrIssuePath.FindStringSubmatch(path)[1])
if tr.broken[n] {
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
return
}
got := tr.issues[n]
if got == nil {
http.Error(w, `{"message":"no such issue"}`, http.StatusNotFound)
return
}
if r.Method == http.MethodPatch {
var req struct {
State *string `json:"state"`
Title string `json:"title"`
Body *string `json:"body"`
}
wrDecode(r, &req)
// A close is state and nothing else; a title or a body arriving
// here would be the command editing an issue it was only asked to
// close.
//
// A title of "" is not one. The SDK's edit body has no pointer
// there and sends the key whatever happens, so an empty string is
// how "no opinion" is spelled for this one field — which is also
// how Gitea itself reads it, and Gitea is what this stands in for.
// Everything else is a pointer and must arrive as null.
if req.Title != "" || req.Body != nil {
http.Error(w, `{"message":"close sent more than a state"}`, http.StatusUnprocessableEntity)
return
}
if req.State != nil {
got.State = *req.State
}
got.UpdatedAt = wrWhen
}
wrJSON(w, got)
case wrCommentPath.MatchString(path):
n, _ := strconv.Atoi(wrCommentPath.FindStringSubmatch(path)[1])
if tr.issues[n] == nil {
http.Error(w, `{"message":"no such issue"}`, http.StatusNotFound)
return
}
if r.Method == http.MethodPost {
var req struct {
Body string `json:"body"`
}
wrDecode(r, &req)
tr.next++
c := wrComment{
ID: tr.next,
Body: req.Body,
HTMLURL: fmt.Sprintf("https://tracker.example/%s/issues/%d#issuecomment-%d", wrRepo, n, tr.next),
User: wrUser{Login: "tester"},
CreatedAt: wrWhen,
}
tr.comments[n] = append(tr.comments[n], c)
wrJSON(w, c)
return
}
wrJSON(w, tr.comments[n])
default:
http.Error(w, `{"message":"not implemented: `+path+`"}`, http.StatusNotFound)
}
}
func wrJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func wrDecode(r *http.Request, v any) {
_ = json.NewDecoder(r.Body).Decode(v)
}
// --- what the tracker holds, for a test to arrange and to read back ---------
func (tr *wrTracker) add(number int, title, state string) {
tr.mu.Lock()
defer tr.mu.Unlock()
tr.issues[number] = &wrIssue{
Number: number,
Title: title,
State: state,
HTMLURL: fmt.Sprintf("https://tracker.example/%s/issues/%d", wrRepo, number),
UpdatedAt: wrWhen,
}
}
func (tr *wrTracker) breaks(number int) {
tr.mu.Lock()
defer tr.mu.Unlock()
tr.broken[number] = true
}
func (tr *wrTracker) state(number int) string {
tr.mu.Lock()
defer tr.mu.Unlock()
if got := tr.issues[number]; got != nil {
return got.State
}
return ""
}
func (tr *wrTracker) label(name string) *wrLabel {
tr.mu.Lock()
defer tr.mu.Unlock()
for i := range tr.labels {
if tr.labels[i].Name == name {
out := tr.labels[i]
return &out
}
}
return nil
}
func (tr *wrTracker) labelCount() int {
tr.mu.Lock()
defer tr.mu.Unlock()
return len(tr.labels)
}
func (tr *wrTracker) thread(number int) []wrComment {
tr.mu.Lock()
defer tr.mu.Unlock()
return append([]wrComment{}, tr.comments[number]...)
}
// mark is where the log has got to, so a test can ask what one run sent.
func (tr *wrTracker) mark() int {
tr.mu.Lock()
defer tr.mu.Unlock()
return len(tr.calls)
}
func (tr *wrTracker) since(mark int) []string {
tr.mu.Lock()
defer tr.mu.Unlock()
return append([]string{}, tr.calls[mark:]...)
}
func (tr *wrTracker) count(method string) int {
tr.mu.Lock()
defer tr.mu.Unlock()
n := 0
for _, c := range tr.calls {
if strings.HasPrefix(c, method+" ") {
n++
}
}
return n
}
// --------------------------------------------------------------------------
// the fixture
// --------------------------------------------------------------------------
// wrProject is an initialized project pointed at a fake tracker.
//
// The credentials arrive through the environment, which is what they are there
// for — and KETTLE_CONFIG_HOME goes at a temp directory so a run can neither
// read nor overwrite the developer's own tokens. KETTLE_LOGIN is cleared for the
// same reason: a value in the developer's shell would send every fixture
// looking for a login that is not in the temp file.
func wrProject(t *testing.T) (dir string, tr *wrTracker, env []string) {
t.Helper()
dir = newProject(t)
tr = wrNewTracker()
srv := httptest.NewServer(tr)
t.Cleanup(srv.Close)
return dir, tr, []string{
"KETTLE_URL=" + srv.URL,
"KETTLE_TOKEN=" + wrToken,
"KETTLE_REPO=" + wrRepo,
"KETTLE_CONFIG_HOME=" + t.TempDir(),
"KETTLE_LOGIN=",
}
}
func wrStore(dir string) string { return filepath.Join(dir, ".kettle", "issues") }
// wrTracked writes an issue the tracker also holds — a working copy, as a pull
// would have left it.
func wrTracked(t *testing.T, dir, id string, number int, state string) string {
t.Helper()
return wrWrite(t, dir, id, fmt.Sprintf(
"---\nid: %s\nstate: %s\nlabels: [type/task]\nassignees: []\nmilestone: none\ndepends: []\n"+
"origin: gitea\ngitea: %s#%d\nsynced: 2026-01-01T00:00:00Z\nurl: https://tracker.example/%s/issues/%d\n---\n"+
"# %s\n\n## Summary\nчто-то\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] сделано\n",
id, state, wrRepo, number, wrRepo, number, id))
}
// wrLocal writes an `origin: local` issue — the only copy of that work.
func wrLocal(t *testing.T, dir, id, state string) string {
t.Helper()
return wrWrite(t, dir, id, fmt.Sprintf(
"---\nid: %s\nstate: %s\nlabels: [type/task]\nassignees: []\nmilestone: none\ndepends: []\n"+
"origin: local\n---\n# %s\n\n## Summary\nчто-то\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] сделано\n",
id, state, id))
}
func wrWrite(t *testing.T, dir, id, text string) string {
t.Helper()
path := filepath.Join(wrStore(dir), id+".md")
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
return path
}
func wrRead(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func wrGone(t *testing.T, path, why string) {
t.Helper()
if _, err := os.Stat(path); err == nil {
t.Errorf("%s is still there — %s", filepath.Base(path), why)
}
}
func wrThere(t *testing.T, path, why string) {
t.Helper()
if _, err := os.Stat(path); err != nil {
t.Fatalf("%s is gone — %s", filepath.Base(path), why)
}
}
// --------------------------------------------------------------------------
// labels
// --------------------------------------------------------------------------
func TestLabelsCreatesTheCanonicalSetAndThenChangesNothing(t *testing.T) {
dir, tr, env := wrProject(t)
r := runWith(t, dir, env, "", "labels")
if r.code != 0 {
t.Fatalf("labels exited %d:\n%s", r.code, r.out())
}
// The set is the domain's, name for name: nothing is spelled out in the
// command, so adding a type over there is what adds it here.
want := issue.CanonicalLabels()
if got := tr.labelCount(); got != len(want) {
t.Fatalf("the repository holds %d label(s), want %d:\n%s", got, len(want), r.out())
}
for _, name := range want {
l := tr.label(name)
if l == nil {
t.Fatalf("%s was not created:\n%s", name, r.out())
}
if l.Color == "" {
t.Errorf("%s was created with no colour", name)
}
// `exclusive` is the flag no tracker CLI could set, and the whole reason
// label creation goes through the API.
if !l.Exclusive {
t.Errorf("%s is not exclusive", name)
}
}
if !strings.Contains(r.stdout, "created type/bug") {
t.Errorf("the receipt does not name what it created:\n%s", r.stdout)
}
// A label belongs to the repository, not to any issue: this must not have
// touched the store.
if _, err := os.Stat(filepath.Join(wrStore(dir), "INDEX.md")); err == nil {
t.Error("a label bootstrap wrote into the issue store")
}
mark := tr.mark()
again := runWith(t, dir, env, "", "labels")
if again.code != 0 {
t.Fatalf("the second run exited %d:\n%s", again.code, again.out())
}
for _, c := range tr.since(mark) {
if !strings.HasPrefix(c, "GET ") {
t.Errorf("the second run wrote: %s", c)
}
}
if !strings.Contains(again.stdout, "present type/bug") ||
!strings.Contains(again.stdout, "0 created") {
t.Errorf("a second run must be a no-op and say so:\n%s", again.stdout)
}
}
// --------------------------------------------------------------------------
// close
// --------------------------------------------------------------------------
func TestCloseChangesTheStateOnTheTrackerAndOnDisk(t *testing.T) {
dir, tr, env := wrProject(t)
tr.add(42, "Done and elsewhere", "open")
tr.add(99, "Never seen here", "open")
path := wrTracked(t, dir, "done-and-elsewhere", 42, "open")
dry := runWith(t, dir, env, "", "close", "--dry-run", "done-and-elsewhere")
if dry.code != 0 || !strings.Contains(dry.stdout, "would close") {
t.Fatalf("the dry run said nothing:\n%s", dry.out())
}
if n := tr.count("PATCH"); n != 0 {
t.Errorf("a dry run sent %d write(s) — it must make no request at all", n)
}
if !strings.Contains(wrRead(t, path), "state: open") {
t.Error("a dry run wrote to the local file")
}
r := runWith(t, dir, env, "", "close", "done-and-elsewhere")
if r.code != 0 {
t.Fatalf("close exited %d:\n%s", r.code, r.out())
}
if got := tr.state(42); got != "closed" {
t.Errorf("the tracker says %q, want closed", got)
}
local := wrRead(t, path)
if !strings.Contains(local, "state: closed") {
t.Errorf("the local copy was not brought along:\n%s", local)
}
// The answer that authorized the write is also the newest thing the tracker
// has said, so the freshness fields are stamped from it.
if !strings.Contains(local, "remote-updated: "+wrWhen) || !strings.Contains(local, "synced: 20") {
t.Errorf("the freshness fields were not stamped:\n%s", local)
}
index := filepath.Join(wrStore(dir), "INDEX.md")
if !strings.Contains(wrRead(t, index), "closed") {
t.Error("INDEX.md was not rebuilt from what is now on disk")
}
// A number this machine has never seen: closed in the tracker, nothing
// written here, and the receipt says which is which.
byNumber := runWith(t, dir, env, "", "close", "99")
if byNumber.code != 0 {
t.Fatalf("closing by number exited %d:\n%s", byNumber.code, byNumber.out())
}
if got := tr.state(99); got != "closed" {
t.Errorf("#99 says %q, want closed", got)
}
if !strings.Contains(byNumber.stdout, "no local copy") {
t.Errorf("the receipt hid that there was nothing to write:\n%s", byNumber.stdout)
}
// A number resolves through the file that carries the handle, so the local
// copy of #42 is kept honest even when it was named by number.
back := runWith(t, dir, env, "", "close", "--reopen", "42")
if back.code != 0 {
t.Fatalf("reopening by number exited %d:\n%s", back.code, back.out())
}
if got := tr.state(42); got != "open" {
t.Errorf("#42 says %q, want open", got)
}
if !strings.Contains(wrRead(t, path), "state: open") {
t.Errorf("a number named the tracker but not the local copy holding its handle:\n%s", wrRead(t, path))
}
// An issue that has never left this machine has no state in the tracker to
// change, and saying so beats editing one field of a local file.
wrLocal(t, dir, "never-left-here", "open")
refused := runWith(t, dir, env, "", "close", "never-left-here")
if refused.code == 0 || !strings.Contains(refused.stderr, "push") {
t.Errorf("closing a local issue must stop and say why:\n%s", refused.out())
}
}
// --------------------------------------------------------------------------
// comment
// --------------------------------------------------------------------------
func TestCommentPostsAndTheThreadLandsBesideTheIssue(t *testing.T) {
dir, tr, env := wrProject(t)
tr.add(42, "Talk about it", "open")
wrTracked(t, dir, "talk-about-it", 42, "open")
wrLocal(t, dir, "never-left-here", "open")
const said = "готово, задеплоено"
r := runWith(t, dir, env, "", "comment", "talk-about-it", "--body", said)
if r.code != 0 {
t.Fatalf("comment exited %d:\n%s", r.code, r.out())
}
thread := tr.thread(42)
if len(thread) != 1 || thread[0].Body != said {
t.Fatalf("the tracker holds %v", thread)
}
sidecar := filepath.Join(wrStore(dir), "talk-about-it.comments.md")
got := wrRead(t, sidecar)
if !strings.Contains(got, said) || !strings.Contains(got, "## comment ") {
t.Errorf("the thread did not land beside the issue:\n%s", got)
}
if !strings.Contains(r.stdout, "posted comment") || !strings.Contains(r.stdout, "thread:") {
t.Errorf("the receipt does not say what happened:\n%s", r.stdout)
}
// The target is a local id resolved through the `gitea:` handle, so an issue
// that carries none cannot be commented on at all.
refused := runWith(t, dir, env, "", "comment", "never-left-here", "--body", "x")
if refused.code == 0 || !strings.Contains(refused.stderr, "push") {
t.Errorf("commenting on a local-only issue must stop and say why:\n%s", refused.out())
}
if n := tr.count("POST"); n != 1 {
t.Errorf("%d comment(s) went out, want 1 — the refused one was sent anyway", n)
}
}
// --------------------------------------------------------------------------
// sync-evict
// --------------------------------------------------------------------------
// The one thing this command adds to the offline evict: a `state:` that is not
// stale. The file says open, the tracker says closed, and the tracker is right.
func TestSyncEvictRefreshesTheStateBeforeItDecides(t *testing.T) {
dir, tr, env := wrProject(t)
tr.add(42, "Closed in the web ui", "closed")
path := wrTracked(t, dir, "closed-in-the-web-ui", 42, "open")
offline := runWith(t, dir, env, "", "evict")
if offline.code != 0 || !strings.Contains(offline.stdout, "0 issue(s) evicted") {
t.Fatalf("the offline evict must keep an issue whose file reads open:\n%s", offline.out())
}
wrThere(t, path, "the offline evict asks the file, and the file says open")
// A dry run asks, reports, and touches nothing.
dry := runWith(t, dir, env, "", "sync-evict", "--dry-run")
if dry.code != 0 || !strings.Contains(dry.stdout, "would evict") {
t.Fatalf("the dry run said nothing:\n%s", dry.out())
}
wrThere(t, path, "a dry run deleted the issue")
if !strings.Contains(wrRead(t, path), "state: open") {
t.Error("a dry run wrote the refreshed state to disk")
}
r := runWith(t, dir, env, "", "sync-evict")
if r.code != 0 {
t.Fatalf("sync-evict exited %d:\n%s", r.code, r.out())
}
if !strings.Contains(r.stdout, "open -> closed") {
t.Errorf("the refresh was not reported:\n%s", r.stdout)
}
wrGone(t, path, "the tracker said it was closed")
if index := wrRead(t, filepath.Join(wrStore(dir), "INDEX.md")); strings.Contains(index, "closed-in-the-web-ui") {
t.Errorf("INDEX.md still lists the evicted issue:\n%s", index)
}
}
func TestSyncEvictKeepsALocalIssueTheTrackerNeverHeardOf(t *testing.T) {
dir, tr, env := wrProject(t)
tr.add(42, "Done elsewhere", "closed")
tracked := wrTracked(t, dir, "done-elsewhere", 42, "closed")
local := wrLocal(t, dir, "only-copy-there-is", "closed")
r := runWith(t, dir, env, "", "sync-evict")
if r.code != 0 {
t.Fatalf("sync-evict exited %d:\n%s", r.code, r.out())
}
wrThere(t, local, "a closed origin: local issue was deleted, and that file IS the work")
wrGone(t, tracked, "it is closed and the tracker has it")
// It was never asked about either: an issue that has never left this machine
// is not a question the tracker has an answer to.
if n := tr.count("GET"); n != 1 {
t.Errorf("%d issue(s) were asked about, want 1", n)
}
// Naming it explicitly does not make deleting it safe, and the reason is
// said out loud rather than left to be inferred from silence.
named := runWith(t, dir, env, "", "sync-evict", "only-copy-there-is")
if named.code != 0 {
t.Fatalf("naming a local issue exited %d:\n%s", named.code, named.out())
}
if !strings.Contains(named.stdout, "kept") || !strings.Contains(named.stdout, "IS the issue") {
t.Errorf("keeping it must be said out loud:\n%s", named.out())
}
wrThere(t, local, "naming it on the command line deleted it")
}
// A failed answer evicts NOTHING AT ALL — not even the issues whose answers had
// already arrived. There is no ordering constraint between evictions, so there
// is no reason to start before every answer is in.
func TestATrackerFailureDuringSyncEvictEvictsNothing(t *testing.T) {
dir, tr, env := wrProject(t)
tr.add(42, "First answer", "closed")
tr.add(43, "Second answer", "closed")
tr.breaks(43)
answered := wrTracked(t, dir, "aaa-answered", 42, "closed")
unanswered := wrTracked(t, dir, "bbb-unanswered", 43, "closed")
r := runWith(t, dir, env, "", "sync-evict")
if r.code == 0 {
t.Fatalf("a tracker failure must stop the run:\n%s", r.out())
}
if !strings.Contains(r.stderr, "Nothing was evicted") {
t.Errorf("the failure must say what it did not do:\n%s", r.stderr)
}
wrThere(t, answered, "its answer arrived, but another one did not")
wrThere(t, unanswered, "the tracker never answered for it")
}