diff --git a/internal/fakeredmine/fakeredmine.go b/internal/fakeredmine/fakeredmine.go index 94c5d1a..c6a826a 100644 --- a/internal/fakeredmine/fakeredmine.go +++ b/internal/fakeredmine/fakeredmine.go @@ -215,6 +215,14 @@ func (s *Server) AddVersion(v Version) int { return v.ID } +// Version returns a copy of a stored version (for assertions). +func (s *Server) Version(id int) (Version, bool) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.versions[id] + return v, ok +} + func (s *Server) AddCategory(name string) int { s.mu.Lock() defer s.mu.Unlock() @@ -274,8 +282,14 @@ func (s *Server) handler(w http.ResponseWriter, r *http.Request) { respStatus, respBody = s.createRelation(from, body) case r.Method == http.MethodGet && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/versions.json"): respStatus, respBody = s.listVersions() - case r.Method == http.MethodPost && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/versions.json"): + case r.Method == http.MethodPost && strings.HasSuffix(path, "/versions.json") && strings.HasPrefix(path, "/projects/"): respStatus, respBody = s.createVersion(body) + case r.Method == http.MethodGet && strings.HasPrefix(path, "/versions/") && strings.HasSuffix(path, ".json"): + id, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(path, "/versions/"), ".json")) + respStatus, respBody = s.getVersion(id) + case r.Method == http.MethodPut && strings.HasPrefix(path, "/versions/") && strings.HasSuffix(path, ".json"): + id, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(path, "/versions/"), ".json")) + respStatus, respBody = s.updateVersion(id, body) case r.Method == http.MethodGet && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/issue_categories.json"): respStatus, respBody = s.listCategories() case r.Method == http.MethodPost && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/issue_categories.json"): @@ -501,6 +515,43 @@ func (s *Server) createVersion(body []byte) (int, string) { return http.StatusCreated, `{"version":` + mustJSON(v) + `}` } +// updateVersion applies a PARTIAL update (only provided fields move) and +// answers 204 with an empty body, exactly like the real tracker. +func (s *Server) updateVersion(id int, body []byte) (int, string) { + v, ok := s.versions[id] + if !ok { + return http.StatusNotFound, `{"errors":["Version not found"]}` + } + var p struct { + Version Version `json:"version"` + } + if err := json.Unmarshal(body, &p); err != nil { + return http.StatusBadRequest, `{"errors":["bad json"]}` + } + if p.Version.Name != "" { + v.Name = p.Version.Name + } + if p.Version.DueDate != "" { + v.DueDate = p.Version.DueDate + } + if p.Version.Status != "" { + v.Status = p.Version.Status + } + if p.Version.Sharing != "" { + v.Sharing = p.Version.Sharing + } + s.versions[id] = v + return http.StatusNoContent, "" +} + +func (s *Server) getVersion(id int) (int, string) { + v, ok := s.versions[id] + if !ok { + return http.StatusNotFound, `{"errors":["Version not found"]}` + } + return jsonReply(map[string]any{"version": v}) +} + func (s *Server) listCategories() (int, string) { var cs []Category for _, c := range s.categories { diff --git a/redmine/projects.go b/redmine/projects.go index a06cbf5..74c8bef 100644 --- a/redmine/projects.go +++ b/redmine/projects.go @@ -16,11 +16,12 @@ type Version struct { Sharing string `json:"sharing,omitempty"` } -// VersionParams is the write shape for CreateVersion. +// VersionParams is the write shape for CreateVersion/UpdateVersion. On +// update, zero fields are omitted so only what you set travels. type VersionParams struct { Name string DueDate string - Status string // "" defaults to open on the server + Status string // "" defaults to open on create; omitted on update Sharing string // "" omits; "descendants" mirrors the PMO roadmap scripts } @@ -55,6 +56,41 @@ func (c *Client) CreateVersion(ctx context.Context, project string, p VersionPar return &body.Version, nil } +// UpdateVersion partially updates a version (PUT /versions/N.json). +// The server answers 204 with an EMPTY body — it is never parsed. Only +// the fields set in p are sent; untouched attributes are not clobbered. +func (c *Client) UpdateVersion(ctx context.Context, id int, p VersionParams) error { + fields := map[string]any{} + if p.Name != "" { + fields["name"] = p.Name + } + if p.DueDate != "" { + fields["due_date"] = p.DueDate + } + if p.Status != "" { + fields["status"] = p.Status + } + if p.Sharing != "" { + fields["sharing"] = p.Sharing + } + if len(fields) == 0 { + return fmt.Errorf("%w: nothing to update", ErrValidation) + } + payload := map[string]any{"version": fields} + return c.do(ctx, "PUT", "/versions/"+strconv.Itoa(id)+".json", nil, payload, nil) +} + +// GetVersion fetches one version by id. +func (c *Client) GetVersion(ctx context.Context, id int) (*Version, error) { + var body struct { + Version Version `json:"version"` + } + if err := c.do(ctx, "GET", "/versions/"+strconv.Itoa(id)+".json", nil, nil, &body); err != nil { + return nil, err + } + return &body.Version, nil +} + // Category is an issue category within a project. type Category struct { ID int `json:"id"` diff --git a/redmine_test.go b/redmine_test.go index c2f2015..0c36e5f 100644 --- a/redmine_test.go +++ b/redmine_test.go @@ -249,6 +249,98 @@ func TestVersionRoundTrip(t *testing.T) { } } +// 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")