Files
mopac-redmine-go/internal/fakeredmine/fakeredmine.go
T
mrcharles 26824c3715 feat(redmine): partial UpdateVersion and GetVersion with fake endpoints
UpdateVersion issues PUT /versions/N.json sending only the fields the
caller set (untouched attributes never travel), and never parses the
empty 204 body Redmine answers with. GetVersion backs re-fetching one
milestone. The fake serves both endpoints with Redmine's semantics
(partial apply, 204 empty, 404 for unknown ids), and table tests pin
the exact request bodies, partiality, and error mapping.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-29 08:12:16 -05:00

657 lines
19 KiB
Go

// Package fakeredmine is a stateful in-memory Redmine REST fake used by
// the test suite and the smoke run. The real tracker is NEVER contacted.
// It implements exactly the endpoints the redmine client uses, enforces
// X-Redmine-API-Key auth on every one of them, and records every request
// so tests can assert round-trips. Error responses deliberately echo the
// presented key back in the body: any test that survives that proves the
// client never surfaces response bodies (key-redaction guarantee).
package fakeredmine
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strconv"
"strings"
"sync"
)
// Request is one recorded exchange (auth checked, body captured).
type Request struct {
Method string
Path string // path without query
Query string // raw query
Body string
}
// Version is the roadmap-milestone shape.
type Version struct {
ID int `json:"id"`
Name string `json:"name"`
DueDate string `json:"due_date,omitempty"`
Status string `json:"status"` // open|locked|closed
Sharing string `json:"sharing,omitempty"`
}
// Category is the issue-category shape.
type Category struct {
ID int `json:"id"`
Name string `json:"name"`
}
// JournalEntry is one history note.
type JournalEntry struct {
ID int `json:"id"`
UserID int `json:"user_id"`
Notes string `json:"notes"`
CreatedOn string `json:"created_on"`
}
// Issue is the stored issue (server side, write-friendly scalars).
type Issue struct {
ID int `json:"id"`
Subject string `json:"subject"`
Description string `json:"description"`
ProjectID string `json:"project_id"`
TrackerID int `json:"tracker_id"`
StatusID int `json:"status_id"`
PriorityID int `json:"priority_id"`
CategoryID int `json:"category_id,omitempty"`
FixedVersionID int `json:"fixed_version_id,omitempty"`
ParentIssueID int `json:"parent_issue_id,omitempty"`
EstimatedHours *float64 `json:"estimated_hours,omitempty"`
DoneRatio int `json:"done_ratio,omitempty"`
DueDate string `json:"due_date,omitempty"`
Notes string `json:"notes,omitempty"` // write-only: create/update note -> journal
CreatedOn string `json:"created_on"`
UpdatedOn string `json:"updated_on"`
Journals []JournalEntry `json:"journals,omitempty"`
}
// Relation is the issue-relation shape.
type Relation struct {
ID int `json:"id"`
IssueID int `json:"issue_id"`
IssueToID int `json:"issue_to_id"`
RelationType string `json:"relation_type"`
Delay int `json:"delay,omitempty"`
}
// Server is the fake Redmine.
type Server struct {
URL string
APIKey string
apiKeySet bool
mu sync.Mutex
srv *httptest.Server
requests []Request
issues map[int]*Issue
versions map[int]Version
categories map[int]Category
relations map[int]Relation
nextID int
// Fail, when non-nil, is returned instead of normal handling; it is
// consumed by the first request that sees it.
Fail *FailSpec
Trackers []IDName
Statuses []Status
Priorities []IDName
}
// FailSpec pins the next response (error-mapping tests).
type FailSpec struct {
Status int
Body string
}
// IDName is a bare enumeration entry.
type IDName struct {
ID int `json:"id"`
Name string `json:"name"`
}
// Status adds the is_closed flag to an enumeration entry.
type Status struct {
ID int `json:"id"`
Name string `json:"name"`
IsClosed bool `json:"is_closed"`
}
// New starts a fake on a random port with Redmine's default
// enumerations and an empty MOPAC project.
func New(apiKey string) *Server {
s := newServer(apiKey)
mux := http.NewServeMux()
mux.HandleFunc("/", s.handler)
s.srv = httptest.NewServer(mux)
s.URL = s.srv.URL
return s
}
// ListenAndServe runs the fake on a fixed address (the smoke run boots it
// in a container). An empty apiKey disables auth checking.
func ListenAndServe(addr, apiKey string) error {
s := newServer(apiKey)
mux := http.NewServeMux()
mux.HandleFunc("/", s.handler)
return http.ListenAndServe(addr, mux)
}
func newServer(apiKey string) *Server {
return &Server{
APIKey: apiKey,
apiKeySet: true,
issues: map[int]*Issue{},
versions: map[int]Version{},
categories: map[int]Category{},
relations: map[int]Relation{},
nextID: 100,
Trackers: []IDName{
{1, "Bug"}, {2, "Feature"}, {3, "Support"}, {4, "Task"},
},
Statuses: []Status{
{1, "New", false}, {2, "In Progress", false},
{3, "Done", true}, {4, "Rejected", true},
},
Priorities: []IDName{
{1, "Low"}, {2, "Normal"}, {3, "High"}, {4, "Urgent"}, {5, "Immediate"},
},
}
}
// Close shuts the fake down.
func (s *Server) Close() { s.srv.Close() }
// Requests returns a copy of the recorded exchanges.
func (s *Server) Requests() []Request {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Request, len(s.requests))
copy(out, s.requests)
return out
}
// AddIssue seeds an issue directly (bypassing REST) and returns its id.
func (s *Server) AddIssue(i Issue) int {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
i.ID = s.nextID
now := "2026-08-29T12:00:00Z"
if i.CreatedOn == "" {
i.CreatedOn = now
}
if i.UpdatedOn == "" {
i.UpdatedOn = now
}
s.issues[i.ID] = &i
return i.ID
}
// Issue returns a deep-ish copy of a stored issue (for assertions).
func (s *Server) Issue(id int) (Issue, bool) {
s.mu.Lock()
defer s.mu.Unlock()
i, ok := s.issues[id]
if !ok {
return Issue{}, false
}
return *i, true
}
// AddVersion / AddCategory seed enumerations directly.
func (s *Server) AddVersion(v Version) int {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
v.ID = s.nextID
s.versions[v.ID] = v
return v.ID
}
// Version returns a copy of a stored version (for assertions).
func (s *Server) Version(id int) (Version, bool) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.versions[id]
return v, ok
}
func (s *Server) AddCategory(name string) int {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
s.categories[s.nextID] = Category{ID: s.nextID, Name: name}
return s.nextID
}
func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
key := ""
if s.apiKeySet {
key = r.Header.Get("X-Redmine-API-Key")
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
s.mu.Lock()
if s.Fail != nil {
spec := *s.Fail
s.Fail = nil
s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, string(body)})
s.mu.Unlock()
// The body deliberately embeds the presented key: surviving this
// proves the client drops response bodies from error strings.
w.WriteHeader(spec.Status)
fmt.Fprintf(w, spec.Body, key)
return
}
if s.apiKeySet && key != s.APIKey {
s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, ""})
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"errors":["Invalid HTTP auth: key %s was rejected"]}`, key)
return
}
s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, string(body)})
var (
respStatus = http.StatusOK
respBody string
)
path := r.URL.Path
q := r.URL.Query()
switch {
case r.Method == http.MethodGet && path == "/issues.json":
respStatus, respBody = s.listIssues(q)
case r.Method == http.MethodGet && strings.HasPrefix(path, "/issues/") && strings.HasSuffix(path, ".json"):
id, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(path, "/issues/"), ".json"))
respStatus, respBody = s.getIssue(id, q.Get("include"))
case r.Method == http.MethodPost && path == "/issues.json":
respStatus, respBody = s.createIssue(body)
case r.Method == http.MethodPut && strings.HasPrefix(path, "/issues/") && strings.HasSuffix(path, ".json"):
id, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(path, "/issues/"), ".json"))
respStatus, respBody = s.updateIssue(id, body)
case r.Method == http.MethodPost && strings.HasSuffix(path, "/relations.json"):
from, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(path, "/issues/"), "/relations.json"))
respStatus, respBody = s.createRelation(from, body)
case r.Method == http.MethodGet && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/versions.json"):
respStatus, respBody = s.listVersions()
case r.Method == http.MethodPost && strings.HasSuffix(path, "/versions.json") && strings.HasPrefix(path, "/projects/"):
respStatus, respBody = s.createVersion(body)
case r.Method == http.MethodGet && strings.HasPrefix(path, "/versions/") && strings.HasSuffix(path, ".json"):
id, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(path, "/versions/"), ".json"))
respStatus, respBody = s.getVersion(id)
case r.Method == http.MethodPut && strings.HasPrefix(path, "/versions/") && strings.HasSuffix(path, ".json"):
id, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(path, "/versions/"), ".json"))
respStatus, respBody = s.updateVersion(id, body)
case r.Method == http.MethodGet && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/issue_categories.json"):
respStatus, respBody = s.listCategories()
case r.Method == http.MethodPost && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/issue_categories.json"):
respStatus, respBody = s.createCategory(body)
case r.Method == http.MethodGet && path == "/trackers.json":
respStatus, respBody = jsonReply(map[string]any{"trackers": s.Trackers})
case r.Method == http.MethodGet && path == "/issue_statuses.json":
respStatus, respBody = jsonReply(map[string]any{"issue_statuses": s.Statuses})
case r.Method == http.MethodGet && path == "/enumerations/issue_priorities.json":
respStatus, respBody = jsonReply(map[string]any{"issue_priorities": s.Priorities})
default:
respStatus, respBody = http.StatusNotFound, `{"errors":["Not found"]}`
}
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(respStatus)
if respBody != "" {
io.WriteString(w, respBody)
}
}
func jsonReply(v any) (int, string) {
b, err := json.Marshal(v)
if err != nil {
return http.StatusInternalServerError, `{"errors":["marshal"]}`
}
return http.StatusOK, string(b)
}
func (s *Server) listIssues(q url.Values) (int, string) {
// Redmine defaults to OPEN issues only; status_id=* lifts it.
wantStatus := "open"
if v := q.Get("status_id"); v != "" {
wantStatus = v
}
var proj, ver string
proj = q.Get("project_id")
ver = q.Get("fixed_version_id")
limit := 25
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
limit = n
}
}
type out struct {
Issues []map[string]any `json:"issues"`
Total int `json:"total_count"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
o := out{Limit: limit}
var ids []int
for id, i := range s.issues {
if proj != "" && i.ProjectID != proj {
continue
}
if wantStatus == "open" && s.statusIsClosed(i.StatusID) {
continue
}
if wantStatus == "closed" && !s.statusIsClosed(i.StatusID) {
continue
}
if ver != "" && strconv.Itoa(i.FixedVersionID) != ver {
continue
}
ids = append(ids, id)
}
sort.Ints(ids)
for _, id := range ids {
o.Issues = append(o.Issues, issueView(s, *s.issues[id], false))
}
o.Total = len(o.Issues)
if len(o.Issues) > limit {
o.Issues = o.Issues[:limit]
}
return jsonReply(o)
}
func (s *Server) statusIsClosed(id int) bool {
for _, st := range s.Statuses {
if st.ID == id {
return st.IsClosed
}
}
return false
}
func (s *Server) getIssue(id int, include string) (int, string) {
i, ok := s.issues[id]
if !ok {
return http.StatusNotFound, `{"errors":["Issue not found"]}`
}
out := map[string]any{"issue": issueView(s, *i, include == "journals")}
return jsonReply(out)
}
func (s *Server) createIssue(body []byte) (int, string) {
var p struct {
Issue Issue `json:"issue"`
}
if err := json.Unmarshal(body, &p); err != nil {
return http.StatusBadRequest, `{"errors":["bad json"]}`
}
if p.Issue.Subject == "" || p.Issue.ProjectID == "" {
return http.StatusUnprocessableEntity, `{"errors":["Subject and project cannot be blank"]}`
}
s.nextID++
i := p.Issue
i.ID = s.nextID
i.CreatedOn = "2026-08-29T12:00:00Z"
i.UpdatedOn = i.CreatedOn
if i.Notes != "" { // notes on create become the first journal
i.Journals = append(i.Journals, JournalEntry{ID: 1, UserID: 5, Notes: i.Notes, CreatedOn: i.CreatedOn})
}
s.issues[i.ID] = &i
return http.StatusCreated, `{"issue":` + mustJSON(issueView(s, i, false)) + `}`
}
func (s *Server) updateIssue(id int, body []byte) (int, string) {
i, ok := s.issues[id]
if !ok {
return http.StatusNotFound, `{"errors":["Issue not found"]}`
}
var p struct {
Issue Issue `json:"issue"`
}
if err := json.Unmarshal(body, &p); err != nil {
return http.StatusBadRequest, `{"errors":["bad json"]}`
}
if p.Issue.Subject != "" {
i.Subject = p.Issue.Subject
}
if p.Issue.Description != "" {
i.Description = p.Issue.Description
}
if p.Issue.TrackerID != 0 {
i.TrackerID = p.Issue.TrackerID
}
if p.Issue.StatusID != 0 {
i.StatusID = p.Issue.StatusID
}
if p.Issue.PriorityID != 0 {
i.PriorityID = p.Issue.PriorityID
}
if p.Issue.CategoryID != 0 {
i.CategoryID = p.Issue.CategoryID
}
if p.Issue.FixedVersionID != 0 {
i.FixedVersionID = p.Issue.FixedVersionID
}
if p.Issue.EstimatedHours != nil {
i.EstimatedHours = p.Issue.EstimatedHours
}
if p.Issue.DoneRatio != 0 {
i.DoneRatio = p.Issue.DoneRatio
}
if p.Issue.DueDate != "" {
i.DueDate = p.Issue.DueDate
}
if p.Issue.Notes != "" {
i.Journals = append(i.Journals, JournalEntry{
ID: len(i.Journals) + 1, UserID: 5, Notes: p.Issue.Notes, CreatedOn: "2026-08-29T13:00:00Z",
})
}
i.UpdatedOn = "2026-08-29T13:00:00Z"
return http.StatusNoContent, ""
}
func (s *Server) createRelation(from int, body []byte) (int, string) {
var p struct {
Relation Relation `json:"relation"`
}
if err := json.Unmarshal(body, &p); err != nil {
return http.StatusBadRequest, `{"errors":["bad json"]}`
}
if _, ok := s.issues[from]; !ok {
return http.StatusNotFound, `{"errors":["Issue not found"]}`
}
if _, ok := s.issues[p.Relation.IssueToID]; !ok {
return http.StatusUnprocessableEntity, `{"errors":["Issue not found"]}`
}
t := p.Relation.RelationType
if t != "blocks" && t != "relates" && t != "precedes" && t != "copied_to" && t != "duplicates" {
return http.StatusUnprocessableEntity, `{"errors":["relation_type is not included in the list"]}`
}
s.nextID++
rel := Relation{ID: s.nextID, IssueID: from, IssueToID: p.Relation.IssueToID, RelationType: t}
s.relations[rel.ID] = rel
return http.StatusCreated, `{"relation":` + mustJSON(rel) + `}`
}
func (s *Server) listVersions() (int, string) {
var vs []Version
for _, v := range s.versions {
vs = append(vs, v)
}
return jsonReply(map[string]any{"versions": vs})
}
func (s *Server) createVersion(body []byte) (int, string) {
var p struct {
Version Version `json:"version"`
}
if err := json.Unmarshal(body, &p); err != nil {
return http.StatusBadRequest, `{"errors":["bad json"]}`
}
if p.Version.Name == "" {
return http.StatusUnprocessableEntity, `{"errors":["Name cannot be blank"]}`
}
for _, v := range s.versions {
if v.Name == p.Version.Name {
return http.StatusUnprocessableEntity, `{"errors":["Name has already been taken"]}`
}
}
s.nextID++
v := p.Version
v.ID = s.nextID
if v.Status == "" {
v.Status = "open"
}
s.versions[v.ID] = v
return http.StatusCreated, `{"version":` + mustJSON(v) + `}`
}
// updateVersion applies a PARTIAL update (only provided fields move) and
// answers 204 with an empty body, exactly like the real tracker.
func (s *Server) updateVersion(id int, body []byte) (int, string) {
v, ok := s.versions[id]
if !ok {
return http.StatusNotFound, `{"errors":["Version not found"]}`
}
var p struct {
Version Version `json:"version"`
}
if err := json.Unmarshal(body, &p); err != nil {
return http.StatusBadRequest, `{"errors":["bad json"]}`
}
if p.Version.Name != "" {
v.Name = p.Version.Name
}
if p.Version.DueDate != "" {
v.DueDate = p.Version.DueDate
}
if p.Version.Status != "" {
v.Status = p.Version.Status
}
if p.Version.Sharing != "" {
v.Sharing = p.Version.Sharing
}
s.versions[id] = v
return http.StatusNoContent, ""
}
func (s *Server) getVersion(id int) (int, string) {
v, ok := s.versions[id]
if !ok {
return http.StatusNotFound, `{"errors":["Version not found"]}`
}
return jsonReply(map[string]any{"version": v})
}
func (s *Server) listCategories() (int, string) {
var cs []Category
for _, c := range s.categories {
cs = append(cs, c)
}
return jsonReply(map[string]any{"issue_categories": cs})
}
func (s *Server) createCategory(body []byte) (int, string) {
var p struct {
Category Category `json:"issue_category"`
}
if err := json.Unmarshal(body, &p); err != nil {
return http.StatusBadRequest, `{"errors":["bad json"]}`
}
if p.Category.Name == "" {
return http.StatusUnprocessableEntity, `{"errors":["Name cannot be blank"]}`
}
for _, c := range s.categories {
if c.Name == p.Category.Name {
return http.StatusUnprocessableEntity, `{"errors":["Name has already been taken"]}`
}
}
s.nextID++
c := Category{ID: s.nextID, Name: p.Category.Name}
s.categories[c.ID] = c
return http.StatusCreated, `{"issue_category":` + mustJSON(c) + `}`
}
// issueView projects the stored issue into Redmine's READ shape
// (nested objects for project/tracker/status/priority/category/version).
func issueView(s *Server, i Issue, withJournals bool) map[string]any {
v := map[string]any{
"id": i.ID,
"subject": i.Subject,
"description": i.Description,
"created_on": i.CreatedOn,
"updated_on": i.UpdatedOn,
}
v["project"] = map[string]any{"id": 1, "name": i.ProjectID, "identifier": i.ProjectID}
if n := enumName(s.Trackers, i.TrackerID); n != "" {
v["tracker"] = map[string]any{"id": i.TrackerID, "name": n}
}
if st := statusByID(s.Statuses, i.StatusID); st != nil {
v["status"] = map[string]any{"id": st.ID, "name": st.Name, "is_closed": st.IsClosed}
}
if n := enumName(s.Priorities, i.PriorityID); n != "" {
v["priority"] = map[string]any{"id": i.PriorityID, "name": n}
}
if i.CategoryID != 0 {
if c, ok := s.categories[i.CategoryID]; ok {
v["category"] = map[string]any{"id": c.ID, "name": c.Name}
}
}
if i.FixedVersionID != 0 {
if ver, ok := s.versions[i.FixedVersionID]; ok {
v["fixed_version"] = map[string]any{"id": ver.ID, "name": ver.Name}
}
}
if i.ParentIssueID != 0 {
v["parent"] = map[string]any{"id": i.ParentIssueID}
}
if i.EstimatedHours != nil {
v["estimated_hours"] = *i.EstimatedHours
}
if i.DoneRatio != 0 {
v["done_ratio"] = i.DoneRatio
}
if i.DueDate != "" {
v["due_date"] = i.DueDate
}
if withJournals && len(i.Journals) > 0 {
v["journals"] = i.Journals
}
return v
}
func enumName(list []IDName, id int) string {
for _, e := range list {
if e.ID == id {
return e.Name
}
}
return ""
}
func statusByID(list []Status, id int) *Status {
for i := range list {
if list[i].ID == id {
return &list[i]
}
}
return nil
}
func mustJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return string(b)
}