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).
443 lines
16 KiB
Go
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) }
|