Skip to content
48 changes: 30 additions & 18 deletions go/pkg/basecamp/cards.go
Original file line number Diff line number Diff line change
Expand Up @@ -471,26 +471,29 @@ func (s *CardsService) Update(ctx context.Context, cardID int64, req *UpdateCard
return nil, err
}

body := generated.UpdateCardJSONRequestBody{}
body := map[string]any{}
if req.Title != "" {
body.Title = req.Title
body["title"] = req.Title
}
if req.Content != "" {
body.Content = req.Content
body["content"] = req.Content
}
if req.DueOn != "" {
d, parseErr := types.ParseDate(req.DueOn)
if parseErr != nil {
if _, parseErr := types.ParseDate(req.DueOn); parseErr != nil {
err = ErrUsage("card due_on must be in YYYY-MM-DD format")
return nil, err
}
body.DueOn = d
body["due_on"] = req.DueOn
Comment thread
jeremy marked this conversation as resolved.
}
if len(req.AssigneeIDs) > 0 {
body.AssigneeIds = req.AssigneeIDs
body["assignee_ids"] = req.AssigneeIDs
}

resp, err := s.client.parent.gen.UpdateCardWithResponse(ctx, s.client.accountID, cardID, body)
bodyReader, err := marshalBody(body)
if err != nil {
return nil, err
}
resp, err := s.client.parent.gen.UpdateCardWithBodyWithResponse(ctx, s.client.accountID, cardID, "application/json", bodyReader)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -665,9 +668,12 @@ func (s *CardColumnsService) Update(ctx context.Context, columnID int64, req *Up
return nil, err
}

body := generated.UpdateCardColumnJSONRequestBody{
Title: req.Title,
Description: req.Description,
body := generated.UpdateCardColumnJSONRequestBody{}
if req.Title != "" {
body.Title = req.Title
}
if req.Description != "" {
body.Description = req.Description
}

resp, err := s.client.parent.gen.UpdateCardColumnWithResponse(ctx, s.client.accountID, columnID, body)
Expand Down Expand Up @@ -1002,20 +1008,26 @@ func (s *CardStepsService) Update(ctx context.Context, stepID int64, req *Update
return nil, err
}

body := generated.UpdateCardStepJSONRequestBody{
Title: req.Title,
Assignees: req.Assignees,
body := map[string]any{}
if req.Title != "" {
body["title"] = req.Title
}
if len(req.Assignees) > 0 {
body["assignees"] = req.Assignees
}
if req.DueOn != "" {
d, parseErr := types.ParseDate(req.DueOn)
if parseErr != nil {
if _, parseErr := types.ParseDate(req.DueOn); parseErr != nil {
err = ErrUsage("step due_on must be in YYYY-MM-DD format")
return nil, err
}
body.DueOn = d
body["due_on"] = req.DueOn
}

resp, err := s.client.parent.gen.UpdateCardStepWithResponse(ctx, s.client.accountID, stepID, body)
bodyReader, err := marshalBody(body)
if err != nil {
return nil, err
}
resp, err := s.client.parent.gen.UpdateCardStepWithBodyWithResponse(ctx, s.client.accountID, stepID, "application/json", bodyReader)
if err != nil {
return nil, err
}
Expand Down
72 changes: 72 additions & 0 deletions go/pkg/basecamp/cards_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -763,3 +763,75 @@ func TestCardStepsService_Get_NotFound(t *testing.T) {
t.Errorf("expected not_found error, got: %v", err)
}
}

// testCardsServer creates an httptest.Server and a CardsService wired to it.
func testCardsServer(t *testing.T, handler http.HandlerFunc) *CardsService {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)

cfg := DefaultConfig()
cfg.BaseURL = server.URL
token := &StaticTokenProvider{Token: "test-token"}
client := NewClient(cfg, token)
account := client.ForAccount("99999")
return account.Cards()
}

func TestCardsService_UpdatePartial(t *testing.T) {
fixture := loadCardsFixture(t, "get.json")
var receivedBody map[string]any
svc := testCardsServer(t, func(w http.ResponseWriter, r *http.Request) {
receivedBody = decodeRequestBody(t, r)

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
w.Write(fixture)
})

_, err := svc.Update(context.Background(), 12345, &UpdateCardRequest{
Title: "new title",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if receivedBody["title"] != "new title" {
t.Errorf("expected title 'new title', got %v", receivedBody["title"])
}

for _, field := range []string{"content", "due_on", "assignee_ids"} {
if _, ok := receivedBody[field]; ok {
t.Errorf("expected %q to be omitted from partial update, but it was present: %v", field, receivedBody[field])
}
}
}

func TestCardStepsService_UpdatePartial(t *testing.T) {
fixture := loadCardsFixture(t, "step.json")
var receivedBody map[string]any
svc := testCardStepsServer(t, func(w http.ResponseWriter, r *http.Request) {
receivedBody = decodeRequestBody(t, r)

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
w.Write(fixture)
})

_, err := svc.Update(context.Background(), 12345, &UpdateStepRequest{
Title: "new step",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if receivedBody["title"] != "new step" {
t.Errorf("expected title 'new step', got %v", receivedBody["title"])
}

for _, field := range []string{"due_on", "assignees"} {
if _, ok := receivedBody[field]; ok {
t.Errorf("expected %q to be omitted from partial update, but it was present: %v", field, receivedBody[field])
}
}
}
87 changes: 55 additions & 32 deletions go/pkg/basecamp/checkins.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,15 @@ type Questionnaire struct {
}

// QuestionSchedule represents the schedule configuration for a question.
//
// BREAKING CHANGE: Hour and Minute changed from int to *int so that
// "not provided" (nil) is distinguishable from "set to 0" (midnight / top
// of hour).
type QuestionSchedule struct {
Frequency string `json:"frequency"`
Days []int `json:"days"`
Hour int `json:"hour"`
Minute int `json:"minute"`
Hour *int `json:"hour,omitempty"`
Minute *int `json:"minute,omitempty"`
Comment thread
jeremy marked this conversation as resolved.
WeekInstance *int `json:"week_instance,omitempty"`
WeekInterval *int `json:"week_interval,omitempty"`
MonthInterval *int `json:"month_interval,omitempty"`
Expand Down Expand Up @@ -352,12 +356,16 @@ func (s *CheckinsService) CreateQuestion(ctx context.Context, questionnaireID in
return nil, err
}

body := generated.CreateQuestionJSONRequestBody{
Title: req.Title,
Schedule: questionScheduleToGenerated(req.Schedule),
body := map[string]any{
"title": req.Title,
"schedule": questionScheduleToMap(req.Schedule),
}

resp, err := s.client.parent.gen.CreateQuestionWithResponse(ctx, s.client.accountID, questionnaireID, body)
bodyReader, err := marshalBody(body)
if err != nil {
return nil, err
}
resp, err := s.client.parent.gen.CreateQuestionWithBodyWithResponse(ctx, s.client.accountID, questionnaireID, "application/json", bodyReader)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -395,18 +403,25 @@ func (s *CheckinsService) UpdateQuestion(ctx context.Context, questionID int64,
return nil, err
}

body := generated.UpdateQuestionJSONRequestBody{}
body := map[string]any{}
if req.Title != "" {
body.Title = req.Title
body["title"] = req.Title
}
if req.Schedule != nil {
body.Schedule = questionScheduleToGenerated(req.Schedule)
sm := questionScheduleToMap(req.Schedule)
if len(sm) > 0 {
body["schedule"] = sm
Comment thread
jeremy marked this conversation as resolved.
}
}
if req.Paused != nil {
body.Paused = req.Paused
body["paused"] = *req.Paused
}

resp, err := s.client.parent.gen.UpdateQuestionWithResponse(ctx, s.client.accountID, questionID, body)
bodyReader, err := marshalBody(body)
if err != nil {
return nil, err
}
resp, err := s.client.parent.gen.UpdateQuestionWithBodyWithResponse(ctx, s.client.accountID, questionID, "application/json", bodyReader)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -685,11 +700,13 @@ func questionFromGenerated(gq generated.Question) Question {
for i, d := range gq.Schedule.Days {
days[i] = int(d)
}
hour := int(gq.Schedule.Hour)
minute := int(gq.Schedule.Minute)
q.Schedule = &QuestionSchedule{
Frequency: gq.Schedule.Frequency,
Days: days,
Hour: int(gq.Schedule.Hour),
Minute: int(gq.Schedule.Minute),
Hour: &hour,
Minute: &minute,
StartDate: gq.Schedule.StartDate,
EndDate: gq.Schedule.EndDate,
}
Expand Down Expand Up @@ -799,31 +816,37 @@ func questionAnswerFromGenerated(ga generated.QuestionAnswer) QuestionAnswer {
return a
}

// questionScheduleToGenerated converts our QuestionSchedule to the generated type.
func questionScheduleToGenerated(s *QuestionSchedule) generated.QuestionSchedule {
days := make([]int32, len(s.Days))
for i, d := range s.Days {
days[i] = int32(d) // #nosec G115 -- weekday values are always 0-6
// questionScheduleToMap converts a QuestionSchedule to a map for JSON marshaling.
// Used by CreateQuestion and UpdateQuestion to avoid the generated QuestionSchedule
// struct's zero-value serialization leaking empty fields.
func questionScheduleToMap(s *QuestionSchedule) map[string]any {
m := map[string]any{}
if s.Frequency != "" {
m["frequency"] = s.Frequency
}

gs := generated.QuestionSchedule{
Frequency: s.Frequency,
Days: days,
Hour: int32(s.Hour), // #nosec G115 -- hour is 0-23
Minute: int32(s.Minute), // #nosec G115 -- minute is 0-59
StartDate: s.StartDate,
EndDate: s.EndDate,
if len(s.Days) > 0 {
m["days"] = s.Days
}
if s.Hour != nil {
m["hour"] = *s.Hour
}
if s.Minute != nil {
m["minute"] = *s.Minute
}
Comment thread
jeremy marked this conversation as resolved.
if s.StartDate != "" {
m["start_date"] = s.StartDate
}
if s.EndDate != "" {
m["end_date"] = s.EndDate
}

if s.WeekInstance != nil {
gs.WeekInstance = int32(*s.WeekInstance) // #nosec G115 -- bounded small value
m["week_instance"] = *s.WeekInstance
}
if s.WeekInterval != nil {
gs.WeekInterval = int32(*s.WeekInterval) // #nosec G115 -- bounded small value
m["week_interval"] = *s.WeekInterval
}
if s.MonthInterval != nil {
gs.MonthInterval = int32(*s.MonthInterval) // #nosec G115 -- bounded small value
m["month_interval"] = *s.MonthInterval
}

return gs
return m
}
Loading
Loading