From 26824c3715626f42c54b61b0f379bd25194f7912 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Sat, 29 Aug 2026 08:12:16 -0500 Subject: [PATCH] feat(redmine): partial UpdateVersion and GetVersion with fake endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/fakeredmine/fakeredmine.go | 53 ++++++++++++++++- redmine/projects.go | 40 ++++++++++++- redmine_test.go | 92 +++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 3 deletions(-) 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")