Add the Redmine REST client with a fake-server test suite

Issues (create/update/partial PUT, notes-as-journals), versions,
categories, relations, and name-to-id resolution over trackers/
statuses/priorities. Sentinel errors carry http codes one-line; response
bodies are never surfaced so the API key cannot leak through error
strings (the fake deliberately echoes the presented key to prove it).
This commit is contained in:
2026-08-29 06:35:20 -05:00
parent a3c83bd464
commit 20dbcf604d
6 changed files with 1618 additions and 0 deletions
+586
View File
@@ -0,0 +1,586 @@
// 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"
"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 := &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"},
},
}
mux := http.NewServeMux()
mux.HandleFunc("/", s.handler)
s.srv = httptest.NewServer(mux)
s.URL = s.srv.URL
return s
}
// 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 []Issue `json:"issues"`
Total int `json:"total_count"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
o := out{Limit: limit}
for _, 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
}
o.Issues = append(o.Issues, *i)
}
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)
}
+130
View File
@@ -0,0 +1,130 @@
// Package redmine is a stdlib-only client for the Redmine REST API
// (JSON). It exists so the PMO, the harness, and every vertical talk to
// Redmine through one Go library instead of python glue: issue
// create/update/notes, versions, categories, relations, and name-to-id
// resolution over the enumeration endpoints.
//
// Security discipline: the API key travels ONLY in the X-Redmine-API-Key
// header. Response bodies are never surfaced in error strings (a server
// echo is assumed to be able to carry the key), so errors are one-line,
// parseable "sentinel: http NNN" shapes.
package redmine
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Sentinel errors. Wrap-check with errors.Is; every API failure maps to
// exactly one of these plus an "http NNN" code in the message.
var (
ErrUnreachable = errors.New("redmine: unreachable")
ErrMalformedResponse = errors.New("redmine: malformed response")
ErrAuth = errors.New("redmine: auth failed")
ErrNotFound = errors.New("redmine: not found")
ErrValidation = errors.New("redmine: validation failed")
ErrServer = errors.New("redmine: server error")
)
// Config configures a Client.
type Config struct {
BaseURL string // e.g. https://projects.knownelement.com (no trailing slash needed)
APIKey string // Redmine API key; header-only, never logged
Timeout time.Duration
}
// Client is a Redmine REST client. Safe for concurrent use.
type Client struct {
cfg Config
http *http.Client
}
// New builds a client. A zero Timeout means 30s.
func New(cfg Config) *Client {
if cfg.Timeout == 0 {
cfg.Timeout = 30 * time.Second
}
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
return &Client{cfg: cfg, http: &http.Client{Timeout: cfg.Timeout}}
}
// do performs one JSON exchange. out may be nil for empty bodies (204).
func (c *Client) do(ctx context.Context, method, path string, query url.Values, in any, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return fmt.Errorf("%w: cannot encode request", ErrMalformedResponse)
}
body = strings.NewReader(string(b))
}
u := c.cfg.BaseURL + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, u, body)
if err != nil {
return fmt.Errorf("%w: bad endpoint", ErrUnreachable)
}
req.Header.Set("X-Redmine-API-Key", c.cfg.APIKey) // key lives here and only here
req.Header.Set("Accept", "application/json")
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
// Transport errors embed URLs and peer text; drop them all.
return ErrUnreachable
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return ErrUnreachable
}
return handleResponse(resp.StatusCode, raw, out)
}
// handleResponse maps one HTTP exchange to typed errors. Response BODIES
// are never surfaced: a server echo is assumed to be able to contain the
// API key (see the fakeredmine package, which deliberately echoes it).
func handleResponse(status int, body []byte, out any) error {
switch {
case status >= 200 && status < 300:
if out == nil {
return nil
}
if len(body) == 0 {
return fmt.Errorf("%w: empty body", ErrMalformedResponse)
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("%w: body is not valid json", ErrMalformedResponse)
}
return nil
default:
return statusError(status)
}
}
// statusError maps a non-2xx status to a sentinel + parseable one-liner.
func statusError(status int) error {
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
return fmt.Errorf("%w: http %d", ErrAuth, status)
case status == http.StatusNotFound:
return fmt.Errorf("%w: http %d", ErrNotFound, status)
case status == http.StatusUnprocessableEntity || status == http.StatusBadRequest || status == http.StatusConflict:
return fmt.Errorf("%w: http %d", ErrValidation, status)
case status >= 500:
return fmt.Errorf("%w: http %d", ErrServer, status)
default:
return fmt.Errorf("%w: http %d", ErrServer, status)
}
}
+122
View File
@@ -0,0 +1,122 @@
package redmine
import (
"context"
"fmt"
"strings"
)
// enumeration endpoints: trackers, statuses, priorities. The CLI maps
// human names ("done", "feature", "immediate") onto ids through these;
// the harness gets the same surface so there is no copy-paste temptation.
type trackerList struct {
Trackers []Ref `json:"trackers"`
}
type statusList struct {
Statuses []StatusRef `json:"issue_statuses"`
}
type priorityList struct {
Priorities []Ref `json:"issue_priorities"`
}
// ListTrackers returns the server's issue trackers.
func (c *Client) ListTrackers(ctx context.Context) ([]Ref, error) {
var body trackerList
if err := c.do(ctx, "GET", "/trackers.json", nil, nil, &body); err != nil {
return nil, err
}
return body.Trackers, nil
}
// ListStatuses returns the server's issue statuses (with is_closed).
func (c *Client) ListStatuses(ctx context.Context) ([]StatusRef, error) {
var body statusList
if err := c.do(ctx, "GET", "/issue_statuses.json", nil, nil, &body); err != nil {
return nil, err
}
return body.Statuses, nil
}
// ListPriorities returns the server's issue priorities.
func (c *Client) ListPriorities(ctx context.Context) ([]Ref, error) {
var body priorityList
if err := c.do(ctx, "GET", "/enumerations/issue_priorities.json", nil, nil, &body); err != nil {
return nil, err
}
return body.Priorities, nil
}
// StatusIDByName resolves a status name case-insensitively ("done" ->
// Done's id). Unknown names are ErrNotFound; the name, never any server
// body, appears in the message.
func (c *Client) StatusIDByName(ctx context.Context, name string) (int, error) {
list, err := c.ListStatuses(ctx)
if err != nil {
return 0, err
}
for _, s := range list {
if strings.EqualFold(s.Name, name) {
return s.ID, nil
}
}
return 0, fmt.Errorf("%w: status %q", ErrNotFound, name)
}
// TrackerIDByName resolves a tracker name case-insensitively.
func (c *Client) TrackerIDByName(ctx context.Context, name string) (int, error) {
list, err := c.ListTrackers(ctx)
if err != nil {
return 0, err
}
for _, t := range list {
if strings.EqualFold(t.Name, name) {
return t.ID, nil
}
}
return 0, fmt.Errorf("%w: tracker %q", ErrNotFound, name)
}
// PriorityIDByName resolves a priority name case-insensitively.
func (c *Client) PriorityIDByName(ctx context.Context, name string) (int, error) {
list, err := c.ListPriorities(ctx)
if err != nil {
return 0, err
}
for _, p := range list {
if strings.EqualFold(p.Name, name) {
return p.ID, nil
}
}
return 0, fmt.Errorf("%w: priority %q", ErrNotFound, name)
}
// VersionIDByName resolves a version name within a project,
// case-insensitively.
func (c *Client) VersionIDByName(ctx context.Context, project, name string) (int, error) {
list, err := c.ListVersions(ctx, project)
if err != nil {
return 0, err
}
for _, v := range list {
if strings.EqualFold(v.Name, name) {
return v.ID, nil
}
}
return 0, fmt.Errorf("%w: version %q in %s", ErrNotFound, name, project)
}
// CategoryIDByName resolves a category name within a project,
// case-insensitively.
func (c *Client) CategoryIDByName(ctx context.Context, project, name string) (int, error) {
list, err := c.ListCategories(ctx, project)
if err != nil {
return 0, err
}
for _, cat := range list {
if strings.EqualFold(cat.Name, name) {
return cat.ID, nil
}
}
return 0, fmt.Errorf("%w: category %q in %s", ErrNotFound, name, project)
}
+201
View File
@@ -0,0 +1,201 @@
package redmine
import (
"context"
"fmt"
"net/url"
"strconv"
)
// Status filter values for IssueFilter.Status.
const (
StatusOpen = "open" // Redmine default: open issues only
StatusClosed = "closed"
StatusAll = "*" // every issue regardless of status
)
// Ref is a nested {id, name} object in Redmine's read shape.
type Ref struct {
ID int `json:"id"`
Name string `json:"name"`
}
// String renders "Name (#ID)" or "#ID" when unnamed.
func (r Ref) String() string {
if r.Name != "" {
return fmt.Sprintf("%s (#%d)", r.Name, r.ID)
}
return fmt.Sprintf("#%d", r.ID)
}
// StatusRef adds the is_closed flag.
type StatusRef struct {
Ref
IsClosed bool `json:"is_closed"`
}
// Journal is one history entry (a note, with its author and timestamp).
type Journal struct {
ID int `json:"id"`
UserID int `json:"user_id"`
Notes string `json:"notes"`
CreatedOn string `json:"created_on"`
}
// Issue is Redmine's READ shape: nested objects for project, tracker,
// status, priority, category, fixed_version, parent.
type Issue struct {
ID int `json:"id"`
Subject string `json:"subject"`
Description string `json:"description"`
Project Ref `json:"project"`
Tracker Ref `json:"tracker"`
Status StatusRef `json:"status"`
Priority Ref `json:"priority"`
Category Ref `json:"category"`
FixedVersion Ref `json:"fixed_version"`
Parent Ref `json:"parent"`
AssignedTo Ref `json:"assigned_to"`
EstimatedHours *float64 `json:"estimated_hours"`
DoneRatio int `json:"done_ratio"`
DueDate string `json:"due_date"`
CreatedOn string `json:"created_on"`
UpdatedOn string `json:"updated_on"`
Journals []Journal `json:"journals"`
}
// IssueFilter narrows GET /issues.json. Zero fields list open issues
// across all projects, Redmine's own default behavior.
type IssueFilter struct {
Project string // project identifier (e.g. "MOPAC"); empty = all
Status string // "open" (default), "closed", "*", or a numeric id
FixedVersion int // version id; 0 = no filter
Limit int // page size; 0 = Redmine default (25)
Offset int
}
func (f IssueFilter) query() url.Values {
q := url.Values{}
if f.Project != "" {
q.Set("project_id", f.Project)
}
switch {
case f.Status == "":
q.Set("status_id", StatusOpen)
case f.Status == StatusOpen || f.Status == StatusClosed || f.Status == StatusAll:
q.Set("status_id", f.Status)
default:
if _, err := strconv.Atoi(f.Status); err == nil {
q.Set("status_id", f.Status)
}
}
if f.FixedVersion != 0 {
q.Set("fixed_version_id", strconv.Itoa(f.FixedVersion))
}
if f.Limit != 0 {
q.Set("limit", strconv.Itoa(f.Limit))
}
if f.Offset != 0 {
q.Set("offset", strconv.Itoa(f.Offset))
}
return q
}
// ListIssues returns issues matching the filter plus the server's total
// count (ignoring limit/offset pagination).
func (c *Client) ListIssues(ctx context.Context, f IssueFilter) ([]Issue, int, error) {
var body struct {
Issues []Issue `json:"issues"`
Total int `json:"total_count"`
}
if err := c.do(ctx, "GET", "/issues.json", f.query(), nil, &body); err != nil {
return nil, 0, err
}
return body.Issues, body.Total, nil
}
// GetIssue fetches one issue. withJournals adds ?include=journals.
func (c *Client) GetIssue(ctx context.Context, id int, withJournals bool) (*Issue, error) {
q := url.Values{}
if withJournals {
q.Set("include", "journals")
}
var body struct {
Issue Issue `json:"issue"`
}
if err := c.do(ctx, "GET", "/issues/"+strconv.Itoa(id)+".json", q, nil, &body); err != nil {
return nil, err
}
return &body.Issue, nil
}
// IssueParams is the WRITE shape for create/update. Zero fields are
// omitted from the payload, so update touches only what you set
// (partial-update discipline: never clobber untouched attributes).
type IssueParams struct {
Project string
Subject string
Description string
TrackerID int
StatusID int
PriorityID int
CategoryID int
FixedVersionID int
ParentIssueID int
EstimatedHours *float64
DoneRatio int
DueDate string
Notes string // journal note; one per call
}
type issueWire struct {
Project string `json:"project_id,omitempty"`
Subject string `json:"subject,omitempty"`
Description string `json:"description,omitempty"`
TrackerID int `json:"tracker_id,omitempty"`
StatusID int `json:"status_id,omitempty"`
PriorityID int `json:"priority_id,omitempty"`
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"`
}
func (p IssueParams) wire() issueWire {
return issueWire{
Project: p.Project,
Subject: p.Subject,
Description: p.Description,
TrackerID: p.TrackerID,
StatusID: p.StatusID,
PriorityID: p.PriorityID,
CategoryID: p.CategoryID,
FixedVersionID: p.FixedVersionID,
ParentIssueID: p.ParentIssueID,
EstimatedHours: p.EstimatedHours,
DoneRatio: p.DoneRatio,
DueDate: p.DueDate,
Notes: p.Notes,
}
}
// CreateIssue creates an issue and returns the stored READ shape.
func (c *Client) CreateIssue(ctx context.Context, p IssueParams) (*Issue, error) {
var body struct {
Issue Issue `json:"issue"`
}
payload := map[string]any{"issue": p.wire()}
if err := c.do(ctx, "POST", "/issues.json", nil, payload, &body); err != nil {
return nil, err
}
return &body.Issue, nil
}
// UpdateIssue partially updates an issue (PUT /issues/N.json -> 204).
func (c *Client) UpdateIssue(ctx context.Context, id int, p IssueParams) error {
payload := map[string]any{"issue": p.wire()}
return c.do(ctx, "PUT", "/issues/"+strconv.Itoa(id)+".json", nil, payload, nil)
}
+137
View File
@@ -0,0 +1,137 @@
package redmine
import (
"context"
"fmt"
"net/url"
"strconv"
)
// Version is a roadmap milestone ("target version") of a project.
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"`
}
// VersionParams is the write shape for CreateVersion.
type VersionParams struct {
Name string
DueDate string
Status string // "" defaults to open on the server
Sharing string // "" omits; "descendants" mirrors the PMO roadmap scripts
}
// ListVersions lists the versions of a project (by identifier or id).
func (c *Client) ListVersions(ctx context.Context, project string) ([]Version, error) {
var body struct {
Versions []Version `json:"versions"`
}
if err := c.do(ctx, "GET", "/projects/"+esc(project)+"/versions.json", nil, nil, &body); err != nil {
return nil, err
}
return body.Versions, nil
}
// CreateVersion creates a roadmap milestone in a project.
func (c *Client) CreateVersion(ctx context.Context, project string, p VersionParams) (*Version, error) {
if p.Name == "" {
return nil, fmt.Errorf("%w: version name required", ErrValidation)
}
payload := map[string]any{"version": map[string]any{
"name": p.Name,
"due_date": optStr(p.DueDate),
"status": optStr(p.Status),
"sharing": optStr(p.Sharing),
}}
var body struct {
Version Version `json:"version"`
}
if err := c.do(ctx, "POST", "/projects/"+esc(project)+"/versions.json", nil, payload, &body); err != nil {
return nil, err
}
return &body.Version, nil
}
// Category is an issue category within a project.
type Category struct {
ID int `json:"id"`
Name string `json:"name"`
}
// ListCategories lists the issue categories of a project.
func (c *Client) ListCategories(ctx context.Context, project string) ([]Category, error) {
var body struct {
Categories []Category `json:"issue_categories"`
}
if err := c.do(ctx, "GET", "/projects/"+esc(project)+"/issue_categories.json", nil, nil, &body); err != nil {
return nil, err
}
return body.Categories, nil
}
// CreateCategory creates an issue category in a project.
func (c *Client) CreateCategory(ctx context.Context, project, name string) (*Category, error) {
if name == "" {
return nil, fmt.Errorf("%w: category name required", ErrValidation)
}
payload := map[string]any{"issue_category": map[string]any{"name": name}}
var body struct {
Category Category `json:"issue_category"`
}
if err := c.do(ctx, "POST", "/projects/"+esc(project)+"/issue_categories.json", nil, payload, &body); err != nil {
return nil, err
}
return &body.Category, nil
}
// Relation ties two issues together.
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"`
}
// ValidRelationType reports whether t is a Redmine relation type.
func ValidRelationType(t string) bool {
switch t {
case "relates", "duplicates", "duplicated", "blocks", "blocked", "precedes", "follows", "copied_to", "copied_from":
return true
}
return false
}
// CreateRelation creates issue_from -> issue_to of the given type
// ("blocks", "relates", ...).
func (c *Client) CreateRelation(ctx context.Context, from, to int, relationType string) (*Relation, error) {
if !ValidRelationType(relationType) {
return nil, fmt.Errorf("%w: relation type %q not allowed", ErrValidation, relationType)
}
payload := map[string]any{"relation": map[string]any{
"issue_to_id": to,
"relation_type": relationType,
}}
var body struct {
Relation Relation `json:"relation"`
}
path := "/issues/" + strconv.Itoa(from) + "/relations.json"
if err := c.do(ctx, "POST", path, nil, payload, &body); err != nil {
return nil, err
}
return &body.Relation, nil
}
// esc path-escapes a project identifier or id string.
func esc(s string) string { return url.PathEscape(s) }
// optStr maps "" to nil for omittable JSON string fields.
func optStr(s string) any {
if s == "" {
return nil
}
return s
}
+442
View File
@@ -0,0 +1,442 @@
package redmine_test
import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"testing"
"time"
"git.knownelement.com/ukrrs/mopac-redmine-go/internal/fakeredmine"
"git.knownelement.com/ukrrs/mopac-redmine-go/redmine"
)
const fakeKey = "fake-redmine-key-0123456789"
func newClient(t *testing.T) (*redmine.Client, *fakeredmine.Server) {
t.Helper()
srv := fakeredmine.New(fakeKey)
t.Cleanup(srv.Close)
c := redmine.New(redmine.Config{BaseURL: srv.URL, APIKey: fakeKey, Timeout: 5 * time.Second})
return c, srv
}
func f64(v float64) *float64 { return &v }
// --- issue create/read round-trip -----------------------------------------
func TestCreateIssueRoundTrip(t *testing.T) {
c, srv := newClient(t)
est := 8.0
created, err := c.CreateIssue(context.Background(), redmine.IssueParams{
Project: "MOPAC",
Subject: "Quota: per-identity usage accounting",
Description: "## Scope\n- usage accounting per acting identity",
TrackerID: 2, // Feature
PriorityID: 5, // Immediate
CategoryID: srv.AddCategory("Quota & Backpressure"),
FixedVersionID: srv.AddVersion(fakeredmine.Version{Name: "Beta", Status: "open"}),
ParentIssueID: srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "Parent", StatusID: 1, TrackerID: 4, PriorityID: 2}),
EstimatedHours: f64(est),
DueDate: "2026-08-31",
Notes: "initial note",
})
if err != nil {
t.Fatalf("CreateIssue: %v", err)
}
if created.ID == 0 || created.Subject != "Quota: per-identity usage accounting" {
t.Fatalf("created = %+v", created)
}
if created.Tracker.Name != "Feature" || created.Priority.Name != "Immediate" {
t.Errorf("enums not projected: tracker=%+v priority=%+v", created.Tracker, created.Priority)
}
if created.EstimatedHours == nil || *created.EstimatedHours != 8 {
t.Errorf("estimated_hours = %v", created.EstimatedHours)
}
if created.FixedVersion.Name != "Beta" || created.Category.Name != "Quota & Backpressure" {
t.Errorf("category/version not projected: %+v %+v", created.Category, created.FixedVersion)
}
got, err := c.GetIssue(context.Background(), created.ID, false)
if err != nil {
t.Fatalf("GetIssue: %v", err)
}
if got.Description != "## Scope\n- usage accounting per acting identity" || got.DueDate != "2026-08-31" {
t.Errorf("round-trip mismatch: %+v", got)
}
// The POST body must carry scalar ids (Redmine write shape).
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)
}
issue := post["issue"].(map[string]any)
for _, k := range []string{"project_id", "tracker_id", "priority_id", "category_id", "fixed_version_id", "parent_issue_id", "estimated_hours", "due_date", "notes"} {
if _, ok := issue[k]; !ok {
t.Errorf("POST body missing %q: %v", k, issue)
}
}
if issue["project_id"] != "MOPAC" {
t.Errorf("project_id = %v, want identifier string", issue["project_id"])
}
}
// --- issue list filters ----------------------------------------------------
func TestListIssuesFilters(t *testing.T) {
c, srv := newClient(t)
open := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "open MOPAC", StatusID: 1, TrackerID: 2, PriorityID: 2})
done := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "done MOPAC", StatusID: 3, TrackerID: 2, PriorityID: 2})
other := srv.AddIssue(fakeredmine.Issue{ProjectID: "OTHER", Subject: "elsewhere", StatusID: 1, TrackerID: 2, PriorityID: 2})
_ = done
tests := []struct {
name string
filter redmine.IssueFilter
wantIDs []int
wantQ string
}{
{
name: "project default open",
filter: redmine.IssueFilter{Project: "MOPAC"},
wantIDs: []int{open},
wantQ: "project_id=MOPAC&status_id=open",
},
{
name: "status all",
filter: redmine.IssueFilter{Project: "MOPAC", Status: redmine.StatusAll},
wantIDs: []int{open, done},
wantQ: "project_id=MOPAC&status_id=%2A",
},
{
name: "status closed",
filter: redmine.IssueFilter{Project: "MOPAC", Status: "closed"},
wantIDs: []int{done},
wantQ: "project_id=MOPAC&status_id=closed",
},
{
name: "no project lists across projects",
filter: redmine.IssueFilter{Status: redmine.StatusAll},
wantIDs: []int{open, done, other},
wantQ: "status_id=%2A",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, total, err := c.ListIssues(context.Background(), tt.filter)
if err != nil {
t.Fatalf("ListIssues: %v", err)
}
if len(got) != len(tt.wantIDs) || total != len(tt.wantIDs) {
t.Fatalf("got %d issues (total %d), want %v", len(got), total, tt.wantIDs)
}
for i, id := range tt.wantIDs {
if got[i].ID != id {
t.Errorf("issue[%d] = %d, want %d", i, got[i].ID, id)
}
}
last := srv.Requests()[len(srv.Requests())-1]
if last.Query != tt.wantQ {
t.Errorf("query = %q, want %q", last.Query, tt.wantQ)
}
})
}
// limit is passed through
if _, _, err := c.ListIssues(context.Background(), redmine.IssueFilter{Project: "MOPAC", Limit: 5}); err != nil {
t.Fatalf("ListIssues limit: %v", err)
}
last := srv.Requests()[len(srv.Requests())-1]
if !strings.Contains(last.Query, "limit=5") {
t.Errorf("query %q missing limit=5", last.Query)
}
}
// --- update + note-as-journal ---------------------------------------------
func TestUpdateIssueNoteBecomesJournal(t *testing.T) {
c, srv := newClient(t)
id := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "seeded", StatusID: 1, TrackerID: 2, PriorityID: 2})
if err := c.UpdateIssue(context.Background(), id, redmine.IssueParams{
StatusID: 3, // Done
DoneRatio: 100,
Notes: "REPORT delivered: see inbox",
}); err != nil {
t.Fatalf("UpdateIssue: %v", err)
}
// PUT body: notes + status_id only (omitempty zero fields).
reqs := srv.Requests()
put := reqs[len(reqs)-1]
var p struct {
Issue struct {
StatusID int `json:"status_id"`
DoneRatio int `json:"done_ratio"`
Notes string `json:"notes"`
Subject string `json:"subject"`
} `json:"issue"`
}
if err := json.Unmarshal([]byte(put.Body), &p); err != nil {
t.Fatalf("PUT body: %v", err)
}
if p.Issue.StatusID != 3 || p.Issue.DoneRatio != 100 || p.Issue.Notes != "REPORT delivered: see inbox" {
t.Errorf("PUT body = %+v", p.Issue)
}
if p.Issue.Subject != "" {
t.Errorf("PUT body carried subject; partial update must omit untouched fields")
}
got, err := c.GetIssue(context.Background(), id, true)
if err != nil {
t.Fatalf("GetIssue: %v", err)
}
if got.Status.Name != "Done" || got.Status.IsClosed != true {
t.Errorf("status = %+v", got.Status)
}
if len(got.Journals) != 1 || got.Journals[0].Notes != "REPORT delivered: see inbox" {
t.Errorf("journals = %+v, want the note recorded", got.Journals)
}
if got, _ := srv.Issue(id); got.DoneRatio != 100 {
t.Errorf("server done_ratio = %d", got.DoneRatio)
}
}
func TestGetIssueWithoutJournalsOmitsThem(t *testing.T) {
c, srv := newClient(t)
id := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "s", StatusID: 1, TrackerID: 2, PriorityID: 2,
Journals: []fakeredmine.JournalEntry{{ID: 1, Notes: "old"}}})
got, err := c.GetIssue(context.Background(), id, false)
if err != nil {
t.Fatalf("GetIssue: %v", err)
}
if len(got.Journals) != 0 {
t.Errorf("journals fetched without include: %+v", got.Journals)
}
last := srv.Requests()[len(srv.Requests())-1]
if strings.Contains(last.Query, "include") {
t.Errorf("query %q must not request include for plain show", last.Query)
}
}
// --- versions and categories ----------------------------------------------
func TestVersionRoundTrip(t *testing.T) {
c, _ := newClient(t)
v, err := c.CreateVersion(context.Background(), "MOPAC", redmine.VersionParams{
Name: "Beta", DueDate: "2026-08-31", Status: "open", Sharing: "descendants",
})
if err != nil {
t.Fatalf("CreateVersion: %v", err)
}
if v.ID == 0 || v.Status != "open" {
t.Fatalf("version = %+v", v)
}
list, err := c.ListVersions(context.Background(), "MOPAC")
if err != nil {
t.Fatalf("ListVersions: %v", err)
}
found := false
for _, got := range list {
if got.ID == v.ID && got.Name == "Beta" && got.DueDate == "2026-08-31" {
found = true
}
}
if !found {
t.Errorf("Beta not in list: %+v", list)
}
}
func TestCategoryRoundTrip(t *testing.T) {
c, _ := newClient(t)
cat, err := c.CreateCategory(context.Background(), "MOPAC", "Secrets")
if err != nil {
t.Fatalf("CreateCategory: %v", err)
}
if cat.ID == 0 || cat.Name != "Secrets" {
t.Fatalf("category = %+v", cat)
}
list, err := c.ListCategories(context.Background(), "MOPAC")
if err != nil {
t.Fatalf("ListCategories: %v", err)
}
if len(list) == 0 || list[0].Name != "Secrets" {
t.Errorf("list = %+v", list)
}
}
// --- relations --------------------------------------------------------------
func TestCreateRelation(t *testing.T) {
c, srv := newClient(t)
from := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "quota", StatusID: 1, TrackerID: 2, PriorityID: 2})
to := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "dispatcher", StatusID: 1, TrackerID: 2, PriorityID: 2})
rel, err := c.CreateRelation(context.Background(), from, to, "blocks")
if err != nil {
t.Fatalf("CreateRelation: %v", err)
}
if rel.IssueID != from || rel.IssueToID != to || rel.RelationType != "blocks" {
t.Errorf("relation = %+v", rel)
}
last := srv.Requests()[len(srv.Requests())-1]
if last.Method != "POST" || last.Path != "/issues/"+itoa(from)+"/relations.json" {
t.Errorf("request = %+v", last)
}
var body struct {
Relation struct {
IssueToID int `json:"issue_to_id"`
RelationType string `json:"relation_type"`
} `json:"relation"`
}
if err := json.Unmarshal([]byte(last.Body), &body); err != nil {
t.Fatalf("body: %v", err)
}
if body.Relation.IssueToID != to || body.Relation.RelationType != "blocks" {
t.Errorf("body = %+v", body.Relation)
}
}
func TestCreateRelationRejectsUnknownType(t *testing.T) {
c, srv := newClient(t)
a := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "a", StatusID: 1, TrackerID: 2, PriorityID: 2})
b := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "b", StatusID: 1, TrackerID: 2, PriorityID: 2})
_, err := c.CreateRelation(context.Background(), a, b, "destroys")
if !errors.Is(err, redmine.ErrValidation) {
t.Fatalf("err = %v, want ErrValidation", err)
}
}
// --- name resolution ---------------------------------------------------------
func TestNameResolution(t *testing.T) {
c, srv := newClient(t)
verID := srv.AddVersion(fakeredmine.Version{Name: "Phase 3 - Integrations", Status: "open"})
catID := srv.AddCategory("Quota & Backpressure")
tests := []struct {
name string
fn func() (int, error)
want int
}{
{"status done", func() (int, error) { return c.StatusIDByName(context.Background(), "done") }, 3},
{"status case-insensitive", func() (int, error) { return c.StatusIDByName(context.Background(), "In Progress") }, 2},
{"tracker feature", func() (int, error) { return c.TrackerIDByName(context.Background(), "feature") }, 2},
{"priority immediate", func() (int, error) { return c.PriorityIDByName(context.Background(), "immediate") }, 5},
{"version by name", func() (int, error) { return c.VersionIDByName(context.Background(), "MOPAC", "phase 3 - integrations") }, verID},
{"category by name", func() (int, error) { return c.CategoryIDByName(context.Background(), "MOPAC", "quota & backpressure") }, catID},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.fn()
if err != nil {
t.Fatalf("resolve: %v", err)
}
if got != tt.want {
t.Fatalf("id = %d, want %d", got, tt.want)
}
})
}
if _, err := c.StatusIDByName(context.Background(), "Nonexistent"); !errors.Is(err, redmine.ErrNotFound) {
t.Fatalf("unknown status err = %v, want ErrNotFound", err)
}
}
// --- error mapping ------------------------------------------------------------
func TestErrorMapping(t *testing.T) {
tests := []struct {
name string
fail fakeredmine.FailSpec
want error
}{
{"401", fakeredmine.FailSpec{Status: 401, Body: `{"errors":["bad key %s"]}`}, redmine.ErrAuth},
{"403", fakeredmine.FailSpec{Status: 403, Body: `{"errors":["forbidden %s"]}`}, redmine.ErrAuth},
{"404", fakeredmine.FailSpec{Status: 404, Body: `{"errors":["missing %s"]}`}, redmine.ErrNotFound},
{"422", fakeredmine.FailSpec{Status: 422, Body: `{"errors":["Name has already been taken","%s"]}`}, redmine.ErrValidation},
{"500", fakeredmine.FailSpec{Status: 500, Body: `boom %s`}, redmine.ErrServer},
{"502", fakeredmine.FailSpec{Status: 502, Body: `bad gateway %s`}, redmine.ErrServer},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, srv := newClient(t)
srv.Fail = &tt.fail
_, err := c.GetIssue(context.Background(), 42, false)
if !errors.Is(err, tt.want) {
t.Fatalf("err = %v, want %v", err, tt.want)
}
if !strings.Contains(err.Error(), "http "+strings.TrimPrefix(itoa(tt.fail.Status), "")) {
// status code must appear for machine parsing
t.Fatalf("err = %q, want embedded http status %d", err.Error(), tt.fail.Status)
}
})
}
}
func TestUnreachable(t *testing.T) {
srv := fakeredmine.New(fakeKey)
url := srv.URL
srv.Close() // port now dead
c := redmine.New(redmine.Config{BaseURL: url, APIKey: fakeKey, Timeout: 2 * time.Second})
_, err := c.GetIssue(context.Background(), 1, false)
if !errors.Is(err, redmine.ErrUnreachable) {
t.Fatalf("err = %v, want ErrUnreachable", err)
}
if strings.Contains(err.Error(), "http") {
t.Fatalf("unreachable err should not fake an http status: %q", err.Error())
}
}
func TestMalformedResponse(t *testing.T) {
c, srv := newClient(t)
srv.Fail = &fakeredmine.FailSpec{Status: 200, Body: `<<not json>>`}
_, err := c.GetIssue(context.Background(), 1, false)
if !errors.Is(err, redmine.ErrMalformedResponse) {
t.Fatalf("err = %v, want ErrMalformedResponse", err)
}
}
// --- key redaction ---------------------------------------------------------------
// The fake embeds the PRESENTED key in every error body (see FailSpec and
// the 401 path). If any error string or recorded request ever leaks the
// key, these assertions fail. The API key must exist only in the
// X-Redmine-API-Key header.
func TestKeyNeverLeaks(t *testing.T) {
t.Run("wrong key rejected without echo", func(t *testing.T) {
srv := fakeredmine.New(fakeKey)
t.Cleanup(srv.Close)
c := redmine.New(redmine.Config{BaseURL: srv.URL, APIKey: "wrong-key-abcdef", Timeout: 2 * time.Second})
_, err := c.GetIssue(context.Background(), 1, false)
if !errors.Is(err, redmine.ErrAuth) {
t.Fatalf("err = %v, want ErrAuth", err)
}
if strings.Contains(err.Error(), "wrong-key-abcdef") || strings.Contains(err.Error(), fakeKey) {
t.Fatalf("error leaks a key: %q", err.Error())
}
})
t.Run("error bodies never surfaced", func(t *testing.T) {
c, srv := newClient(t)
srv.Fail = &fakeredmine.FailSpec{Status: 500, Body: `crashed while holding key %s`}
_, err := c.GetIssue(context.Background(), 1, false)
if strings.Contains(err.Error(), "crashed") || strings.Contains(err.Error(), fakeKey) {
t.Fatalf("err = %q, want sanitized one-liner", err.Error())
}
})
t.Run("key travels only in the header", func(t *testing.T) {
c, srv := newClient(t)
if _, _, err := c.ListIssues(context.Background(), redmine.IssueFilter{Project: "MOPAC"}); err != nil {
t.Fatalf("ListIssues: %v", err)
}
for _, req := range srv.Requests() {
if strings.Contains(req.Body, fakeKey) || strings.Contains(req.Query, fakeKey) || strings.Contains(req.Path, fakeKey) {
t.Fatalf("key outside header: %+v", req)
}
}
})
}
func itoa(n int) string { return strconv.Itoa(n) }