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. // // EVERY FAKE ANSWERS /api/v1/version, because building a client is now a // request: the SDK asks the instance what it is before it hands one back, and // that answer is what the dependency gate is decided on later. A fake that did // not answer it would be a fake no client can be built against — see // versionRoute. import ( "encoding/json" "errors" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "reflect" "strconv" "strings" "testing" sdk "code.gitea.io/sdk/gitea" "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" ) // modernGitea is what a fake says it is: new enough for everything this // transport asks for, dependency endpoints included. const modernGitea = "1.26.1" // 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 } // versionRoute answers the SDK's version handshake and reports whether it did, // so every other handler can be written as though the request were not there. func versionRoute(w http.ResponseWriter, r *http.Request, version string) bool { if r.URL.Path != "/api/v1/version" { return false } w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"version":"`+version+`"}`) return true } // serve is an httptest server that speaks the handshake and hands everything // else to next. func serve(t *testing.T, version string, next http.HandlerFunc) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if versionRoute(w, r, version) { return } next(w, r) })) t.Cleanup(srv.Close) return srv } 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 := serve(t, modernGitea, 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) }) 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?limit=50&page=1" 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 := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) io.WriteString(w, `{"message":"label already exists","url":"https://example.test/docs"}`) }) _, 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 := serve(t, modernGitea, 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"}) }) body := " a & b" got, err := newClient(t, srv.URL).CreateIssue( sdk.CreateIssueOption{Title: "wire sqlc", Body: body}, "issue-wire-sqlc") if err != nil { t.Fatalf("CreateIssue: %v", err) } if got.Index != 42 { t.Errorf("got issue #%d, want #42", got.Index) } 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) } // The same JSON VALUE as went out, and not the same bytes: the SDK marshals // with encoding/json's defaults, so what is on the wire has its markup // escaped and no indentation. A dump nobody can read is a dump nobody // reads, and a re-POST of this file sends what this call sent. var filed, onTheWire any if err := json.Unmarshal(raw, &filed); err != nil { t.Fatalf("the filed body is not JSON: %v\n%s", err, raw) } if err := json.Unmarshal(sent, &onTheWire); err != nil { t.Fatalf("what was sent is not JSON: %v\n%s", err, sent) } if !reflect.DeepEqual(filed, onTheWire) { 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) } if !strings.Contains(string(raw), "\n \"") { t.Errorf("the dump is not indented:\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(sdk.CreateLabelOption{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. The version handshake every client // opens with is a read, so building one is not "a run that sent something". func TestAReadOnlyCallCreatesNoScratchpad(t *testing.T) { root := newProject(t) srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) { writeJSON(t, w, map[string]any{"number": 42}) }) 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 := serve(t, modernGitea, 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}}, }) }) 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].Index != 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 := serve(t, modernGitea, 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) }) got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{ Limit: 2, Keep: func(i *sdk.Issue) bool { return i.State == sdk.StateOpen }, }) 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 := serve(t, modernGitea, 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) }) got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{ Limit: 2, Keep: func(i *sdk.Issue) bool { return i.State == sdk.StateOpen }, }) 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 has but this repository does not is "no // dependencies", not a failed pull. A dead connection still is one. func TestDependenciesToleratesAnInstanceWithoutThem(t *testing.T) { newProject(t) srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) { http.Error(w, "not implemented", http.StatusNotImplemented) }) // Both clients are built while the server is up, because building one is // itself a request now — and the question this asks is what a call does // when the connection dies UNDER it, which is a different failure from a // tracker that was never there. live := newClient(t, srv.URL) dead := newClient(t, srv.URL) got, err := live.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 := dead.Dependencies(42); err == nil { t.Error("a dead connection was reported as an instance without dependency support") } } // The gate the SDK made possible: an instance too old to have the endpoint at // all is answered from its version, without a request. // // It matters because the alternative is guessing from a status code. An old // Gitea answers a route it does not have with the same 404 it answers for an // issue that does not exist, and a pull that read the second as "no blockers" // would quietly drop half the unit of work. func TestDependenciesAreNotAskedForOnAnInstanceTooOldToHaveThem(t *testing.T) { newProject(t) asked := 0 srv := serve(t, "1.19.4", func(w http.ResponseWriter, r *http.Request) { asked++ writeJSON(t, w, []map[string]any{{"number": 7}}) }) c := newClient(t, srv.URL) got, err := c.Dependencies(42) if err != nil || len(got) != 0 { t.Errorf("Dependencies = %v, %v; want none and no error", got, err) } if asked != 0 { t.Errorf("%d request(s) went out — the version had already answered", asked) } // Writing one says so out loud instead: push reports it beside the issue it // could not link, and a warning naming the version is something an operator // can act on. err = c.AddDependency(42, wire.Key{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 7}) if err == nil { t.Fatal("a link was attempted against an instance that has no endpoint for it") } if !strings.Contains(err.Error(), "1.20.0") { t.Errorf("the refusal does not name the version that would work: %v", err) } if asked != 0 { t.Errorf("%d request(s) went out for a write the instance cannot take", asked) } } // And the other side of the same gate: a modern instance is asked, and the // answer comes back as cross-repo keys — a dependency is allowed to live // somewhere else, and a bare number would not say where. func TestDependenciesAreAskedForOnAnInstanceThatHasThem(t *testing.T) { newProject(t) var body string srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { raw, _ := io.ReadAll(r.Body) body = string(raw) w.WriteHeader(http.StatusCreated) return } writeJSON(t, w, []map[string]any{ {"number": 7}, {"number": 3, "repository": map[string]any{"full_name": "other/repo"}}, }) }) c := newClient(t, srv.URL) got, err := c.DependencyKeys(42) if err != nil { t.Fatalf("DependencyKeys: %v", err) } want := []wire.Key{ {Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 7}, {Repo: wire.Repo{Owner: "other", Name: "repo"}, Number: 3}, } if !reflect.DeepEqual(got, want) { t.Errorf("keys = %v, want %v", got, want) } // The one endpoint left with a hand-rolled request, because the SDK's // IssueMeta carries an index and nothing else: a link to another repository // needs the owner and the name with it. if err := c.AddDependency(42, want[1]); err != nil { t.Fatalf("AddDependency: %v", err) } for _, part := range []string{`"index":3`, `"owner":"other"`, `"repo":"repo"`} { if !strings.Contains(body, part) { t.Errorf("the link body does not carry %s: %s", part, body) } } } // The generic request: bytes out, bytes back, and the same three services every // other call in this package gets — the header, the scratchpad, the *APIError. func TestDoAnswersWithWhatTheServerSent(t *testing.T) { root := newProject(t) var got struct{ method, uri, auth, ctype string } var sent []byte srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) { got.method, got.uri = r.Method, r.URL.RequestURI() got.auth, got.ctype = r.Header.Get("Authorization"), r.Header.Get("Content-Type") sent, _ = io.ReadAll(r.Body) w.WriteHeader(http.StatusCreated) io.WriteString(w, `{"tag_name":"v0.2.0"}`) }) c := newClient(t, srv.URL) // A read: no body out, and nothing filed — the scratchpad holds what was // SENT, and a run that sent nothing leaves no directory behind. code, answer, err := c.Do(http.MethodGet, "repos/acme/widgets/releases?limit=50", nil, "") if err != nil { t.Fatalf("Do: %v", err) } if code != http.StatusCreated || string(answer) != `{"tag_name":"v0.2.0"}` { t.Errorf("got %d %q, want 201 and the server's bytes", code, answer) } if got.uri != "/api/v1/repos/acme/widgets/releases?limit=50" { t.Errorf("the endpoint was rewritten: %s", got.uri) } if got.auth != "token s3cret" { t.Errorf("Authorization was %q, want %q", got.auth, "token s3cret") } if got.ctype != "" { t.Errorf("a request with no body carried Content-Type %q", got.ctype) } if _, err := os.Stat(filepath.Join(root, ".kettle", "payload")); !os.IsNotExist(err) { t.Errorf("a read created the payload directory (%v)", err) } // A write: the body goes out verbatim and is filed under the name it was // given, by the same RoundTripper that files every other request. body := []byte(`{"tag_name":"v0.2.0","body":"a & b"}`) if _, _, err := c.Do(http.MethodPost, "/api/v1/repos/acme/widgets/releases", body, "release-v0-2-0"); err != nil { t.Fatalf("Do: %v", err) } if got.method != http.MethodPost || got.ctype != "application/json" { t.Errorf("the write went out as %s %q", got.method, got.ctype) } if string(sent) != string(body) { t.Errorf("the server got %s, want %s — a passthrough reformatted the body", sent, body) } filed, err := os.ReadFile(filepath.Join(root, ".kettle", "payload", "release-v0-2-0.json")) if err != nil { t.Fatalf("the body was not filed: %v", err) } if !strings.Contains(string(filed), `"tag_name": "v0.2.0"`) { t.Errorf("the dump is not the body that was sent:\n%s", filed) } } // A refusal comes back as this package's error, with the status and the // server's own words — and the status and body are returned as well, so a // caller that would rather print them than wrap them can. func TestDoReportsAStatusAndTheServersWords(t *testing.T) { newProject(t) srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) io.WriteString(w, `{"message":"release does not exist"}`) }) code, answer, err := newClient(t, srv.URL).Do(http.MethodGet, "repos/acme/widgets/releases/9", nil, "") if err == nil { t.Fatal("a 404 came back as success") } var apiErr *gitea.APIError if !errors.As(err, &apiErr) { t.Fatalf("error is %T, want *gitea.APIError: %v", err, err) } if code != http.StatusNotFound || !strings.Contains(string(answer), "release does not exist") { t.Errorf("got %d %q; the status and the body are the caller's too", code, answer) } for _, want := range []string{"404", "release does not exist", "GET", "/api/v1/repos/acme/widgets/releases/9"} { if !strings.Contains(err.Error(), want) { t.Errorf("the error does not mention %q:\n%s", want, err) } } } // A 403 is answered with what to do about it, because Gitea's own 403 names no // scope and a token minted for issues is the usual reason. func TestAForbiddenAnswerNamesTheScopeItMightBe(t *testing.T) { newProject(t) srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) io.WriteString(w, `{"message":"token does not have at least one of required scope(s)"}`) }) _, _, err := newClient(t, srv.URL).Do(http.MethodPost, "repos/acme/widgets/releases", []byte(`{}`), "") if err == nil { t.Fatal("a 403 came back as success") } for _, want := range []string{"403", "kettle auth list", "repository"} { if !strings.Contains(err.Error(), want) { t.Errorf("a 403 does not say %q — the server named no scope, so this has to:\n%s", want, err) } } } // The token is this instance's. A full URL somewhere else is refused before a // socket is opened, because sending it would hand the credential to whatever // host was typed. func TestDoRefusesAURLOnAnotherHost(t *testing.T) { newProject(t) asked := 0 srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) { asked++ writeJSON(t, w, map[string]any{}) }) c := newClient(t, srv.URL) _, _, err := c.Do(http.MethodGet, "https://gitea.example.invalid/api/v1/user", nil, "") if err == nil { t.Fatal("a request to another host was allowed — that sends this project's token to it") } if strings.Contains(err.Error(), "s3cret") { t.Errorf("the refusal quotes the token:\n%s", err) } if asked != 0 { t.Errorf("%d request(s) went out for an endpoint that was refused", asked) } // A full URL on the instance itself is the same request as the bare path. if _, _, err := c.Do(http.MethodGet, srv.URL+"/api/v1/user", nil, ""); err != nil { t.Errorf("a full URL on this instance was refused: %v", err) } if asked != 1 { t.Errorf("%d request(s) went out, want 1", asked) } } // A half-filled configuration is refused here rather than at the first 401, // because a 401 names nothing an operator can act on — and before the client is // built at all, because building one dials. 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) } } } // An instance nobody can reach is named as one. The handshake is the first // request of every run, so this is the failure an operator meets when the URL // is wrong or the tracker is down, and it has to say which instance. func TestNewSaysWhichInstanceItCouldNotReach(t *testing.T) { newProject(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) srv.Close() _, err := gitea.New(&config.Resolved{URL: srv.URL, Token: "t", Owner: "a", Repo: "b"}) if err == nil { t.Fatal("a client was built against a tracker that is not there") } if !strings.Contains(err.Error(), srv.URL) { t.Errorf("the error does not name the instance: %v", err) } } // A version nobody can parse is refused at the handshake, not carried. // // The SDK hands back a client that has silently decided the server is 1.11 and // then rewrites issue URLs from a field a modern payload need not carry, which // is a nil dereference on the first issue read. A refusal that names the // instance is the answer an operator can act on. func TestNewRefusesAnInstanceWhoseVersionIsNotOne(t *testing.T) { newProject(t) srv := serve(t, "not-a-version", func(w http.ResponseWriter, r *http.Request) { t.Errorf("a call went out to %s after the handshake had already failed", r.URL.Path) }) _, err := gitea.New(&config.Resolved{URL: srv.URL, Token: "t", Owner: "a", Repo: "b"}) if err == nil { t.Fatal("an unreadable version was accepted — the first issue read would panic inside the SDK") } if !strings.Contains(err.Error(), srv.URL) || !strings.Contains(err.Error(), config.EnvURL) { t.Errorf("the refusal names neither the instance nor the setting that points at it: %v", 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 vocabulary both of them share, the SDK's payloads, exists precisely // so that neither has to reach for the other to name one. 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) } } }