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: `<>`} _, 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) }