Files
mopac-redmine-go/internal/fakeredmine/fakeredmine.go
T

606 lines
17 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
}
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.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/versions.json"):
respStatus, respBody = s.createVersion(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) + `}`
}
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)
}