From fa23e8cdd63d0b819e9a2ad90b5f9bfffc921bcf Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 29 Jun 2026 16:51:46 -0400 Subject: [PATCH 1/4] Return fresh project after update --- internal/commands/projects.go | 5 ++ internal/commands/projects_test.go | 110 +++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 internal/commands/projects_test.go diff --git a/internal/commands/projects.go b/internal/commands/projects.go index 3518bf7f8..565ac8546 100644 --- a/internal/commands/projects.go +++ b/internal/commands/projects.go @@ -356,6 +356,11 @@ Examples: return convertSDKError(err) } + project, err = app.Account().Projects().Get(cmd.Context(), projectID) + if err != nil { + return convertSDKError(err) + } + return app.OK(project, output.WithEntity("project"), output.WithSummary("Project updated"), diff --git a/internal/commands/projects_test.go b/internal/commands/projects_test.go new file mode 100644 index 000000000..60fdbc8b3 --- /dev/null +++ b/internal/commands/projects_test.go @@ -0,0 +1,110 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/auth" + "github.com/basecamp/basecamp-cli/internal/config" + "github.com/basecamp/basecamp-cli/internal/names" + "github.com/basecamp/basecamp-cli/internal/output" +) + +type mockProjectUpdateTransport struct { + getCount int + putCount int +} + +func (t *mockProjectUpdateTransport) RoundTrip(req *http.Request) (*http.Response, error) { + header := make(http.Header) + header.Set("Content-Type", "application/json") + + if !strings.Contains(req.URL.Path, "/projects/123") { + return nil, fmt.Errorf("unexpected request path: %s", req.URL.Path) + } + + switch req.Method { + case http.MethodGet: + t.getCount++ + description := "Old description" + updatedAt := "2026-06-01T00:00:00.000Z" + if t.getCount > 1 { + description = "New description" + updatedAt = "2026-06-02T00:00:00.000Z" + } + return jsonResponse(200, fmt.Sprintf(`{"id":123,"name":"Test Project","description":%q,"updated_at":%q}`, description, updatedAt), header), nil + case http.MethodPut: + t.putCount++ + return jsonResponse(200, `{"id":123,"name":"Test Project","description":"Old description","updated_at":"2026-06-01T00:00:00.000Z"}`, header), nil + default: + return nil, fmt.Errorf("unexpected method: %s", req.Method) + } +} + +func jsonResponse(status int, body string, header http.Header) *http.Response { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + Header: header, + } +} + +func setupProjectsMockApp(t *testing.T, transport http.RoundTripper) (*appctx.App, *bytes.Buffer) { + t.Helper() + t.Setenv("BASECAMP_NO_KEYRING", "1") + + cfg := &config.Config{AccountID: "99999"} + sdkClient := basecamp.NewClient(&basecamp.Config{}, &testTokenProvider{}, + basecamp.WithTransport(transport), + basecamp.WithMaxRetries(1), + ) + authMgr := auth.NewManager(cfg, nil) + buf := &bytes.Buffer{} + + return &appctx.App{ + Config: cfg, + Auth: authMgr, + SDK: sdkClient, + Names: names.NewResolver(sdkClient, authMgr, cfg.AccountID), + Output: output.New(output.Options{Format: output.FormatJSON, Writer: buf}), + }, buf +} + +func TestProjectsUpdateReturnsFreshProjectAfterDescriptionChange(t *testing.T) { + transport := &mockProjectUpdateTransport{} + app, out := setupProjectsMockApp(t, transport) + + cmd := NewProjectsCmd() + err := executeCommand(cmd, app, "update", "123", "--description", "New description") + require.NoError(t, err) + + assert.Equal(t, 1, transport.putCount) + assert.Equal(t, 2, transport.getCount, "description-only update should fetch the current name, then refetch the fresh project after update") + + var envelope struct { + OK bool `json:"ok"` + Data struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + UpdatedAt string `json:"updated_at"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &envelope)) + assert.True(t, envelope.OK) + assert.Equal(t, int64(123), envelope.Data.ID) + assert.Equal(t, "Test Project", envelope.Data.Name) + assert.Equal(t, "New description", envelope.Data.Description) + assert.Equal(t, "2026-06-02T00:00:00Z", envelope.Data.UpdatedAt) +} From ea461e33f948b6eca01e4b5e11b172d6e427dccc Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 29 Jun 2026 16:59:49 -0400 Subject: [PATCH 2/4] Fix project update lint --- internal/commands/projects.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/commands/projects.go b/internal/commands/projects.go index 565ac8546..14abf5c28 100644 --- a/internal/commands/projects.go +++ b/internal/commands/projects.go @@ -351,12 +351,11 @@ Examples: Description: description, } - project, err := app.Account().Projects().Update(cmd.Context(), projectID, req) - if err != nil { + if _, err := app.Account().Projects().Update(cmd.Context(), projectID, req); err != nil { return convertSDKError(err) } - project, err = app.Account().Projects().Get(cmd.Context(), projectID) + project, err := app.Account().Projects().Get(cmd.Context(), projectID) if err != nil { return convertSDKError(err) } From 3bf299c4c8643073f663b6792a4301ebff925ea1 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 29 Jun 2026 17:02:52 -0400 Subject: [PATCH 3/4] Assert project update request body --- internal/commands/projects_test.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/internal/commands/projects_test.go b/internal/commands/projects_test.go index 60fdbc8b3..1d0e57d57 100644 --- a/internal/commands/projects_test.go +++ b/internal/commands/projects_test.go @@ -22,8 +22,10 @@ import ( ) type mockProjectUpdateTransport struct { - getCount int - putCount int + getCount int + putCount int + putName string + putDescription string } func (t *mockProjectUpdateTransport) RoundTrip(req *http.Request) (*http.Response, error) { @@ -46,6 +48,16 @@ func (t *mockProjectUpdateTransport) RoundTrip(req *http.Request) (*http.Respons return jsonResponse(200, fmt.Sprintf(`{"id":123,"name":"Test Project","description":%q,"updated_at":%q}`, description, updatedAt), header), nil case http.MethodPut: t.putCount++ + var body map[string]any + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("decode update body: %w", err) + } + if name, ok := body["name"].(string); ok { + t.putName = name + } + if description, ok := body["description"].(string); ok { + t.putDescription = description + } return jsonResponse(200, `{"id":123,"name":"Test Project","description":"Old description","updated_at":"2026-06-01T00:00:00.000Z"}`, header), nil default: return nil, fmt.Errorf("unexpected method: %s", req.Method) @@ -90,6 +102,8 @@ func TestProjectsUpdateReturnsFreshProjectAfterDescriptionChange(t *testing.T) { require.NoError(t, err) assert.Equal(t, 1, transport.putCount) + assert.Equal(t, "Test Project", transport.putName) + assert.Equal(t, "New description", transport.putDescription) assert.Equal(t, 2, transport.getCount, "description-only update should fetch the current name, then refetch the fresh project after update") var envelope struct { From eefed1754423573bb8e84b1686c9d56071f20470 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 29 Jun 2026 17:17:08 -0400 Subject: [PATCH 4/4] Fall back when project update refetch fails --- internal/commands/projects.go | 18 +++++++----- internal/commands/projects_test.go | 46 ++++++++++++++++++++++++------ 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/internal/commands/projects.go b/internal/commands/projects.go index 14abf5c28..415f7bec4 100644 --- a/internal/commands/projects.go +++ b/internal/commands/projects.go @@ -351,19 +351,23 @@ Examples: Description: description, } - if _, err := app.Account().Projects().Update(cmd.Context(), projectID, req); err != nil { - return convertSDKError(err) - } - - project, err := app.Account().Projects().Get(cmd.Context(), projectID) + project, err := app.Account().Projects().Update(cmd.Context(), projectID, req) if err != nil { return convertSDKError(err) } - return app.OK(project, + respOpts := []output.ResponseOption{ output.WithEntity("project"), output.WithSummary("Project updated"), - ) + } + freshProject, err := app.Account().Projects().Get(cmd.Context(), projectID) + if err != nil { + respOpts = append(respOpts, output.WithDiagnostic(fmt.Sprintf("Project updated, but fetching the latest project state failed: %v", err))) + } else { + project = freshProject + } + + return app.OK(project, respOpts...) }, } diff --git a/internal/commands/projects_test.go b/internal/commands/projects_test.go index 1d0e57d57..6ad1a02d0 100644 --- a/internal/commands/projects_test.go +++ b/internal/commands/projects_test.go @@ -26,6 +26,7 @@ type mockProjectUpdateTransport struct { putCount int putName string putDescription string + failRefetch bool } func (t *mockProjectUpdateTransport) RoundTrip(req *http.Request) (*http.Response, error) { @@ -39,6 +40,9 @@ func (t *mockProjectUpdateTransport) RoundTrip(req *http.Request) (*http.Respons switch req.Method { case http.MethodGet: t.getCount++ + if t.getCount > 1 && t.failRefetch { + return jsonResponse(400, `{"error":"boom"}`, header), nil + } description := "Old description" updatedAt := "2026-06-01T00:00:00.000Z" if t.getCount > 1 { @@ -106,19 +110,43 @@ func TestProjectsUpdateReturnsFreshProjectAfterDescriptionChange(t *testing.T) { assert.Equal(t, "New description", transport.putDescription) assert.Equal(t, 2, transport.getCount, "description-only update should fetch the current name, then refetch the fresh project after update") - var envelope struct { - OK bool `json:"ok"` - Data struct { - ID int64 `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - UpdatedAt string `json:"updated_at"` - } `json:"data"` - } + var envelope projectUpdateEnvelope require.NoError(t, json.Unmarshal(out.Bytes(), &envelope)) assert.True(t, envelope.OK) assert.Equal(t, int64(123), envelope.Data.ID) assert.Equal(t, "Test Project", envelope.Data.Name) assert.Equal(t, "New description", envelope.Data.Description) assert.Equal(t, "2026-06-02T00:00:00Z", envelope.Data.UpdatedAt) + assert.Empty(t, envelope.Notice) +} + +func TestProjectsUpdateFallsBackToUpdateResponseWhenRefetchFails(t *testing.T) { + transport := &mockProjectUpdateTransport{failRefetch: true} + app, out := setupProjectsMockApp(t, transport) + + cmd := NewProjectsCmd() + err := executeCommand(cmd, app, "update", "123", "--description", "New description") + require.NoError(t, err) + + assert.Equal(t, 1, transport.putCount) + assert.Equal(t, 2, transport.getCount) + + var envelope projectUpdateEnvelope + require.NoError(t, json.Unmarshal(out.Bytes(), &envelope)) + assert.True(t, envelope.OK) + assert.Equal(t, int64(123), envelope.Data.ID) + assert.Equal(t, "Test Project", envelope.Data.Name) + assert.Equal(t, "Old description", envelope.Data.Description) + assert.Contains(t, envelope.Notice, "Project updated, but fetching the latest project state failed") +} + +type projectUpdateEnvelope struct { + OK bool `json:"ok"` + Notice string `json:"notice"` + Data struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + UpdatedAt string `json:"updated_at"` + } `json:"data"` }