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

💘 Generated with Crush

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

535 lines
19 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)
}
}
// TestUpdateVersionPartial drives PUT /versions/N.json (Redmine answers
// 204 with an EMPTY body — the client must not try to parse it) and
// asserts the partial-update discipline: only provided fields travel.
func TestUpdateVersionPartial(t *testing.T) {
tests := []struct {
name string
params redmine.VersionParams
wantBody string
}{
{"status flip", redmine.VersionParams{Status: "closed"}, `{"version":{"status":"closed"}}`},
{"due change", redmine.VersionParams{DueDate: "2026-09-15"}, `{"version":{"due_date":"2026-09-15"}}`},
{"name change", redmine.VersionParams{Name: "Beta 2"}, `{"version":{"name":"Beta 2"}}`},
{"combined flags", redmine.VersionParams{Name: "Beta 2", DueDate: "2026-09-15", Status: "closed"},
`{"version":{"due_date":"2026-09-15","name":"Beta 2","status":"closed"}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, srv := newClient(t)
id := srv.AddVersion(fakeredmine.Version{Name: "Beta", DueDate: "2026-08-31", Status: "open"})
if err := c.UpdateVersion(context.Background(), id, tt.params); err != nil {
t.Fatalf("UpdateVersion: %v", err)
}
got, ok := srv.Version(id)
if !ok {
t.Fatalf("version %d vanished", id)
}
for k, v := range map[string]string{
"name": got.Name, "due": got.DueDate, "status": got.Status,
} {
if v == "" {
t.Errorf("field %q wiped by partial update: %+v", k, got)
}
}
if tt.params.Name != "" && got.Name != tt.params.Name {
t.Errorf("name = %q, want %q", got.Name, tt.params.Name)
}
if tt.params.DueDate != "" && got.DueDate != tt.params.DueDate {
t.Errorf("due = %q, want %q", got.DueDate, tt.params.DueDate)
}
if tt.params.Status != "" && got.Status != tt.params.Status {
t.Errorf("status = %q, want %q", got.Status, tt.params.Status)
}
// only provided flags are sent — assert the exact PUT body
last := srv.Requests()[len(srv.Requests())-1]
if last.Method != "PUT" || last.Path != "/versions/"+itoa(id)+".json" {
t.Errorf("request = %+v", last)
}
if last.Body != tt.wantBody {
t.Errorf("body = %s, want %s", last.Body, tt.wantBody)
}
// untouched attributes survive
if tt.params.Name == "" && got.Name != "Beta" {
t.Errorf("untouched name clobbered: %+v", got)
}
if tt.params.DueDate == "" && got.DueDate != "2026-08-31" {
t.Errorf("untouched due clobbered: %+v", got)
}
if tt.params.Status == "" && got.Status != "open" {
t.Errorf("untouched status clobbered: %+v", got)
}
})
}
}
func TestGetVersion(t *testing.T) {
c, srv := newClient(t)
id := srv.AddVersion(fakeredmine.Version{Name: "Beta", DueDate: "2026-08-31", Status: "open"})
v, err := c.GetVersion(context.Background(), id)
if err != nil {
t.Fatalf("GetVersion: %v", err)
}
if v.ID != id || v.Name != "Beta" || v.DueDate != "2026-08-31" || v.Status != "open" {
t.Errorf("version = %+v", v)
}
if _, err := c.GetVersion(context.Background(), 424242); !errors.Is(err, redmine.ErrNotFound) {
t.Errorf("missing version err = %v, want ErrNotFound", err)
}
}
func TestUpdateVersionMissing(t *testing.T) {
c, _ := newClient(t)
err := c.UpdateVersion(context.Background(), 424242, redmine.VersionParams{Status: "closed"})
if !errors.Is(err, redmine.ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
if !strings.Contains(err.Error(), "http 404") {
t.Fatalf("err = %q, want embedded http 404", err)
}
}
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) }