package gitea_test import ( "context" "encoding/json" "errors" "strconv" "strings" "testing" "time" "git.knownelement.com/ukrrs/mopac-gitea-go/gitea" "git.knownelement.com/ukrrs/mopac-gitea-go/internal/fakegitea" ) const fakeToken = "fake-gitea-token-0123456789" func newClient(t *testing.T) (*gitea.Client, *fakegitea.Server) { t.Helper() srv := fakegitea.New(fakeToken, "ukrrs") t.Cleanup(srv.Close) c := gitea.New(gitea.Config{BaseURL: srv.URL, Token: fakeToken, Timeout: 5 * time.Second}) return c, srv } // --- transport core: auth, error mapping, redaction -------------------------- func TestGetRepoRoundTrip(t *testing.T) { c, srv := newClient(t) srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota") repo, err := c.GetRepo(context.Background(), "ukrrs", "MOPAC") if err != nil { t.Fatalf("GetRepo: %v", err) } if repo.FullName != "ukrrs/MOPAC" || repo.DefaultBranch != "main" || repo.Owner.Login != "ukrrs" { t.Fatalf("repo = %+v", repo) } reqs := srv.Requests() if len(reqs) != 1 || reqs[0].Method != "GET" || reqs[0].Path != "/api/v1/repos/ukrrs/MOPAC" { t.Fatalf("requests = %+v", reqs) } // The token lives in the Authorization header only: never in the // path, query, or body the server recorded. for _, r := range reqs { if strings.Contains(r.Path+r.Query+r.Body, fakeToken) { t.Fatalf("token leaked into request: %+v", r) } } } func TestErrorMapping(t *testing.T) { tests := []struct { status int body string wantSent error }{ {401, `{"message":"bad credentials: token %s"}`, gitea.ErrAuth}, {403, `{"message":"forbidden for token %s"}`, gitea.ErrAuth}, {404, `{"message":"The target couldn't be found. Echo %s"}`, gitea.ErrNotFound}, {409, `{"message":"conflict, token %s"}`, gitea.ErrValidation}, {422, `{"message":"validation failed for %s"}`, gitea.ErrValidation}, {500, `{"message":"server exploded with token %s"}`, gitea.ErrServer}, {503, `{"message":"down for %s"}`, gitea.ErrServer}, } for _, tt := range tests { t.Run("http_"+strconv.Itoa(tt.status), func(t *testing.T) { c, srv := newClient(t) srv.Fail = &fakegitea.FailSpec{Status: tt.status, Body: tt.body} _, err := c.GetRepo(context.Background(), "ukrrs", "MOPAC") if err == nil { t.Fatalf("want error for http %d", tt.status) } if !errors.Is(err, tt.wantSent) { t.Fatalf("err = %v, want %v", err, tt.wantSent) } if !strings.Contains(err.Error(), "http "+strconv.Itoa(tt.status)) { t.Fatalf("err = %q, want http code in message", err) } }) } } func TestTokenNeverLeaks(t *testing.T) { c, srv := newClient(t) srv.Fail = &fakegitea.FailSpec{Status: 401, Body: `{"message":"token %s was rejected"}`} _, err := c.GetRepo(context.Background(), "ukrrs", "MOPAC") if err == nil || !errors.Is(err, gitea.ErrAuth) { t.Fatalf("err = %v, want auth failure", err) } if strings.Contains(err.Error(), fakeToken) { t.Fatalf("error leaks the token: %q", err) } if strings.Contains(err.Error(), "was rejected") { t.Fatalf("error leaks response body: %q", err) } } func TestUnreachable(t *testing.T) { c, srv := newClient(t) srv.Close() // port gone; every call must map to ErrUnreachable, not panic _, err := c.GetRepo(context.Background(), "ukrrs", "MOPAC") if !errors.Is(err, gitea.ErrUnreachable) { t.Fatalf("err = %v, want ErrUnreachable", err) } } func TestMalformedResponse(t *testing.T) { c, srv := newClient(t) srv.Fail = &fakegitea.FailSpec{Status: 200, Body: `not json`} _, err := c.GetRepo(context.Background(), "ukrrs", "MOPAC") if !errors.Is(err, gitea.ErrMalformedResponse) { t.Fatalf("err = %v, want ErrMalformedResponse", err) } } func TestBadTokenRejected(t *testing.T) { srv := fakegitea.New(fakeToken, "ukrrs") t.Cleanup(srv.Close) c := gitea.New(gitea.Config{BaseURL: srv.URL, Token: "wrong-token-abcdef"}) _, err := c.GetRepo(context.Background(), "ukrrs", "MOPAC") if !errors.Is(err, gitea.ErrAuth) { t.Fatalf("err = %v, want ErrAuth", err) } if strings.Contains(err.Error(), "wrong-token-abcdef") { t.Fatalf("presented token echoed in error: %q", err) } } // --- repos ------------------------------------------------------------------ func TestCreateRepoRoundTrip(t *testing.T) { c, srv := newClient(t) repo, err := c.CreateRepo(context.Background(), gitea.RepoParams{ Owner: "", Name: "vertical-x", Description: "one vertical", Private: true, AutoInit: true, DefaultBranch: "main", }) if err != nil { t.Fatalf("CreateRepo: %v", err) } if repo.FullName != "ukrrs/vertical-x" || !repo.Private || repo.DefaultBranch != "main" { t.Fatalf("repo = %+v", repo) } // POST /user/repos carries the write shape (scalars only). var post map[string]any if err := json.Unmarshal([]byte(srv.Requests()[0].Body), &post); err != nil { t.Fatalf("POST body not json: %v", err) } for _, k := range []string{"name", "description", "private", "auto_init", "default_branch"} { if _, ok := post[k]; !ok { t.Errorf("POST body missing %q: %v", k, post) } } if post["name"] != "vertical-x" || post["private"] != true { t.Errorf("POST body = %v", post) } } func TestCreateRepoInOrg(t *testing.T) { c, srv := newClient(t) repo, err := c.CreateRepo(context.Background(), gitea.RepoParams{Owner: "ukrrs-labs", Name: "org-repo"}) if err != nil { t.Fatalf("CreateRepo: %v", err) } if repo.Owner.Login != "ukrrs-labs" || repo.FullName != "ukrrs-labs/org-repo" { t.Fatalf("repo = %+v", repo) } if reqs := srv.Requests(); reqs[0].Path != "/api/v1/orgs/ukrrs-labs/repos" { t.Fatalf("path = %s, want orgs endpoint", reqs[0].Path) } } func TestCreateRepoDuplicateConflict(t *testing.T) { c, _ := newClient(t) if _, err := c.CreateRepo(context.Background(), gitea.RepoParams{Name: "dup", AutoInit: true}); err != nil { t.Fatalf("first create: %v", err) } _, err := c.CreateRepo(context.Background(), gitea.RepoParams{Name: "dup"}) if !errors.Is(err, gitea.ErrValidation) { // 409 -> validation sentinel t.Fatalf("err = %v, want ErrValidation (409)", err) } } func TestListRepos(t *testing.T) { c, srv := newClient(t) srv.AddRepo("ukrrs", "alpha", "main", "main") srv.AddRepo("ukrrs", "beta", "main", "main") srv.AddRepo("other", "gamma", "main", "main") tests := []struct { name string owner string want []string path string }{ {name: "self", owner: "", want: []string{"ukrrs/alpha", "ukrrs/beta"}, path: "/api/v1/user/repos"}, {name: "other user", owner: "other", want: []string{"other/gamma"}, path: "/api/v1/users/other/repos"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { repos, err := c.ListRepos(context.Background(), tt.owner) if err != nil { t.Fatalf("ListRepos: %v", err) } var got []string for _, r := range repos { got = append(got, r.FullName) } if strings.Join(got, ",") != strings.Join(tt.want, ",") { t.Fatalf("got %v, want %v", got, tt.want) } if reqs := srv.Requests(); reqs[len(reqs)-1].Path != tt.path { t.Fatalf("path = %s, want %s", reqs[len(reqs)-1].Path, tt.path) } }) } } func TestGetRepoMissing(t *testing.T) { c, _ := newClient(t) _, err := c.GetRepo(context.Background(), "ukrrs", "absent") if !errors.Is(err, gitea.ErrNotFound) { t.Fatalf("err = %v, want ErrNotFound", err) } } // --- branches --------------------------------------------------------------- func TestListBranches(t *testing.T) { c, srv := newClient(t) srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota", "fix/lint") branches, err := c.ListBranches(context.Background(), "ukrrs", "MOPAC") if err != nil { t.Fatalf("ListBranches: %v", err) } if len(branches) != 3 { t.Fatalf("got %d branches, want 3: %+v", len(branches), branches) } if branches[0].Name != "main" || branches[2].Name != "fix/lint" { t.Fatalf("order = %+v", branches) } for _, b := range branches { if len(b.Commit.ID) != 40 { t.Errorf("branch %s tip sha = %q, want 40-hex", b.Name, b.Commit.ID) } } if reqs := srv.Requests(); reqs[0].Path != "/api/v1/repos/ukrrs/MOPAC/branches" { t.Fatalf("path = %s", reqs[0].Path) } } func TestListBranchesMissingRepo(t *testing.T) { c, _ := newClient(t) _, err := c.ListBranches(context.Background(), "ukrrs", "absent") if !errors.Is(err, gitea.ErrNotFound) { t.Fatalf("err = %v, want ErrNotFound", err) } } // --- commit statuses ---------------------------------------------------------- func TestCommitStatusFlow(t *testing.T) { c, srv := newClient(t) srv.AddRepo("ukrrs", "MOPAC", "main", "main") sha := srv.AddBranch("ukrrs", "MOPAC", "feat/quota") st, err := c.CreateCommitStatus(context.Background(), "ukrrs", "MOPAC", sha, gitea.CommitStatusParams{ State: gitea.StatusPending, Context: "ci/lint", Description: "lint queued", TargetURL: "https://ci.example/run/1", }) if err != nil { t.Fatalf("CreateCommitStatus: %v", err) } if st.ID == 0 || st.State != "pending" || st.Context != "ci/lint" { t.Fatalf("status = %+v", st) } if reqs := srv.Requests(); reqs[0].Path != "/api/v1/repos/ukrrs/MOPAC/statuses/"+sha { t.Fatalf("path = %s", reqs[0].Path) } // Same context flips to success; a second context reports failure. if _, err := c.CreateCommitStatus(context.Background(), "ukrrs", "MOPAC", sha, gitea.CommitStatusParams{ State: gitea.StatusSuccess, Context: "ci/lint", }); err != nil { t.Fatalf("CreateCommitStatus flip: %v", err) } if _, err := c.CreateCommitStatus(context.Background(), "ukrrs", "MOPAC", sha, gitea.CommitStatusParams{ State: gitea.StatusFailure, Context: "ci/test", }); err != nil { t.Fatalf("CreateCommitStatus second context: %v", err) } // Listing by branch NAME resolves to the tip sha and folds to the // latest status per context. listed, err := c.ListCommitStatuses(context.Background(), "ukrrs", "MOPAC", "feat/quota") if err != nil { t.Fatalf("ListCommitStatuses: %v", err) } if len(listed) != 2 { t.Fatalf("got %d statuses, want 2 (latest per context): %+v", len(listed), listed) } byCtx := map[string]string{} for _, s := range listed { byCtx[s.Context] = s.State } if byCtx["ci/lint"] != "success" || byCtx["ci/test"] != "failure" { t.Fatalf("folded = %v", byCtx) } // Combined status is the worst of the latest per context. comb, err := c.GetCombinedStatus(context.Background(), "ukrrs", "MOPAC", "feat/quota") if err != nil { t.Fatalf("GetCombinedStatus: %v", err) } if comb.State != "failure" || comb.SHA != sha || comb.TotalCount != 2 { t.Fatalf("combined = %+v", comb) } // Short sha (>= 7 chars) resolves too. short := sha[:7] if _, err := c.GetCombinedStatus(context.Background(), "ukrrs", "MOPAC", short); err != nil { t.Fatalf("short sha: %v", err) } } func TestCommitStatusErrors(t *testing.T) { c, srv := newClient(t) srv.AddRepo("ukrrs", "MOPAC", "main", "main") sha := srv.AddBranch("ukrrs", "MOPAC", "feat/quota") tests := []struct { name string run func() error want error }{ { name: "unknown sha", run: func() error { _, err := c.CreateCommitStatus(context.Background(), "ukrrs", "MOPAC", strings.Repeat("d", 40), gitea.CommitStatusParams{State: gitea.StatusSuccess}) return err }, want: gitea.ErrNotFound, }, { name: "invalid state", run: func() error { _, err := c.CreateCommitStatus(context.Background(), "ukrrs", "MOPAC", sha, gitea.CommitStatusParams{State: "green"}) return err }, want: gitea.ErrValidation, }, { name: "combined with no statuses", run: func() error { _, err := c.GetCombinedStatus(context.Background(), "ukrrs", "MOPAC", "main") return err }, want: gitea.ErrNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := tt.run(); !errors.Is(err, tt.want) { t.Fatalf("err = %v, want %v", err, tt.want) } }) } } // --- pull requests ------------------------------------------------------------ func TestPullRequestFlow(t *testing.T) { c, srv := newClient(t) srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota") pr, err := c.CreatePullRequest(context.Background(), "ukrrs", "MOPAC", gitea.PRParams{ Title: "Quota accounting", Body: "## Scope\n- usage accounting", Base: "main", Head: "feat/quota", }) if err != nil { t.Fatalf("CreatePullRequest: %v", err) } if pr.Number != 1 || pr.State != "open" || !pr.Mergeable || pr.Merged { t.Fatalf("pr = %+v", pr) } if pr.Base.Ref != "main" || pr.Head.Ref != "feat/quota" || pr.Head.SHA == "" { t.Fatalf("pr ends = %+v %+v", pr.Base, pr.Head) } var post map[string]any if err := json.Unmarshal([]byte(srv.Requests()[0].Body), &post); err != nil { t.Fatalf("POST body not json: %v", err) } for _, k := range []string{"title", "body", "base", "head"} { if _, ok := post[k]; !ok { t.Errorf("POST body missing %q: %v", k, post) } } // Green the head, then merge. if _, err := c.CreateCommitStatus(context.Background(), "ukrrs", "MOPAC", pr.Head.SHA, gitea.CommitStatusParams{ State: gitea.StatusSuccess, Context: "ci/lint", }); err != nil { t.Fatalf("status on head: %v", err) } if err := c.MergePullRequest(context.Background(), "ukrrs", "MOPAC", pr.Number, gitea.MergeParams{Do: gitea.MergeSquash}); err != nil { t.Fatalf("MergePullRequest: %v", err) } stored, ok := srv.PullRequest("ukrrs", "MOPAC", pr.Number) if !ok || !stored.Merged || stored.State != "closed" { t.Fatalf("stored pr after merge = %+v ok=%v", stored, ok) } // State filters. open, err := c.ListPullRequests(context.Background(), "ukrrs", "MOPAC", gitea.PRStateOpen) if err != nil { t.Fatalf("ListPullRequests open: %v", err) } if len(open) != 0 { t.Fatalf("open after merge = %+v", open) } closed, err := c.ListPullRequests(context.Background(), "ukrrs", "MOPAC", gitea.PRStateClosed) if err != nil { t.Fatalf("ListPullRequests closed: %v", err) } if len(closed) != 1 || closed[0].Number != pr.Number { t.Fatalf("closed = %+v", closed) } all, err := c.ListPullRequests(context.Background(), "ukrrs", "MOPAC", gitea.PRStateAll) if err != nil || len(all) != 1 { t.Fatalf("all = %+v err=%v", all, err) } // Merging again is a 409 -> validation sentinel. if err := c.MergePullRequest(context.Background(), "ukrrs", "MOPAC", pr.Number, gitea.MergeParams{}); !errors.Is(err, gitea.ErrValidation) { t.Fatalf("re-merge err = %v, want ErrValidation", err) } } func TestGetPullRequest(t *testing.T) { c, srv := newClient(t) srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota") created, err := c.CreatePullRequest(context.Background(), "ukrrs", "MOPAC", gitea.PRParams{Title: "t", Base: "main", Head: "feat/quota"}) if err != nil { t.Fatalf("create: %v", err) } got, err := c.GetPullRequest(context.Background(), "ukrrs", "MOPAC", created.Number) if err != nil { t.Fatalf("GetPullRequest: %v", err) } if got.Title != "t" || got.Number != created.Number { t.Fatalf("got = %+v", got) } if _, err := c.GetPullRequest(context.Background(), "ukrrs", "MOPAC", 999); !errors.Is(err, gitea.ErrNotFound) { t.Fatalf("err = %v, want ErrNotFound", err) } } func TestCreatePullRequestErrors(t *testing.T) { c, srv := newClient(t) srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota") base := gitea.PRParams{Title: "t", Base: "main", Head: "feat/quota"} tests := []struct { name string p gitea.PRParams want error }{ {"missing title", gitea.PRParams{Base: "main", Head: "feat/quota"}, gitea.ErrValidation}, {"missing base branch", gitea.PRParams{Title: "t", Base: "nope", Head: "feat/quota"}, gitea.ErrValidation}, {"missing head branch", gitea.PRParams{Title: "t", Base: "main", Head: "nope"}, gitea.ErrValidation}, {"head equals base", gitea.PRParams{Title: "t", Base: "main", Head: "main"}, gitea.ErrValidation}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if _, err := c.CreatePullRequest(context.Background(), "ukrrs", "MOPAC", tt.p); !errors.Is(err, tt.want) { t.Fatalf("err = %v, want %v", err, tt.want) } }) } // Duplicate open PR -> 409. if _, err := c.CreatePullRequest(context.Background(), "ukrrs", "MOPAC", base); err != nil { t.Fatalf("first create: %v", err) } if _, err := c.CreatePullRequest(context.Background(), "ukrrs", "MOPAC", base); !errors.Is(err, gitea.ErrValidation) { t.Fatalf("duplicate err = %v, want ErrValidation (409)", err) } // Unknown repo -> 404. if _, err := c.CreatePullRequest(context.Background(), "ukrrs", "absent", base); !errors.Is(err, gitea.ErrNotFound) { t.Fatalf("err = %v, want ErrNotFound", err) } } func TestMergeStyles(t *testing.T) { for _, style := range []string{gitea.MergeMerge, gitea.MergeRebase, gitea.MergeRebaseMerge, gitea.MergeSquash, gitea.MergeFastForward, ""} { t.Run("style_"+style, func(t *testing.T) { c, srv := newClient(t) srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/x") pr, err := c.CreatePullRequest(context.Background(), "ukrrs", "MOPAC", gitea.PRParams{Title: "t", Base: "main", Head: "feat/x"}) if err != nil { t.Fatalf("create: %v", err) } if err := c.MergePullRequest(context.Background(), "ukrrs", "MOPAC", pr.Number, gitea.MergeParams{Do: style}); err != nil { t.Fatalf("merge %q: %v", style, err) } }) } // Invalid style -> 422. c, srv := newClient(t) srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/x") pr, _ := c.CreatePullRequest(context.Background(), "ukrrs", "MOPAC", gitea.PRParams{Title: "t", Base: "main", Head: "feat/x"}) if err := c.MergePullRequest(context.Background(), "ukrrs", "MOPAC", pr.Number, gitea.MergeParams{Do: "blend"}); !errors.Is(err, gitea.ErrValidation) { t.Fatalf("err = %v, want ErrValidation", err) } }