feat(contacts): update contacts from JSON

Supersedes #200.

- Add `contacts update --from-file <path|->` + `--ignore-etag`
- Update mask from JSON keys; clears via `[]`/`null`
- ETag safety + resourceName validation
- Docs/tests/README/CHANGELOG updates

Co-authored-by: Jeremy Rossi <jeremy@jeremyrossi.com>
This commit is contained in:
Peter Steinberger 2026-02-14 15:31:14 +01:00 committed by GitHub
parent 4bffa81c2f
commit e61769cb1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 687 additions and 8 deletions

View File

@ -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.

View File

@ -873,6 +873,11 @@ gog contacts update people/<resourceName> \
--birthday "1990-05-12" \
--notes "Met at WWDC"
# Update via JSON (see docs/contacts-json-update.md)
gog contacts get people/<resourceName> --json | \
jq '(.contact.urls //= []) | (.contact.urls += [{"value":"obsidian://open?vault=notes&file=People/John%20Doe","type":"profile"}])' | \
gog contacts update people/<resourceName> --from-file -
gog contacts delete people/<resourceName>
# Workspace directory (requires Google Workspace)

View File

@ -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. Dont 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.

View File

@ -295,7 +295,7 @@ Flag aliases:
- `gog contacts list [--max N] [--page TOKEN]`
- `gog contacts get <people/...|email>`
- `gog contacts create --given NAME [--family NAME] [--email addr] [--phone num]`
- `gog contacts update <people/...> [--given NAME] [--family NAME] [--email addr] [--phone num] [--birthday YYYY-MM-DD] [--notes TEXT]`
- `gog contacts update <people/...> [--given NAME] [--family NAME] [--email addr] [--phone num] [--birthday YYYY-MM-DD] [--notes TEXT] [--from-file PATH|-] [--ignore-etag]`
- `gog contacts delete <people/...>`
- `gog contacts directory list [--max N] [--page TOKEN]`
- `gog contacts directory search <query> [--max N] [--page TOKEN]`

View File

@ -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()

View File

@ -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
}

View File

@ -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)
}
}