Files
mopac-redmine-go/redmine_test.go
T
mrcharles 20dbcf604d 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).
2026-08-29 06:35:20 -05:00

443 lines
16 KiB
Go

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) }