feat(people): add update person endpoint

Amolith created

Assisted-by: Claude Opus 4.5 via Crush

Change summary

people.go      | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++++
people_test.go | 70 ++++++++++++++++++++++++++++++++++++++
2 files changed, 165 insertions(+)

Detailed changes

people.go 🔗

@@ -67,6 +67,44 @@ func (r createPersonRequest) MarshalJSON() ([]byte, error) {
 	return data, nil
 }
 
+// updatePersonRequest specifies which fields to change on a person.
+type updatePersonRequest struct {
+	FirstName            *string               `json:"first_name,omitempty"`
+	LastName             *string               `json:"last_name,omitempty"`
+	RelationshipStrength *RelationshipStrength `json:"relationship_strength,omitempty"`
+	CustomFields         map[string]any        `json:"-"`
+}
+
+// MarshalJSON flattens CustomFields to top-level JSON fields.
+func (r updatePersonRequest) MarshalJSON() ([]byte, error) {
+	// Alias to avoid infinite recursion
+	type plain updatePersonRequest
+
+	base, err := json.Marshal(plain(r))
+	if err != nil {
+		return nil, fmt.Errorf("marshaling person update request: %w", err)
+	}
+
+	if len(r.CustomFields) == 0 {
+		return base, nil
+	}
+
+	// Merge custom fields into the base object
+	var merged map[string]any
+	if err := json.Unmarshal(base, &merged); err != nil {
+		return nil, fmt.Errorf("unmarshaling person update request for merge: %w", err)
+	}
+
+	maps.Copy(merged, r.CustomFields)
+
+	data, err := json.Marshal(merged)
+	if err != nil {
+		return nil, fmt.Errorf("marshaling merged person update request: %w", err)
+	}
+
+	return data, nil
+}
+
 // personResponse wraps a single person from the API.
 type personResponse struct {
 	Person Person `json:"person"`
@@ -164,3 +202,60 @@ func (b *PersonBuilder) WithCustomField(key string, value any) *PersonBuilder {
 func (b *PersonBuilder) Create(ctx context.Context) (*Person, error) {
 	return create(ctx, b.client, "/people", b.req, func(r personResponse) Person { return r.Person })
 }
+
+// PersonUpdateBuilder constructs and updates a person via method chaining.
+// Only fields you set will be modified; others remain unchanged.
+//
+//	person, err := client.NewPersonUpdate(personID).
+//		WithRelationshipStrength(lunatask.RelationshipCloseFriend).
+//		Update(ctx)
+type PersonUpdateBuilder struct {
+	client   *Client
+	personID string
+	req      updatePersonRequest
+}
+
+// NewPersonUpdate starts building a person update for the given person ID.
+func (c *Client) NewPersonUpdate(personID string) *PersonUpdateBuilder {
+	return &PersonUpdateBuilder{client: c, personID: personID}
+}
+
+// FirstName changes the person's first name.
+func (b *PersonUpdateBuilder) FirstName(firstName string) *PersonUpdateBuilder {
+	b.req.FirstName = &firstName
+
+	return b
+}
+
+// LastName changes the person's last name.
+func (b *PersonUpdateBuilder) LastName(lastName string) *PersonUpdateBuilder {
+	b.req.LastName = &lastName
+
+	return b
+}
+
+// WithRelationshipStrength categorizes the closeness of the relationship.
+// Use one of the Relationship* constants (e.g., [RelationshipCloseFriend]).
+func (b *PersonUpdateBuilder) WithRelationshipStrength(strength RelationshipStrength) *PersonUpdateBuilder {
+	b.req.RelationshipStrength = &strength
+
+	return b
+}
+
+// WithCustomField sets an arbitrary custom field. Lunatask supports "email",
+// "birthday", and "phone" out of the box; other fields must first be defined
+// in the app or [ErrUnprocessableEntity] is returned.
+func (b *PersonUpdateBuilder) WithCustomField(key string, value any) *PersonUpdateBuilder {
+	if b.req.CustomFields == nil {
+		b.req.CustomFields = make(map[string]any)
+	}
+
+	b.req.CustomFields[key] = value
+
+	return b
+}
+
+// Update sends the changes to Lunatask.
+func (b *PersonUpdateBuilder) Update(ctx context.Context) (*Person, error) {
+	return update(ctx, b.client, "/people", b.personID, "person", b.req, func(r personResponse) Person { return r.Person })
+}

people_test.go 🔗

@@ -213,6 +213,76 @@ func TestCreatePerson_Errors(t *testing.T) {
 	})
 }
 
+// --- UpdatePerson ---
+
+func TestUpdatePerson_Success(t *testing.T) {
+	t.Parallel()
+
+	server, capture := newPUTServer(t, "/people/"+personID, singlePersonResponseBody)
+	defer server.Close()
+
+	client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL))
+
+	person, err := client.NewPersonUpdate(personID).
+		FirstName("Jane").
+		LastName("Smith").
+		WithRelationshipStrength(lunatask.RelationshipCloseFriend).
+		Update(ctx())
+	if err != nil {
+		t.Fatalf("error = %v", err)
+	}
+
+	if person == nil {
+		t.Fatal("returned nil")
+	}
+
+	if person.ID != personID {
+		t.Errorf("ID = %q, want %q", person.ID, personID)
+	}
+
+	assertBodyField(t, capture.Body, "first_name", "Jane")
+	assertBodyField(t, capture.Body, "last_name", "Smith")
+	assertBodyField(t, capture.Body, "relationship_strength", "close-friends")
+}
+
+func TestUpdatePerson_AllFields(t *testing.T) {
+	t.Parallel()
+
+	server, capture := newPUTServer(t, "/people/"+personID, singlePersonResponseBody)
+	defer server.Close()
+
+	client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL))
+
+	_, err := client.NewPersonUpdate(personID).
+		FirstName("Ada").
+		LastName("Lovelace").
+		WithRelationshipStrength(lunatask.RelationshipFamily).
+		WithCustomField("email", "ada@example.com").
+		WithCustomField("phone", "+1234567890").
+		WithCustomField("birthday", "1815-12-10").
+		Update(ctx())
+	if err != nil {
+		t.Fatalf("error = %v", err)
+	}
+
+	assertBodyField(t, capture.Body, "first_name", "Ada")
+	assertBodyField(t, capture.Body, "last_name", "Lovelace")
+	assertBodyField(t, capture.Body, "relationship_strength", "family")
+	assertBodyField(t, capture.Body, "email", "ada@example.com")
+	assertBodyField(t, capture.Body, "phone", "+1234567890")
+	assertBodyField(t, capture.Body, "birthday", "1815-12-10")
+}
+
+func TestUpdatePerson_Errors(t *testing.T) {
+	t.Parallel()
+
+	testErrorCases(t, func(c *lunatask.Client) error {
+		_, err := c.NewPersonUpdate(personID).FirstName("x").Update(ctx())
+
+		return err //nolint:wrapcheck // test helper
+	})
+}
+
 // --- DeletePerson ---
 
 func TestDeletePerson_Success(t *testing.T) {