449 lines
13 KiB
Go
449 lines
13 KiB
Go
// Package forgetest runs an in-memory Gitea or GitHub REST server for tests, the way [httptest] does for plain handlers.
|
|
//
|
|
// It serves only the endpoints spx calls, for the single repo [Owner]/[Repo], and mimics each forge's quirks that
|
|
// matter to spx: Gitea lives under `/api/v1` and returns every issue comment at once, while GitHub pages comments
|
|
// with `per_page` and a `Link` header.
|
|
package forgetest
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// Flavor picks which forge's REST dialect the fake speaks.
|
|
type Flavor int
|
|
|
|
const (
|
|
Gitea Flavor = iota
|
|
GitHub
|
|
)
|
|
|
|
// The only repo the fake serves; requests for any other repo get 404.
|
|
const (
|
|
Owner = "acme"
|
|
Repo = "widgets"
|
|
)
|
|
|
|
// Issue is an issue stored in the fake.
|
|
type Issue struct {
|
|
Number int
|
|
Title string
|
|
Body string
|
|
State string
|
|
Author string
|
|
Assignees []string
|
|
}
|
|
|
|
// Comment is an issue or pull request comment stored in the fake.
|
|
type Comment struct {
|
|
ID int64
|
|
Author string
|
|
Body string
|
|
CreatedAt string
|
|
}
|
|
|
|
// Pull is a pull request stored in the fake; it shares numbering with [Issue] as on both real forges.
|
|
type Pull struct {
|
|
Number int
|
|
Title string
|
|
Body string
|
|
State string
|
|
Merged bool
|
|
Author string
|
|
HeadBranch string
|
|
HeadSHA string
|
|
BaseBranch string
|
|
BaseSHA string
|
|
// HeadDeleted marks a head branch deleted after merging, which changes what Gitea reports as its `ref`.
|
|
HeadDeleted bool
|
|
}
|
|
|
|
// Server is a running fake forge; read its `URL` to point a client at it.
|
|
type Server struct {
|
|
*httptest.Server
|
|
Flavor Flavor
|
|
Token string
|
|
Login string
|
|
DefaultBranch string
|
|
|
|
mu sync.Mutex
|
|
issues map[int]*Issue
|
|
pulls map[int]*Pull
|
|
comments map[int][]Comment
|
|
nextNumber int
|
|
nextCommentID int64
|
|
requests []string
|
|
}
|
|
|
|
// NewServer starts a fake forge of the given flavor that is shut down when the test ends.
|
|
func NewServer(t testing.TB, flavor Flavor) *Server {
|
|
s := &Server{
|
|
Flavor: flavor,
|
|
Token: "test-token",
|
|
Login: "alice",
|
|
DefaultBranch: "main",
|
|
issues: map[int]*Issue{},
|
|
pulls: map[int]*Pull{},
|
|
comments: map[int][]Comment{},
|
|
nextNumber: 1,
|
|
nextCommentID: 1,
|
|
}
|
|
s.Server = httptest.NewServer(http.HandlerFunc(s.serve))
|
|
t.Cleanup(s.Close)
|
|
return s
|
|
}
|
|
|
|
// AddIssue stores an issue under the next free number and returns that number; an empty `State` means `open`.
|
|
func (s *Server) AddIssue(is Issue) int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.addIssueLocked(is)
|
|
}
|
|
|
|
// AddPull stores a pull request under the next free number and returns that number; an empty `State` means `open`.
|
|
func (s *Server) AddPull(p Pull) int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.addPullLocked(p)
|
|
}
|
|
|
|
// AddComment appends a comment to issue or pull request `number`, as if `author` had posted it last.
|
|
func (s *Server) AddComment(number int, author, body string) Comment {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.addCommentLocked(number, author, body)
|
|
}
|
|
|
|
// Comments returns a copy of the comments on issue or pull request `number`, oldest first.
|
|
func (s *Server) Comments(number int) []Comment {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return append([]Comment(nil), s.comments[number]...)
|
|
}
|
|
|
|
// Issue returns a copy of issue `number` and whether it exists.
|
|
func (s *Server) Issue(number int) (Issue, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
is, ok := s.issues[number]
|
|
if !ok {
|
|
return Issue{}, false
|
|
}
|
|
return *is, true
|
|
}
|
|
|
|
// Pull returns a copy of pull request `number` and whether it exists.
|
|
func (s *Server) Pull(number int) (Pull, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
p, ok := s.pulls[number]
|
|
if !ok {
|
|
return Pull{}, false
|
|
}
|
|
return *p, true
|
|
}
|
|
|
|
// Requests returns every request served so far as `METHOD /path?query`, so tests can check which endpoints were hit.
|
|
func (s *Server) Requests() []string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return append([]string(nil), s.requests...)
|
|
}
|
|
|
|
func (s *Server) addIssueLocked(is Issue) int {
|
|
is.Number = s.nextNumber
|
|
s.nextNumber++
|
|
if is.State == "" {
|
|
is.State = "open"
|
|
}
|
|
s.issues[is.Number] = &is
|
|
return is.Number
|
|
}
|
|
|
|
func (s *Server) addPullLocked(p Pull) int {
|
|
p.Number = s.nextNumber
|
|
s.nextNumber++
|
|
if p.State == "" {
|
|
p.State = "open"
|
|
}
|
|
s.pulls[p.Number] = &p
|
|
return p.Number
|
|
}
|
|
|
|
func (s *Server) addCommentLocked(number int, author, body string) Comment {
|
|
c := Comment{
|
|
ID: s.nextCommentID,
|
|
Author: author,
|
|
Body: body,
|
|
CreatedAt: fmt.Sprintf("2026-10-19T10:%02d:%02dZ", (s.nextCommentID/60)%60, s.nextCommentID%60),
|
|
}
|
|
s.nextCommentID++
|
|
s.comments[number] = append(s.comments[number], c)
|
|
return c
|
|
}
|
|
|
|
// apiPrefix is the path every API route sits under: Gitea mounts its API at `/api/v1`, GitHub at the root.
|
|
func (s *Server) apiPrefix() string {
|
|
if s.Flavor == Gitea {
|
|
return "/api/v1"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// authorized checks the headers each real forge expects, so a client that sends the wrong scheme fails here too.
|
|
func (s *Server) authorized(r *http.Request) bool {
|
|
if s.Flavor == Gitea {
|
|
return r.Header.Get("Authorization") == "token "+s.Token
|
|
}
|
|
return r.Header.Get("Authorization") == "Bearer "+s.Token &&
|
|
r.Header.Get("Accept") == "application/vnd.github+json" &&
|
|
r.Header.Get("X-GitHub-Api-Version") == "2022-11-28"
|
|
}
|
|
|
|
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.requests = append(s.requests, r.Method+" "+r.URL.RequestURI())
|
|
if !s.authorized(r) {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]any{"message": "Bad credentials"})
|
|
return
|
|
}
|
|
prefix := s.apiPrefix()
|
|
if !strings.HasPrefix(r.URL.Path, prefix+"/") {
|
|
notFound(w)
|
|
return
|
|
}
|
|
path := strings.TrimPrefix(r.URL.Path, prefix)
|
|
if path == "/user" && r.Method == http.MethodGet {
|
|
writeJSON(w, http.StatusOK, map[string]any{"login": s.Login})
|
|
return
|
|
}
|
|
repoPath := "/repos/" + Owner + "/" + Repo
|
|
if path != repoPath && !strings.HasPrefix(path, repoPath+"/") {
|
|
notFound(w)
|
|
return
|
|
}
|
|
rest := strings.TrimPrefix(path, repoPath)
|
|
segs := strings.Split(strings.Trim(rest, "/"), "/")
|
|
switch {
|
|
case rest == "" && r.Method == http.MethodGet:
|
|
writeJSON(w, http.StatusOK, map[string]any{"full_name": Owner + "/" + Repo, "default_branch": s.DefaultBranch})
|
|
case rest == "/issues" && r.Method == http.MethodPost:
|
|
s.createIssue(w, r)
|
|
case rest == "/pulls" && r.Method == http.MethodPost:
|
|
s.createPull(w, r)
|
|
case len(segs) == 2 && segs[0] == "issues":
|
|
s.serveIssue(w, r, atoi(segs[1]))
|
|
case len(segs) == 3 && segs[0] == "issues" && segs[2] == "comments":
|
|
s.serveComments(w, r, atoi(segs[1]))
|
|
case len(segs) == 2 && segs[0] == "pulls" && r.Method == http.MethodGet:
|
|
p, ok := s.pulls[atoi(segs[1])]
|
|
if !ok {
|
|
notFound(w)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, s.pullJSON(p))
|
|
default:
|
|
notFound(w)
|
|
}
|
|
}
|
|
|
|
func (s *Server) createIssue(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
Assignees []string `json:"assignees"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Title == "" {
|
|
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": "title is required"})
|
|
return
|
|
}
|
|
n := s.addIssueLocked(Issue{Title: in.Title, Body: in.Body, Author: s.Login, Assignees: in.Assignees})
|
|
writeJSON(w, http.StatusCreated, s.issueJSON(s.issues[n]))
|
|
}
|
|
|
|
func (s *Server) createPull(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
Head string `json:"head"`
|
|
Base string `json:"base"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Title == "" || in.Head == "" || in.Base == "" {
|
|
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": "title, head and base are required"})
|
|
return
|
|
}
|
|
n := s.addPullLocked(Pull{
|
|
Title: in.Title, Body: in.Body, Author: s.Login,
|
|
HeadBranch: in.Head, HeadSHA: "head-sha-" + in.Head,
|
|
BaseBranch: in.Base, BaseSHA: "base-sha-" + in.Base,
|
|
})
|
|
writeJSON(w, http.StatusCreated, s.pullJSON(s.pulls[n]))
|
|
}
|
|
|
|
// serveIssue answers `GET` and `PATCH` on one issue; a pull request number is answered as an issue, as both forges do.
|
|
func (s *Server) serveIssue(w http.ResponseWriter, r *http.Request, n int) {
|
|
if p, ok := s.pulls[n]; ok && r.Method == http.MethodGet {
|
|
writeJSON(w, http.StatusOK, s.issueJSON(&Issue{Number: p.Number, Title: p.Title, Body: p.Body, State: p.State, Author: p.Author}))
|
|
return
|
|
}
|
|
is, ok := s.issues[n]
|
|
if !ok {
|
|
notFound(w)
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
writeJSON(w, http.StatusOK, s.issueJSON(is))
|
|
case http.MethodPatch:
|
|
var in struct {
|
|
Body *string `json:"body"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": err.Error()})
|
|
return
|
|
}
|
|
if in.Body != nil {
|
|
is.Body = *in.Body
|
|
}
|
|
writeJSON(w, http.StatusOK, s.issueJSON(is))
|
|
default:
|
|
notFound(w)
|
|
}
|
|
}
|
|
|
|
func (s *Server) serveComments(w http.ResponseWriter, r *http.Request, n int) {
|
|
if _, isIssue := s.issues[n]; !isIssue {
|
|
if _, isPull := s.pulls[n]; !isPull {
|
|
notFound(w)
|
|
return
|
|
}
|
|
}
|
|
switch r.Method {
|
|
case http.MethodPost:
|
|
var in struct {
|
|
Body string `json:"body"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": err.Error()})
|
|
return
|
|
}
|
|
c := s.addCommentLocked(n, s.Login, in.Body)
|
|
writeJSON(w, http.StatusCreated, s.commentJSON(n, c))
|
|
case http.MethodGet:
|
|
all := s.comments[n]
|
|
if s.Flavor == GitHub {
|
|
all = s.paginate(w, r, all)
|
|
}
|
|
out := make([]map[string]any, 0, len(all))
|
|
for _, c := range all {
|
|
out = append(out, s.commentJSON(n, c))
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
default:
|
|
notFound(w)
|
|
}
|
|
}
|
|
|
|
// paginate cuts out the page GitHub would return for `per_page` and `page` and sets the `Link` header to the next one.
|
|
func (s *Server) paginate(w http.ResponseWriter, r *http.Request, all []Comment) []Comment {
|
|
perPage := 30
|
|
if v, err := strconv.Atoi(r.URL.Query().Get("per_page")); err == nil && v > 0 {
|
|
perPage = min(v, 100)
|
|
}
|
|
page := 1
|
|
if v, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && v > 0 {
|
|
page = v
|
|
}
|
|
start := min((page-1)*perPage, len(all))
|
|
end := min(start+perPage, len(all))
|
|
if end < len(all) {
|
|
last := (len(all) + perPage - 1) / perPage
|
|
base := "http://" + r.Host + r.URL.Path
|
|
w.Header().Set("Link", fmt.Sprintf(`<%s?per_page=%d&page=%d>; rel="next", <%s?per_page=%d&page=%d>; rel="last"`,
|
|
base, perPage, page+1, base, perPage, last))
|
|
}
|
|
return all[start:end]
|
|
}
|
|
|
|
func (s *Server) issueJSON(is *Issue) map[string]any {
|
|
var assignees any
|
|
if len(is.Assignees) > 0 || s.Flavor == GitHub {
|
|
list := make([]map[string]any, 0, len(is.Assignees))
|
|
for _, a := range is.Assignees {
|
|
list = append(list, map[string]any{"login": a})
|
|
}
|
|
assignees = list
|
|
}
|
|
return map[string]any{
|
|
"number": is.Number,
|
|
"title": is.Title,
|
|
"body": is.Body,
|
|
"state": is.State,
|
|
"html_url": fmt.Sprintf("%s/%s/%s/issues/%d", s.URL, Owner, Repo, is.Number),
|
|
"user": map[string]any{"login": is.Author},
|
|
"assignees": assignees,
|
|
}
|
|
}
|
|
|
|
// pullJSON renders a pull request the way each forge does; the two disagree on the head branch fields:
|
|
// GitHub's `label` is `owner:branch` and `ref` the branch, while Gitea's `label` is the branch and `ref` turns into
|
|
// `refs/pull/<n>/head` once the branch is gone.
|
|
func (s *Server) pullJSON(p *Pull) map[string]any {
|
|
segment := "pulls"
|
|
head := map[string]any{"label": p.HeadBranch, "ref": p.HeadBranch, "sha": p.HeadSHA}
|
|
base := map[string]any{"label": p.BaseBranch, "ref": p.BaseBranch, "sha": p.BaseSHA}
|
|
if s.Flavor == GitHub {
|
|
segment = "pull"
|
|
head["label"] = Owner + ":" + p.HeadBranch
|
|
base["label"] = Owner + ":" + p.BaseBranch
|
|
} else if p.HeadDeleted {
|
|
head["ref"] = fmt.Sprintf("refs/pull/%d/head", p.Number)
|
|
}
|
|
return map[string]any{
|
|
"number": p.Number,
|
|
"title": p.Title,
|
|
"body": p.Body,
|
|
"state": p.State,
|
|
"merged": p.Merged,
|
|
"html_url": fmt.Sprintf("%s/%s/%s/%s/%d", s.URL, Owner, Repo, segment, p.Number),
|
|
"user": map[string]any{"login": p.Author},
|
|
"head": head,
|
|
"base": base,
|
|
}
|
|
}
|
|
|
|
func (s *Server) commentJSON(n int, c Comment) map[string]any {
|
|
return map[string]any{
|
|
"id": c.ID,
|
|
"body": c.Body,
|
|
"user": map[string]any{"login": c.Author},
|
|
"created_at": c.CreatedAt,
|
|
"html_url": fmt.Sprintf("%s/%s/%s/issues/%d#issuecomment-%d", s.URL, Owner, Repo, n, c.ID),
|
|
}
|
|
}
|
|
|
|
func atoi(s string) int {
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return -1
|
|
}
|
|
return n
|
|
}
|
|
|
|
func notFound(w http.ResponseWriter) {
|
|
writeJSON(w, http.StatusNotFound, map[string]any{"message": "Not Found"})
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|