aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSimen A. W. Olsen <hello@simenandre.no>2026-10-18 13:01:54 +0200
committerSimen A. W. Olsen <hello@simenandre.no>2026-10-18 13:01:54 +0200
commit53323a0671a3bfe86e7cff75ee5269f60c01ba05 (patch)
tree77f0764204fedee138c084c6eb16075ab578dea4
parentc4f6746182070aaccaa88b5ab95322b8ee87200c (diff)
downloadfiken-53323a0671a3bfe86e7cff75ee5269f60c01ba05.tar.gz
fiken-53323a0671a3bfe86e7cff75ee5269f60c01ba05.zip
Make UpdateContact read, merge and writev0.3.1
Fiken's PUT replaces the whole contact and rejects a body missing required fields ("'name' er påkrevd"), so a partial update cannot work. Merging over the raw JSON rather than over Contact keeps every field Fiken holds, including the ones this package does not model — a struct round-trip would silently blank an address, its contact people and its notes.
-rw-r--r--fiken/fiken.go60
-rw-r--r--fiken/fiken_test.go49
2 files changed, 100 insertions, 9 deletions
diff --git a/fiken/fiken.go b/fiken/fiken.go
index 2ba549c..2a5d94b 100644
--- a/fiken/fiken.go
+++ b/fiken/fiken.go
@@ -246,14 +246,39 @@ type ContactUpdate struct {
Email string `json:"email,omitempty"`
}
-// UpdateContact changes a contact in place.
+// UpdateContact changes fields on a contact, leaving the rest as they are.
+//
+// Fiken's PUT **replaces** the whole contact and rejects a body missing
+// required fields ("Feil ved validering av data: 'name' er påkrevd"), so the
+// contact is read first and sent back with the changes applied.
+//
+// It merges over the raw JSON rather than over Contact, so every field Fiken
+// holds survives — including the ones this package does not model. Round-
+// tripping through a struct would silently blank a contact's address, its
+// contact people and its notes.
func (c *Client) UpdateContact(ctx context.Context, companySlug string, contactID int64, update ContactUpdate) error {
assert.True(companySlug != "", "company slug must not be empty")
assert.True(contactID > 0, "contact id must be positive, got %d", contactID)
- body, err := json.Marshal(update)
+ current, err := c.contactFields(ctx, companySlug, contactID)
+ if err != nil {
+ return err
+ }
+
+ changes, err := asFields(update)
+ if err != nil {
+ return err
+ }
+ if len(changes) == 0 {
+ return nil
+ }
+ for field, value := range changes {
+ current[field] = value
+ }
+
+ body, err := json.Marshal(current)
if err != nil {
- return fmt.Errorf("encode contact update: %w", err)
+ return fmt.Errorf("encode contact: %w", err)
}
path := fmt.Sprintf("/companies/%s/contacts/%d", companySlug, contactID)
resp, err := c.do(ctx, http.MethodPut, path, nil, body)
@@ -265,6 +290,35 @@ func (c *Client) UpdateContact(ctx context.Context, companySlug string, contactI
return nil
}
+// contactFields reads a contact as the fields Fiken actually holds.
+func (c *Client) contactFields(ctx context.Context, companySlug string, contactID int64) (map[string]any, error) {
+ path := fmt.Sprintf("/companies/%s/contacts/%d", companySlug, contactID)
+ resp, err := c.do(ctx, http.MethodGet, path, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var out map[string]any
+ if err := json.NewDecoder(io.LimitReader(resp.Body, maxBodyBytes)).Decode(&out); err != nil {
+ return nil, fmt.Errorf("decode contact: %w", err)
+ }
+ return out, nil
+}
+
+// asFields turns an update into the fields it sets, dropping the unset ones.
+func asFields(update ContactUpdate) (map[string]any, error) {
+ raw, err := json.Marshal(update)
+ if err != nil {
+ return nil, fmt.Errorf("encode contact update: %w", err)
+ }
+ var fields map[string]any
+ if err := json.Unmarshal(raw, &fields); err != nil {
+ return nil, fmt.Errorf("decode contact update: %w", err)
+ }
+ return fields, nil
+}
+
// Companies lists the companies the token can act for.
func (c *Client) Companies(ctx context.Context) ([]Company, error) {
return page[Company](ctx, c, "/companies", nil)
diff --git a/fiken/fiken_test.go b/fiken/fiken_test.go
index 2d82616..5e2124a 100644
--- a/fiken/fiken_test.go
+++ b/fiken/fiken_test.go
@@ -375,12 +375,24 @@ func TestCreateContactCarriesTheCurrency(t *testing.T) {
}
}
-func TestUpdateContactSendsOnlyWhatIsSet(t *testing.T) {
- var raw map[string]any
+// Fiken's PUT replaces the whole contact, so an update has to read it first
+// and send everything back. Anything this package does not model — the
+// address, contact people, notes — must survive the round trip.
+func TestUpdateContactKeepsEverythingItDoesNotChange(t *testing.T) {
+ var sent map[string]any
var method, path string
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"contactId":14883209386,"name":"Upstacked AS","currency":"NOK",
+ "customer":true,"supplier":false,"organizationNumber":"926030396",
+ "address":{"streetAddress":"Drammensveien 151","postCode":"0277",
+ "city":"Oslo","country":"Norge"},
+ "contactPerson":[{"name":"Someone"}],"notes":[],"language":"Norwegian"}`)) //nolint:errcheck
+ return
+ }
method, path = r.Method, r.URL.Path
- json.NewDecoder(r.Body).Decode(&raw) //nolint:errcheck
+ json.NewDecoder(r.Body).Decode(&sent) //nolint:errcheck
w.WriteHeader(http.StatusOK)
})
@@ -392,9 +404,34 @@ func TestUpdateContactSendsOnlyWhatIsSet(t *testing.T) {
if method != http.MethodPut || !strings.HasSuffix(path, "/contacts/14883209386") {
t.Errorf("sent %s %s", method, path)
}
- // Only the currency: a caller changing one field must not blank the rest.
- if len(raw) != 1 || raw["currency"] != "EUR" {
- t.Errorf("body = %v, want only the currency", raw)
+ if sent["currency"] != "EUR" {
+ t.Errorf("currency = %v, want it changed", sent["currency"])
+ }
+ // Fiken rejects a body without these; a struct round-trip would drop them.
+ for _, field := range []string{"name", "organizationNumber", "address", "contactPerson"} {
+ if sent[field] == nil {
+ t.Errorf("the update dropped %q, which Fiken holds", field)
+ }
+ }
+ if sent["language"] != "Norwegian" {
+ t.Errorf("language = %v, want it left alone", sent["language"])
+ }
+}
+
+// An update that changes nothing makes no call, rather than rewriting a
+// contact to the value it already had.
+func TestUpdateContactWithNoChangesDoesNothing(t *testing.T) {
+ calls := 0
+ c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+ calls++
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"contactId":1,"name":"Upstacked AS"}`)) //nolint:errcheck
+ })
+ if err := c.UpdateContact(context.Background(), "sokkel-as", 1, ContactUpdate{}); err != nil {
+ t.Fatalf("UpdateContact: %v", err)
+ }
+ if calls > 1 {
+ t.Errorf("made %d calls for an empty update, want no PUT", calls)
}
}