diff --git a/CHANGELOG.md b/CHANGELOG.md index 707186a..dfaeea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Forms: add `forms` command group (create/get forms, list/get responses). - Apps Script: add `appscript` command group (create/get projects, fetch content, run deployed functions). +- Contacts: update contacts from JSON via `contacts update --from-file` (PR #200 — thanks @jrossi). ### Fixed - Gmail: when `gmail attachment --out` points to a directory (or ends with a trailing slash), combine with `--name` and avoid false cache hits on directories. (#248) — thanks @zerone0x. diff --git a/README.md b/README.md index f8a76c7..82cac31 100644 --- a/README.md +++ b/README.md @@ -873,6 +873,11 @@ gog contacts update people/ \ --birthday "1990-05-12" \ --notes "Met at WWDC" +# Update via JSON (see docs/contacts-json-update.md) +gog contacts get people/ --json | \ + jq '(.contact.urls //= []) | (.contact.urls += [{"value":"obsidian://open?vault=notes&file=People/John%20Doe","type":"profile"}])' | \ + gog contacts update people/ --from-file - + gog contacts delete people/ # Workspace directory (requires Google Workspace) diff --git a/docs/contacts-json-update.md b/docs/contacts-json-update.md new file mode 100644 index 0000000..f3cff04 --- /dev/null +++ b/docs/contacts-json-update.md @@ -0,0 +1,56 @@ +# Update Contacts From JSON + +`gog contacts update` supports JSON input via `--from-file`, so you can update People API fields without adding new CLI flags. + +## Usage + +Update from a file: + +```bash +gog contacts get people/c123456 --json > contact.json + +# Edit contact.json (see notes below) +gog contacts update people/c123456 --from-file contact.json +``` + +Update from stdin: + +```bash +gog contacts get people/c123456 --json | \ + jq '(.contact.urls //= []) | (.contact.urls += [{"value":"https://example.com","type":"profile"}])' | \ + gog contacts update people/c123456 --from-file - +``` + +## Input Formats + +The command accepts: + +- Wrapped (from `gog contacts get --json`): `{"contact": { ...person... }}` +- Direct Person object: `{ ...person... }` + +## What Can Be Updated + +`--from-file` updates only fields that the People API allows via `people.updateContact` `updatePersonFields`. + +Practical rule: include only fields you want to change, at the top level of the JSON object (for example `urls`, `biographies`, `names`, `emailAddresses`, `phoneNumbers`, `addresses`, `organizations`, ...). + +If the JSON contains unsupported fields (for `updateContact`), gog errors instead of silently ignoring them. + +Notes: + +- Some fields are “singleton” for contact sources. Don’t include more than one value for `biographies`, `birthdays`, `genders`, or `names`. +- If you update `memberships`, the Person must include contact group memberships or the API will error. + +## Clearing Fields + +Clearing list fields is supported by including the key with an empty value: + +- Use `[]` to clear a list field (example: `"urls": []`) +- Use `null` to clear a list field (example: `"biographies": null`) + +## Concurrency (ETags) + +To avoid overwriting concurrent contact edits, gog compares the JSON etag with the current contact etag: + +- If they mismatch, update fails with an etag error. +- Use `--ignore-etag` to apply your JSON changes to the latest version anyway. diff --git a/docs/spec.md b/docs/spec.md index e0e0054..1051586 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -295,7 +295,7 @@ Flag aliases: - `gog contacts list [--max N] [--page TOKEN]` - `gog contacts get ` - `gog contacts create --given NAME [--family NAME] [--email addr] [--phone num]` -- `gog contacts update [--given NAME] [--family NAME] [--email addr] [--phone num] [--birthday YYYY-MM-DD] [--notes TEXT]` +- `gog contacts update [--given NAME] [--family NAME] [--email addr] [--phone num] [--birthday YYYY-MM-DD] [--notes TEXT] [--from-file PATH|-] [--ignore-etag]` - `gog contacts delete ` - `gog contacts directory list [--max N] [--page TOKEN]` - `gog contacts directory search [--max N] [--page TOKEN]` diff --git a/internal/cmd/contacts_crud.go b/internal/cmd/contacts_crud.go index 8aac486..a888f17 100644 --- a/internal/cmd/contacts_crud.go +++ b/internal/cmd/contacts_crud.go @@ -15,8 +15,10 @@ import ( ) const ( - contactsReadMask = "names,emailAddresses,phoneNumbers" - contactsGetReadMask = contactsReadMask + ",birthdays" + contactsReadMask = "names,emailAddresses,phoneNumbers" + // contactsGetReadMask is tuned for round-tripping `gog contacts get --json` + // into `gog contacts update --from-file`. + contactsGetReadMask = contactsReadMask + ",birthdays,urls,biographies,addresses,organizations,metadata" ) type ContactsListCmd struct { @@ -218,6 +220,8 @@ type ContactsUpdateCmd struct { Family string `name:"family" help:"Family name"` Email string `name:"email" help:"Email address (empty clears)"` Phone string `name:"phone" help:"Phone number (empty clears)"` + FromFile string `name:"from-file" help:"Update from contact JSON file (use - for stdin)"` + IgnoreETag bool `name:"ignore-etag" help:"Allow updating even if the JSON etag is stale (may overwrite concurrent changes)"` // Extra People API fields (not previously exposed by gog) Birthday string `name:"birthday" help:"Birthday in YYYY-MM-DD (empty clears)"` @@ -240,7 +244,14 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags * return err } - existing, err := svc.People.Get(resourceName).PersonFields(contactsReadMask + ",birthdays,biographies").Do() + if strings.TrimSpace(c.FromFile) != "" { + if flagProvided(kctx, "given") || flagProvided(kctx, "family") || flagProvided(kctx, "email") || flagProvided(kctx, "phone") || flagProvided(kctx, "birthday") || flagProvided(kctx, "notes") { + return usage("can't combine --from-file with other update flags") + } + return c.updateFromJSON(ctx, svc, resourceName, u) + } + + existing, err := svc.People.Get(resourceName).PersonFields(contactsReadMask + ",birthdays,biographies,metadata").Do() if err != nil { return err } @@ -266,7 +277,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags * } if flagProvided(kctx, "email") { if strings.TrimSpace(c.Email) == "" { - existing.EmailAddresses = nil + existing.EmailAddresses = nil // will be forced to [] for patch } else { existing.EmailAddresses = []*people.EmailAddress{{Value: strings.TrimSpace(c.Email)}} } @@ -274,7 +285,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags * } if flagProvided(kctx, "phone") { if strings.TrimSpace(c.Phone) == "" { - existing.PhoneNumbers = nil + existing.PhoneNumbers = nil // will be forced to [] for patch } else { existing.PhoneNumbers = []*people.PhoneNumber{{Value: strings.TrimSpace(c.Phone)}} } @@ -283,7 +294,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags * if flagProvided(kctx, "birthday") { if strings.TrimSpace(c.Birthday) == "" { - existing.Birthdays = nil + existing.Birthdays = nil // will be forced to [] for patch } else { d, parseErr := parseYYYYMMDD(strings.TrimSpace(c.Birthday)) if parseErr != nil { @@ -299,7 +310,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags * if flagProvided(kctx, "notes") { if strings.TrimSpace(c.Notes) == "" { - existing.Biographies = nil + existing.Biographies = nil // will be forced to [] for patch } else { existing.Biographies = []*people.Biography{{ Value: c.Notes, @@ -314,6 +325,11 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags * return usage("no updates provided") } + for _, f := range updateFields { + // Clearing list fields requires forcing them into the patch payload (Google API client omits empty values by default). + forceSendEmptyPersonListField(existing, f) + } + updated, err := svc.People.UpdateContact(resourceName, existing). UpdatePersonFields(strings.Join(updateFields, ",")). Do() diff --git a/internal/cmd/contacts_update_json.go b/internal/cmd/contacts_update_json.go new file mode 100644 index 0000000..6359da6 --- /dev/null +++ b/internal/cmd/contacts_update_json.go @@ -0,0 +1,405 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "sort" + "strings" + + "google.golang.org/api/people/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +// contactsUpdateMaskFields matches the documented updatePersonFields values for +// people.people.updateContact. +var contactsUpdateMaskFields = map[string]struct{}{ + "addresses": {}, + "biographies": {}, + "birthdays": {}, + "calendarUrls": {}, + "clientData": {}, + "emailAddresses": {}, + "events": {}, + "externalIds": {}, + "genders": {}, + "imClients": {}, + "interests": {}, + "locales": {}, + "locations": {}, + "memberships": {}, + "miscKeywords": {}, + "names": {}, + "nicknames": {}, + "occupations": {}, + "organizations": {}, + "phoneNumbers": {}, + "relations": {}, + "sipAddresses": {}, + "urls": {}, + "userDefined": {}, +} + +const ( + contactsJSONKeyContact = "contact" + contactsJSONKeyETag = "etag" + contactsJSONKeyMetadata = "metadata" + contactsJSONKeyResource = "resourceName" +) + +func contactsPersonFieldToGoField(personField string) string { + personField = strings.TrimSpace(personField) + if personField == "" { + return "" + } + return strings.ToUpper(personField[:1]) + personField[1:] +} + +func appendUnique(ss []string, v string) []string { + for _, cur := range ss { + if cur == v { + return ss + } + } + return append(ss, v) +} + +var contactsPersonListForceSend = map[string]func(*people.Person) bool{ + "addresses": func(p *people.Person) bool { + if p.Addresses == nil { + p.Addresses = []*people.Address{} + } + return len(p.Addresses) == 0 + }, + "biographies": func(p *people.Person) bool { + if p.Biographies == nil { + p.Biographies = []*people.Biography{} + } + return len(p.Biographies) == 0 + }, + "birthdays": func(p *people.Person) bool { + if p.Birthdays == nil { + p.Birthdays = []*people.Birthday{} + } + return len(p.Birthdays) == 0 + }, + "calendarUrls": func(p *people.Person) bool { + if p.CalendarUrls == nil { + p.CalendarUrls = []*people.CalendarUrl{} + } + return len(p.CalendarUrls) == 0 + }, + "clientData": func(p *people.Person) bool { + if p.ClientData == nil { + p.ClientData = []*people.ClientData{} + } + return len(p.ClientData) == 0 + }, + "emailAddresses": func(p *people.Person) bool { + if p.EmailAddresses == nil { + p.EmailAddresses = []*people.EmailAddress{} + } + return len(p.EmailAddresses) == 0 + }, + "events": func(p *people.Person) bool { + if p.Events == nil { + p.Events = []*people.Event{} + } + return len(p.Events) == 0 + }, + "externalIds": func(p *people.Person) bool { + if p.ExternalIds == nil { + p.ExternalIds = []*people.ExternalId{} + } + return len(p.ExternalIds) == 0 + }, + "genders": func(p *people.Person) bool { + if p.Genders == nil { + p.Genders = []*people.Gender{} + } + return len(p.Genders) == 0 + }, + "imClients": func(p *people.Person) bool { + if p.ImClients == nil { + p.ImClients = []*people.ImClient{} + } + return len(p.ImClients) == 0 + }, + "interests": func(p *people.Person) bool { + if p.Interests == nil { + p.Interests = []*people.Interest{} + } + return len(p.Interests) == 0 + }, + "locales": func(p *people.Person) bool { + if p.Locales == nil { + p.Locales = []*people.Locale{} + } + return len(p.Locales) == 0 + }, + "locations": func(p *people.Person) bool { + if p.Locations == nil { + p.Locations = []*people.Location{} + } + return len(p.Locations) == 0 + }, + "memberships": func(p *people.Person) bool { + if p.Memberships == nil { + p.Memberships = []*people.Membership{} + } + return len(p.Memberships) == 0 + }, + "miscKeywords": func(p *people.Person) bool { + if p.MiscKeywords == nil { + p.MiscKeywords = []*people.MiscKeyword{} + } + return len(p.MiscKeywords) == 0 + }, + "names": func(p *people.Person) bool { + if p.Names == nil { + p.Names = []*people.Name{} + } + return len(p.Names) == 0 + }, + "nicknames": func(p *people.Person) bool { + if p.Nicknames == nil { + p.Nicknames = []*people.Nickname{} + } + return len(p.Nicknames) == 0 + }, + "occupations": func(p *people.Person) bool { + if p.Occupations == nil { + p.Occupations = []*people.Occupation{} + } + return len(p.Occupations) == 0 + }, + "organizations": func(p *people.Person) bool { + if p.Organizations == nil { + p.Organizations = []*people.Organization{} + } + return len(p.Organizations) == 0 + }, + "phoneNumbers": func(p *people.Person) bool { + if p.PhoneNumbers == nil { + p.PhoneNumbers = []*people.PhoneNumber{} + } + return len(p.PhoneNumbers) == 0 + }, + "relations": func(p *people.Person) bool { + if p.Relations == nil { + p.Relations = []*people.Relation{} + } + return len(p.Relations) == 0 + }, + "sipAddresses": func(p *people.Person) bool { + if p.SipAddresses == nil { + p.SipAddresses = []*people.SipAddress{} + } + return len(p.SipAddresses) == 0 + }, + "urls": func(p *people.Person) bool { + if p.Urls == nil { + p.Urls = []*people.Url{} + } + return len(p.Urls) == 0 + }, + "userDefined": func(p *people.Person) bool { + if p.UserDefined == nil { + p.UserDefined = []*people.UserDefined{} + } + return len(p.UserDefined) == 0 + }, +} + +func forceSendEmptyPersonListField(p *people.Person, personField string) { + if p == nil { + return + } + personField = strings.TrimSpace(personField) + if personField == "" { + return + } + + ensureFn := contactsPersonListForceSend[personField] + if ensureFn == nil { + return + } + if !ensureFn(p) { + return + } + + goField := contactsPersonFieldToGoField(personField) + p.ForceSendFields = appendUnique(p.ForceSendFields, goField) +} + +func forceSendEmptyPersonListFields(p *people.Person, personFields []string) { + for _, f := range personFields { + forceSendEmptyPersonListField(p, f) + } +} + +func firstNonEmpty(ss ...string) string { + for _, s := range ss { + if strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) + } + } + return "" +} + +func contactSourceETag(p *people.Person) string { + if p == nil || p.Metadata == nil { + return "" + } + for _, s := range p.Metadata.Sources { + if s == nil { + continue + } + if strings.EqualFold(s.Type, "CONTACT") && strings.TrimSpace(s.Etag) != "" { + return strings.TrimSpace(s.Etag) + } + } + for _, s := range p.Metadata.Sources { + if s == nil { + continue + } + if strings.TrimSpace(s.Etag) != "" { + return strings.TrimSpace(s.Etag) + } + } + return "" +} + +func openFileOrStdin(path string) (io.Reader, func(), error) { + if strings.TrimSpace(path) == "" { + return nil, nil, usage("missing --from-file path") + } + if path == "-" { + return os.Stdin, nil, nil + } + // #nosec G304 -- user-controlled CLI input; reading arbitrary files is expected here. + f, err := os.Open(path) + if err != nil { + return nil, nil, fmt.Errorf("open %s: %w", path, err) + } + return f, func() { _ = f.Close() }, nil +} + +func parseContactsUpdateJSON(data []byte) (*people.Person, map[string]json.RawMessage, error) { + data = []byte(strings.TrimSpace(string(data))) + if len(data) == 0 { + return nil, nil, usage("empty JSON input") + } + + // Support wrapped format from `gog contacts get --json`: {"contact": {...}}. + var outer map[string]json.RawMessage + if err := json.Unmarshal(data, &outer); err != nil { + return nil, nil, fmt.Errorf("parse JSON: %w", err) + } + if raw, ok := outer[contactsJSONKeyContact]; ok && len(raw) > 0 && raw[0] == '{' { + data = raw + } + + var present map[string]json.RawMessage + if err := json.Unmarshal(data, &present); err != nil { + return nil, nil, fmt.Errorf("parse JSON object: %w", err) + } + var p people.Person + if err := json.Unmarshal(data, &p); err != nil { + return nil, nil, fmt.Errorf("parse contact JSON: %w", err) + } + return &p, present, nil +} + +func contactsUpdateMaskFromKeys(keys map[string]json.RawMessage) ([]string, error) { + update := make([]string, 0, len(keys)) + unsupported := make([]string, 0) + for k := range keys { + if _, ok := contactsUpdateMaskFields[k]; ok { + update = append(update, k) + continue + } + switch k { + case contactsJSONKeyResource, contactsJSONKeyETag, contactsJSONKeyMetadata: + // Allowed (but not part of updatePersonFields). + continue + default: + unsupported = append(unsupported, k) + } + } + if len(unsupported) > 0 { + sort.Strings(unsupported) + return nil, usage("JSON contains unsupported keys for contacts update: " + strings.Join(unsupported, ", ") + ". Include only fields you want to change (for example: urls, biographies, addresses, organizations, ...). Tip: start from `gog contacts get ... --json` and delete keys you don't want to update.") + } + sort.Strings(update) + return update, nil +} + +func (c *ContactsUpdateCmd) updateFromJSON(ctx context.Context, svc *people.Service, resourceName string, u *ui.UI) error { + reader, closeFn, err := openFileOrStdin(strings.TrimSpace(c.FromFile)) + if err != nil { + return err + } + if closeFn != nil { + defer closeFn() + } + data, err := io.ReadAll(reader) + if err != nil { + return fmt.Errorf("read JSON: %w", err) + } + + inputPerson, presentKeys, err := parseContactsUpdateJSON(data) + if err != nil { + return err + } + + updateFields, err := contactsUpdateMaskFromKeys(presentKeys) + if err != nil { + return err + } + if len(updateFields) == 0 { + return usage("no updatable fields found in JSON (needs one of updatePersonFields fields like urls, biographies, ...)") + } + + // Fetch current metadata/etag (required by updateContact). + cur, err := svc.People.Get(resourceName).PersonFields("metadata").Do() + if err != nil { + return err + } + curETag := firstNonEmpty(contactSourceETag(cur), strings.TrimSpace(cur.Etag)) + inputETag := firstNonEmpty(contactSourceETag(inputPerson), strings.TrimSpace(inputPerson.Etag)) + if inputETag == "" { + u.Err().Println("warning: JSON input is missing an etag; consider starting from `gog contacts get ... --json`") + } else if !c.IgnoreETag && curETag != "" && inputETag != curETag { + return usage("etag mismatch (contact changed). Re-run `gog contacts get ... --json`, re-apply edits, retry (or pass --ignore-etag).") + } + + if strings.TrimSpace(inputPerson.ResourceName) != "" && strings.TrimSpace(inputPerson.ResourceName) != resourceName { + return usage("resourceName in JSON does not match CLI argument") + } + + // Enforce resourceName and required metadata. + inputPerson.ResourceName = resourceName + inputPerson.Metadata = cur.Metadata + if curETag != "" { + inputPerson.Etag = curETag + } + + forceSendEmptyPersonListFields(inputPerson, updateFields) + + updated, err := svc.People.UpdateContact(resourceName, inputPerson). + UpdatePersonFields(strings.Join(updateFields, ",")). + Do() + if err != nil { + return err + } + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"contact": updated}) + } + u.Out().Printf("resource\t%s", updated.ResourceName) + return nil +} diff --git a/internal/cmd/contacts_update_json_more_test.go b/internal/cmd/contacts_update_json_more_test.go new file mode 100644 index 0000000..ec98535 --- /dev/null +++ b/internal/cmd/contacts_update_json_more_test.go @@ -0,0 +1,196 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "os" + "strings" + "testing" + + "github.com/steipete/gogcli/internal/ui" +) + +func TestContactsUpdate_FromFile_JSON_CanClearFields(t *testing.T) { + var gotUpdateFields string + var gotURLsPresent bool + var gotURLsLen int + var gotBioPresent bool + + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "people/c1") && r.Method == http.MethodGet && !strings.Contains(r.URL.Path, ":"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "resourceName": "people/c1", + "metadata": map[string]any{ + "sources": []map[string]any{ + {"type": "CONTACT", "etag": "etag-cur"}, + }, + }, + }) + return + case strings.Contains(r.URL.Path, ":updateContact") && (r.Method == http.MethodPatch || r.Method == http.MethodPost): + gotUpdateFields = r.URL.Query().Get("updatePersonFields") + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if urls, ok := body["urls"]; ok { + gotURLsPresent = true + if arr, ok := urls.([]any); ok { + gotURLsLen = len(arr) + } + } + if _, ok := body["biographies"]; ok { + gotBioPresent = true + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"resourceName": "people/c1"}) + return + default: + http.NotFound(w, r) + } + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + withStdin(t, `{"resourceName":"people/c1","etag":"etag-cur","urls":[],"biographies":null}`, func() { + if err := runKong(t, &ContactsUpdateCmd{}, []string{"people/c1", "--from-file", "-"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + }) + + // Sorted mask. + if gotUpdateFields != "biographies,urls" { + t.Fatalf("unexpected updatePersonFields: %q", gotUpdateFields) + } + if !gotURLsPresent || gotURLsLen != 0 { + t.Fatalf("expected urls present as empty list, present=%v len=%d", gotURLsPresent, gotURLsLen) + } + if !gotBioPresent { + t.Fatalf("expected biographies present (clear)") + } +} + +func TestContactsUpdate_FromFile_JSON_UnsupportedFieldErrors(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + withStdin(t, `{"resourceName":"people/c1","photos":[]}`, func() { + err := runKong(t, &ContactsUpdateCmd{}, []string{"people/c1", "--from-file", "-"}, context.Background(), &RootFlags{Account: "a@b.com"}) + if err == nil || !strings.Contains(err.Error(), "photos") { + t.Fatalf("expected unsupported field error mentioning photos, got %v", err) + } + }) +} + +func TestContactsUpdate_FromFile_JSON_ETagMismatch(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "people/c1") && r.Method == http.MethodGet && !strings.Contains(r.URL.Path, ":"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "resourceName": "people/c1", + "metadata": map[string]any{ + "sources": []map[string]any{ + {"type": "CONTACT", "etag": "etag-cur"}, + }, + }, + }) + return + default: + http.NotFound(w, r) + } + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + withStdin(t, `{"resourceName":"people/c1","etag":"etag-old","urls":[{"value":"https://example.com"}]}`, func() { + err := runKong(t, &ContactsUpdateCmd{}, []string{"people/c1", "--from-file", "-"}, context.Background(), &RootFlags{Account: "a@b.com"}) + if err == nil || !strings.Contains(err.Error(), "etag mismatch") { + t.Fatalf("expected etag mismatch error, got %v", err) + } + }) +} + +func TestContactsUpdate_FromFile_JSON_IgnoreETagAllowsUpdate(t *testing.T) { + var gotETag string + + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "people/c1") && r.Method == http.MethodGet && !strings.Contains(r.URL.Path, ":"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "resourceName": "people/c1", + "metadata": map[string]any{ + "sources": []map[string]any{ + {"type": "CONTACT", "etag": "etag-cur"}, + }, + }, + }) + return + case strings.Contains(r.URL.Path, ":updateContact") && (r.Method == http.MethodPatch || r.Method == http.MethodPost): + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + gotETag, _ = body["etag"].(string) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"resourceName": "people/c1"}) + return + default: + http.NotFound(w, r) + } + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + withStdin(t, `{"resourceName":"people/c1","etag":"etag-old","urls":[{"value":"https://example.com"}]}`, func() { + if err := runKong(t, &ContactsUpdateCmd{}, []string{"people/c1", "--from-file", "-", "--ignore-etag"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + }) + + if gotETag != "etag-cur" { + t.Fatalf("expected request to use current etag, got %q", gotETag) + } +} + +func TestContactsUpdate_FromFile_CantCombineWithFlags(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + tmp, err := os.CreateTemp(t.TempDir(), "contact-*.json") + if err != nil { + t.Fatalf("CreateTemp: %v", err) + } + if _, writeErr := tmp.WriteString(`{"resourceName":"people/c1","urls":[]}`); writeErr != nil { + t.Fatalf("write temp: %v", writeErr) + } + _ = tmp.Close() + + err = runKong(t, &ContactsUpdateCmd{}, []string{"people/c1", "--from-file", tmp.Name(), "--email", "x@example.com"}, context.Background(), &RootFlags{Account: "a@b.com"}) + if err == nil || !strings.Contains(err.Error(), "can't combine --from-file") { + t.Fatalf("expected combine error, got %v", err) + } +}