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:
+442
@@ -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) }
|
||||
Reference in New Issue
Block a user