diff --git a/README.md b/README.md index 2b2e999..7cfdac8 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,13 @@ fopost accounts list · get · rename · move · health · validate · refr discord channels · switch-channel · identity · set-identity discord events · create-event · delete-event discord members · roles · assign-role · unassign-role · dm + gbp location · update-location · attributes · update-attributes + gbp menus · replace-menus · services · replace-services + gbp media · add-media · delete-media + gbp place-actions · add-place-action · update-place-action + gbp delete-place-action + gbp verification · start-verification · complete-verification + gbp performance · keywords · assign fopost account-groups list · get · create · rename · set-members · delete fopost posts list · get · create · publish · cancel · delete duplicate · preflight · deliveries @@ -226,12 +233,9 @@ fopost knowledge list · add · sync · delete · search fopost analytics overview · top-posts · time-series fopost automations list · get · toggle · trigger · runs fopost webhooks list · create · test · delete -<<<<<<< HEAD fopost ads tree · pause · resume · insights · leads fopost activity list · audit -======= fopost ads tree · pause · resume · insights · leads · catalogs · library ->>>>>>> origin/main fopost completion bash · zsh · fish · powershell fopost version ``` diff --git a/go.mod b/go.mod index 5780bed..c4e281d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/fopost/fopost-cli go 1.22 require ( - github.com/fopost/fopost-go v0.2.1-0.20260920150019-214947b2e852 + github.com/fopost/fopost-go v0.2.1-0.20260920154339-1957686eb2db github.com/spf13/cobra v1.10.2 golang.org/x/term v0.27.0 ) diff --git a/go.sum b/go.sum index bdb02b4..4b1b7b3 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/fopost/fopost-go v0.2.1-0.20260920130053-a8ac95df638d h1:XSxGXq2PUdF+agsbdzvUbL+NQQ8qC/lvUec0PX6pHrA= -github.com/fopost/fopost-go v0.2.1-0.20260920130053-a8ac95df638d/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= -github.com/fopost/fopost-go v0.2.1-0.20260920141700-633d72b705ca h1:1a7XwNMmGCUh22hW6FOs8R5F5xJVTsbgCoJK+VdgEMc= -github.com/fopost/fopost-go v0.2.1-0.20260920141700-633d72b705ca/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= -github.com/fopost/fopost-go v0.2.1-0.20260920143205-156d1c2d360c h1:Md2W/Uu8pXDMqJkZ0gRuSb8iNz3/H4zn5FS++Yt+Lf8= -github.com/fopost/fopost-go v0.2.1-0.20260920143205-156d1c2d360c/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= -github.com/fopost/fopost-go v0.2.1-0.20260920150019-214947b2e852 h1:Vn2ObLqhFK01lhS7bqtSOyTlK7/rmEz58Y11Bj83eVE= -github.com/fopost/fopost-go v0.2.1-0.20260920150019-214947b2e852/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= +github.com/fopost/fopost-go v0.2.1-0.20260920154339-1957686eb2db h1:ZRTF4nBDm+K4SzZEPlsC8RvLHIKO0Vj4Dr+nw1+iw8M= +github.com/fopost/fopost-go v0.2.1-0.20260920154339-1957686eb2db/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/cmd/accounts.go b/internal/cmd/accounts.go index 3b3a3c4..45d96f0 100644 --- a/internal/cmd/accounts.go +++ b/internal/cmd/accounts.go @@ -30,6 +30,7 @@ func newAccountsCmd(state *State) *cobra.Command { newAccountsMessagingCmd(state), newAccountsWebhookCmd(state), newAccountsDiscordCmd(state), + newAccountsGBPCmd(state), ) return cmd } diff --git a/internal/cmd/accounts_gbp.go b/internal/cmd/accounts_gbp.go new file mode 100644 index 0000000..b698432 --- /dev/null +++ b/internal/cmd/accounts_gbp.go @@ -0,0 +1,502 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + + fopost "github.com/fopost/fopost-go" + "github.com/spf13/cobra" +) + +// Business Profile management for a connected Google location. +// +// Google grants Business Profile API access per project. Until that grant +// lands every command here fails with a 503 configuration_error. +// +// Responses relay Google's own shape, so the human view is the JSON itself +// rather than a table that would go stale the moment Google adds a field. +func newAccountsGBPCmd(state *State) *cobra.Command { + cmd := &cobra.Command{ + Use: "gbp", + Aliases: []string{"google-business"}, + Short: "Manage a connected Google Business Profile location", + } + cmd.AddCommand( + newGBPLocationCmd(state), + newGBPUpdateLocationCmd(state), + newGBPAttributesCmd(state), + newGBPUpdateAttributesCmd(state), + newGBPMenusCmd(state), + newGBPReplaceMenusCmd(state), + newGBPServicesCmd(state), + newGBPReplaceServicesCmd(state), + newGBPMediaCmd(state), + newGBPAddMediaCmd(state), + newGBPDeleteMediaCmd(state), + newGBPPlaceActionsCmd(state), + newGBPAddPlaceActionCmd(state), + newGBPUpdatePlaceActionCmd(state), + newGBPDeletePlaceActionCmd(state), + newGBPVerificationCmd(state), + newGBPStartVerificationCmd(state), + newGBPCompleteVerificationCmd(state), + newGBPPerformanceCmd(state), + newGBPKeywordsCmd(state), + newGBPAssignCmd(state), + ) + return cmd +} + +/* ── Location ──────────────────────────────────────────────────── */ + +func newGBPLocationCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "location ", + Short: "Show the connected location", + Args: cobra.ExactArgs(1), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + return c.GoogleBusiness.GetLocation(cmd.Context(), args[0]) + }), + } +} + +func newGBPUpdateLocationCmd(state *State) *cobra.Command { + var title, description, website, phone, storeCode string + cmd := &cobra.Command{ + Use: "update-location ", + Short: "Update the location's name, description, website, phone or store code", + Long: "Updates the location. Only the flags you pass change; pass an empty string\n" + + "to clear a field.", + Args: cobra.ExactArgs(1), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + body := &fopost.UpdateGoogleBusinessLocationRequest{} + if cmd.Flags().Changed("title") { + body.Title = &title + } + if cmd.Flags().Changed("description") { + body.Description = &description + } + if cmd.Flags().Changed("website") { + body.WebsiteURI = &website + } + if cmd.Flags().Changed("phone") { + body.PrimaryPhone = &phone + } + if cmd.Flags().Changed("store-code") { + body.StoreCode = &storeCode + } + return c.GoogleBusiness.UpdateLocation(cmd.Context(), args[0], body) + }), + } + cmd.Flags().StringVar(&title, "title", "", "Business name") + cmd.Flags().StringVar(&description, "description", "", "Profile description") + cmd.Flags().StringVar(&website, "website", "", "Website the listing links to") + cmd.Flags().StringVar(&phone, "phone", "", "Phone number on the listing") + cmd.Flags().StringVar(&storeCode, "store-code", "", "Your own code for this location") + return cmd +} + +/* ── Attributes, menus, services ───────────────────────────────── */ + +func newGBPAttributesCmd(state *State) *cobra.Command { + var available bool + var categoryName, regionCode, languageCode string + cmd := &cobra.Command{ + Use: "attributes ", + Short: "Show the attributes set on the location", + Args: cobra.ExactArgs(1), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + return c.GoogleBusiness.GetAttributes(cmd.Context(), args[0], + &fopost.GoogleBusinessAttributesOptions{ + Available: available, + CategoryName: categoryName, + RegionCode: regionCode, + LanguageCode: languageCode, + }) + }), + } + cmd.Flags().BoolVar(&available, "available", false, "List what Google offers instead of what is set") + cmd.Flags().StringVar(&categoryName, "category", "", "Category to list attributes for") + cmd.Flags().StringVar(®ionCode, "region", "", "Region code to list attributes for") + cmd.Flags().StringVar(&languageCode, "language", "", "Language for the attribute labels") + return cmd +} + +func newGBPUpdateAttributesCmd(state *State) *cobra.Command { + var file string + cmd := &cobra.Command{ + Use: "update-attributes ", + Short: "Set attributes from a JSON file", + Long: "Sets attributes from a JSON array, e.g.\n" + + ` [{"name":"attributes/has_wifi","values":[true]}]` + "\n" + + "Only the named attributes change; every other one is left alone.", + Args: cobra.ExactArgs(1), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + attributes, err := readJSONList(file) + if err != nil { + return nil, err + } + return c.GoogleBusiness.UpdateAttributes(cmd.Context(), args[0], attributes) + }), + } + cmd.Flags().StringVar(&file, "file", "", "JSON file holding the attribute array, or - for stdin") + _ = cmd.MarkFlagRequired("file") + return cmd +} + +func newGBPMenusCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "menus ", + Short: "Show the location's food menus", + Args: cobra.ExactArgs(1), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + return c.GoogleBusiness.GetMenus(cmd.Context(), args[0]) + }), + } +} + +func newGBPReplaceMenusCmd(state *State) *cobra.Command { + var file string + cmd := &cobra.Command{ + Use: "replace-menus ", + Short: "Replace the food menus from a JSON file", + Long: "Google has no per-section patch, so the whole menu set is replaced.", + Args: cobra.ExactArgs(1), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + menus, err := readJSONList(file) + if err != nil { + return nil, err + } + return c.GoogleBusiness.ReplaceMenus(cmd.Context(), args[0], menus) + }), + } + cmd.Flags().StringVar(&file, "file", "", "JSON file holding the menu array, or - for stdin") + _ = cmd.MarkFlagRequired("file") + return cmd +} + +func newGBPServicesCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "services ", + Short: "Show the location's service list", + Args: cobra.ExactArgs(1), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + return c.GoogleBusiness.GetServices(cmd.Context(), args[0]) + }), + } +} + +func newGBPReplaceServicesCmd(state *State) *cobra.Command { + var file string + cmd := &cobra.Command{ + Use: "replace-services ", + Short: "Replace the service list from a JSON file", + Args: cobra.ExactArgs(1), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + items, err := readJSONList(file) + if err != nil { + return nil, err + } + return c.GoogleBusiness.ReplaceServices(cmd.Context(), args[0], items) + }), + } + cmd.Flags().StringVar(&file, "file", "", "JSON file holding the service array, or - for stdin") + _ = cmd.MarkFlagRequired("file") + return cmd +} + +/* ── Photos ────────────────────────────────────────────────────── */ + +func newGBPMediaCmd(state *State) *cobra.Command { + var pageSize int + var pageToken string + cmd := &cobra.Command{ + Use: "media ", + Short: "List the location's photos", + Args: cobra.ExactArgs(1), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + return c.GoogleBusiness.ListMedia(cmd.Context(), args[0], pageSize, pageToken) + }), + } + cmd.Flags().IntVar(&pageSize, "page-size", 0, "Photos per page (1-100)") + cmd.Flags().StringVar(&pageToken, "page-token", "", "Page token from a previous listing") + return cmd +} + +func newGBPAddMediaCmd(state *State) *cobra.Command { + var mediaID, category, description string + cmd := &cobra.Command{ + Use: "add-media ", + Short: "Add a photo from your media library", + Long: "The photo is a media-library asset in the same workspace, JPEG or PNG.", + Args: cobra.ExactArgs(1), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + return c.GoogleBusiness.AddMedia(cmd.Context(), args[0], &fopost.AddGoogleBusinessMediaRequest{ + MediaID: mediaID, + Category: category, + Description: description, + }) + }), + } + cmd.Flags().StringVar(&mediaID, "media-id", "", "Media library asset id") + cmd.Flags().StringVar(&category, "category", "ADDITIONAL", "COVER, INTERIOR, PRODUCT, MENU and the rest") + cmd.Flags().StringVar(&description, "description", "", "Caption Google shows with the photo") + _ = cmd.MarkFlagRequired("media-id") + return cmd +} + +func newGBPDeleteMediaCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "delete-media ", + Short: "Remove a photo from the location", + Args: cobra.ExactArgs(2), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + return c.GoogleBusiness.DeleteMedia(cmd.Context(), args[0], args[1]) + }), + } +} + +/* ── Place action links ────────────────────────────────────────── */ + +func newGBPPlaceActionsCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "place-actions ", + Short: "List the Book, Order and Reserve links on the listing", + Args: cobra.ExactArgs(1), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + return c.GoogleBusiness.ListPlaceActions(cmd.Context(), args[0]) + }), + } +} + +func newGBPAddPlaceActionCmd(state *State) *cobra.Command { + var uri, actionType string + var preferred bool + cmd := &cobra.Command{ + Use: "add-place-action ", + Short: "Add an action link to the listing", + Args: cobra.ExactArgs(1), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + body := &fopost.CreateGoogleBusinessPlaceActionRequest{URI: uri, PlaceActionType: actionType} + if cmd.Flags().Changed("preferred") { + body.IsPreferred = &preferred + } + return c.GoogleBusiness.CreatePlaceAction(cmd.Context(), args[0], body) + }), + } + cmd.Flags().StringVar(&uri, "uri", "", "Where the button sends the visitor") + cmd.Flags().StringVar(&actionType, "type", "", "APPOINTMENT, FOOD_ORDERING, SHOP_ONLINE and the rest") + cmd.Flags().BoolVar(&preferred, "preferred", false, "Prefer this link over the others of its type") + _ = cmd.MarkFlagRequired("uri") + _ = cmd.MarkFlagRequired("type") + return cmd +} + +func newGBPUpdatePlaceActionCmd(state *State) *cobra.Command { + var uri string + var preferred bool + cmd := &cobra.Command{ + Use: "update-place-action ", + Short: "Change an action link's URL or preference", + Args: cobra.ExactArgs(2), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + body := &fopost.UpdateGoogleBusinessPlaceActionRequest{} + if cmd.Flags().Changed("uri") { + body.URI = &uri + } + if cmd.Flags().Changed("preferred") { + body.IsPreferred = &preferred + } + return c.GoogleBusiness.UpdatePlaceAction(cmd.Context(), args[0], args[1], body) + }), + } + cmd.Flags().StringVar(&uri, "uri", "", "Where the button sends the visitor") + cmd.Flags().BoolVar(&preferred, "preferred", false, "Prefer this link over the others of its type") + return cmd +} + +func newGBPDeletePlaceActionCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "delete-place-action ", + Short: "Remove an action link from the listing", + Args: cobra.ExactArgs(2), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + return c.GoogleBusiness.DeletePlaceAction(cmd.Context(), args[0], args[1]) + }), + } +} + +/* ── Verification ──────────────────────────────────────────────── */ + +func newGBPVerificationCmd(state *State) *cobra.Command { + var languageCode string + cmd := &cobra.Command{ + Use: "verification ", + Short: "List the ways Google will let this location be verified", + Args: cobra.ExactArgs(1), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + return c.GoogleBusiness.GetVerificationOptions(cmd.Context(), args[0], languageCode) + }), + } + cmd.Flags().StringVar(&languageCode, "language", "", "Language Google should verify in") + return cmd +} + +func newGBPStartVerificationCmd(state *State) *cobra.Command { + var method, languageCode, phone, email, mailer string + cmd := &cobra.Command{ + Use: "start-verification ", + Short: "Start verifying the location", + Long: "The response names the pending verification to finish with complete-verification.", + Args: cobra.ExactArgs(1), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + return c.GoogleBusiness.StartVerification(cmd.Context(), args[0], + &fopost.StartGoogleBusinessVerificationRequest{ + Method: strings.ToUpper(method), + LanguageCode: languageCode, + PhoneNumber: phone, + EmailAddress: email, + MailerContactName: mailer, + }) + }), + } + cmd.Flags().StringVar(&method, "method", "", "ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO or VETTED_PARTNER") + cmd.Flags().StringVar(&languageCode, "language", "", "Language Google should verify in") + cmd.Flags().StringVar(&phone, "phone", "", "Number to call or text") + cmd.Flags().StringVar(&email, "email", "", "Address to mail") + cmd.Flags().StringVar(&mailer, "mailer-contact", "", "Who the postcard is addressed to") + _ = cmd.MarkFlagRequired("method") + return cmd +} + +func newGBPCompleteVerificationCmd(state *State) *cobra.Command { + var pin string + cmd := &cobra.Command{ + Use: "complete-verification ", + Short: "Finish a verification with the PIN Google sent", + Args: cobra.ExactArgs(2), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + return c.GoogleBusiness.CompleteVerification(cmd.Context(), args[0], args[1], pin) + }), + } + cmd.Flags().StringVar(&pin, "pin", "", "The PIN Google sent") + _ = cmd.MarkFlagRequired("pin") + return cmd +} + +/* ── Performance ───────────────────────────────────────────────── */ + +func newGBPPerformanceCmd(state *State) *cobra.Command { + var start, end string + var metrics []string + cmd := &cobra.Command{ + Use: "performance ", + Short: "Show daily impressions, calls, directions and clicks", + Args: cobra.ExactArgs(1), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + return c.GoogleBusiness.GetPerformance(cmd.Context(), args[0], start, end, metrics) + }), + } + cmd.Flags().StringVar(&start, "start", "", "First day, as 2026-09-01") + cmd.Flags().StringVar(&end, "end", "", "Last day, as 2026-09-30") + cmd.Flags().StringSliceVar(&metrics, "metric", nil, "Metric to fetch; repeat for more") + _ = cmd.MarkFlagRequired("start") + _ = cmd.MarkFlagRequired("end") + return cmd +} + +func newGBPKeywordsCmd(state *State) *cobra.Command { + var start, end, pageToken string + cmd := &cobra.Command{ + Use: "keywords ", + Short: "Show the search terms people used to find the listing", + Args: cobra.ExactArgs(1), + RunE: gbpRun(state, func(cmd *cobra.Command, c *fopost.Client, args []string) (any, error) { + return c.GoogleBusiness.GetSearchKeywords(cmd.Context(), args[0], start, end, pageToken) + }), + } + cmd.Flags().StringVar(&start, "start", "", "First month, as 2026-08-01") + cmd.Flags().StringVar(&end, "end", "", "Last month, as 2026-09-01") + cmd.Flags().StringVar(&pageToken, "page-token", "", "Page token from a previous listing") + _ = cmd.MarkFlagRequired("start") + _ = cmd.MarkFlagRequired("end") + return cmd +} + +/* ── Workspace assignment ──────────────────────────────────────── */ + +func newGBPAssignCmd(state *State) *cobra.Command { + var workspace string + cmd := &cobra.Command{ + Use: "assign ", + Short: "Hand the location to another workspace you own", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + moved, err := client.GoogleBusiness.Assign(cmd.Context(), args[0], workspace) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(moved, func() { + printer.Table([]string{"id", "workspace"}, [][]string{{moved.ID, moved.WorkspaceID}}) + }) + }, + } + cmd.Flags().StringVar(&workspace, "workspace", "", "Workspace to move the location into") + _ = cmd.MarkFlagRequired("workspace") + return cmd +} + +/* ── Shared plumbing ───────────────────────────────────────────── */ + +// gbpRun wires a Business Profile call to the printer. The payload is Google's +// own shape, so the human view is the JSON rather than a table. +func gbpRun( + state *State, + call func(*cobra.Command, *fopost.Client, []string) (any, error), +) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + payload, err := call(cmd, client, args) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(payload, func() { + encoded, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return + } + printer.Line(string(encoded)) + }) + } +} + +// readJSONList reads a JSON array of objects from a file, or stdin for "-". +func readJSONList(path string) ([]map[string]any, error) { + var raw []byte + var err error + if path == "-" { + raw, err = io.ReadAll(os.Stdin) + } else { + raw, err = os.ReadFile(path) + } + if err != nil { + return nil, err + } + var out []map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("reading %s: expected a JSON array of objects: %w", path, err) + } + return out, nil +} diff --git a/internal/cmd/accounts_gbp_test.go b/internal/cmd/accounts_gbp_test.go new file mode 100644 index 0000000..4a49c42 --- /dev/null +++ b/internal/cmd/accounts_gbp_test.go @@ -0,0 +1,178 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/fopost/fopost-cli/internal/config" +) + +// Business Profile management: one invocation per route, pinning the method, +// the path and the body the CLI actually sends. +func TestGBPCommandsHitTheirRoutes(t *testing.T) { + isolate(t) + api := newFakeAPI(t, func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"ok": true}}) + }) + if err := config.Save(&config.File{APIKey: "fp_k", BaseURL: api.URL}); err != nil { + t.Fatal(err) + } + + attributes := filepath.Join(t.TempDir(), "attributes.json") + if err := os.WriteFile(attributes, []byte(`[{"name":"attributes/has_wifi","values":[true]}]`), 0o600); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + args []string + method string + path string + }{ + {"location", []string{"location", "acc_1"}, "GET", "/accounts/acc_1/gbp/location"}, + {"update-location", []string{"update-location", "acc_1", "--title", "Corner Bakery"}, + "PATCH", "/accounts/acc_1/gbp/location"}, + {"attributes", []string{"attributes", "acc_1"}, "GET", "/accounts/acc_1/gbp/attributes"}, + {"update-attributes", []string{"update-attributes", "acc_1", "--file", attributes}, + "PATCH", "/accounts/acc_1/gbp/attributes"}, + {"menus", []string{"menus", "acc_1"}, "GET", "/accounts/acc_1/gbp/menus"}, + {"services", []string{"services", "acc_1"}, "GET", "/accounts/acc_1/gbp/services"}, + {"media", []string{"media", "acc_1"}, "GET", "/accounts/acc_1/gbp/media"}, + {"add-media", []string{"add-media", "acc_1", "--media-id", "m_1", "--category", "INTERIOR"}, + "POST", "/accounts/acc_1/gbp/media"}, + {"delete-media", []string{"delete-media", "acc_1", "CAoSL"}, + "DELETE", "/accounts/acc_1/gbp/media/CAoSL"}, + {"place-actions", []string{"place-actions", "acc_1"}, + "GET", "/accounts/acc_1/gbp/place-actions"}, + {"add-place-action", + []string{"add-place-action", "acc_1", "--uri", "https://example.test/book", "--type", "APPOINTMENT"}, + "POST", "/accounts/acc_1/gbp/place-actions"}, + {"update-place-action", []string{"update-place-action", "acc_1", "links-1", "--preferred"}, + "PATCH", "/accounts/acc_1/gbp/place-actions/links-1"}, + {"delete-place-action", []string{"delete-place-action", "acc_1", "links-1"}, + "DELETE", "/accounts/acc_1/gbp/place-actions/links-1"}, + {"verification", []string{"verification", "acc_1"}, + "GET", "/accounts/acc_1/gbp/verification"}, + {"start-verification", []string{"start-verification", "acc_1", "--method", "sms"}, + "POST", "/accounts/acc_1/gbp/verification/start"}, + {"complete-verification", []string{"complete-verification", "acc_1", "v1", "--pin", "123456"}, + "POST", "/accounts/acc_1/gbp/verification/complete"}, + {"performance", []string{"performance", "acc_1", "--start", "2026-09-01", "--end", "2026-09-07"}, + "GET", "/accounts/acc_1/gbp/performance"}, + {"keywords", []string{"keywords", "acc_1", "--start", "2026-08-01", "--end", "2026-09-01"}, + "GET", "/accounts/acc_1/gbp/performance"}, + {"assign", []string{"assign", "acc_1", "--workspace", "ws_2"}, + "POST", "/accounts/acc_1/gbp/assign"}, + } + + for _, tc := range cases { + args := append([]string{"accounts", "gbp"}, tc.args...) + _, stderr, code := run(t, "", append(args, "--quiet")...) + if code != ExitOK { + t.Fatalf("%s: exit = %d: %s", tc.name, code, stderr) + } + last := api.Requests[len(api.Requests)-1] + if last.Method != tc.method || last.Path != tc.path { + t.Fatalf("%s sent %s %s, want %s %s", tc.name, last.Method, last.Path, tc.method, tc.path) + } + } +} + +func TestGBPUpdateLocationSendsOnlyTheFlagsGiven(t *testing.T) { + isolate(t) + api := newFakeAPI(t, func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{}}) + }) + if err := config.Save(&config.File{APIKey: "fp_k", BaseURL: api.URL}); err != nil { + t.Fatal(err) + } + + // An empty --description clears the field; the untouched flags stay out. + _, stderr, code := run(t, "", "accounts", "gbp", "update-location", "acc_1", + "--title", "Corner Bakery", "--description", "", "--quiet") + if code != ExitOK { + t.Fatalf("exit = %d: %s", code, stderr) + } + + body := api.Requests[len(api.Requests)-1].Body + if len(body) != 2 || body["title"] != "Corner Bakery" || body["description"] != nil { + t.Fatalf("body = %+v", body) + } +} + +func TestGBPAddMediaNamesTheLibraryAsset(t *testing.T) { + isolate(t) + api := newFakeAPI(t, func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{}}) + }) + if err := config.Save(&config.File{APIKey: "fp_k", BaseURL: api.URL}); err != nil { + t.Fatal(err) + } + + if _, stderr, code := run(t, "", "accounts", "gbp", "add-media", "acc_1", + "--media-id", "m_1", "--category", "MENU", "--quiet"); code != ExitOK { + t.Fatalf("exit = %d: %s", code, stderr) + } + + body := api.Requests[len(api.Requests)-1].Body + if body["media_id"] != "m_1" || body["category"] != "MENU" { + t.Fatalf("body = %+v", body) + } +} + +func TestGBPPerformanceRepeatsTheMetricFlag(t *testing.T) { + isolate(t) + var query string + api := newFakeAPI(t, func(w http.ResponseWriter, r *http.Request) { + query = r.URL.RawQuery + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{}}) + }) + if err := config.Save(&config.File{APIKey: "fp_k", BaseURL: api.URL}); err != nil { + t.Fatal(err) + } + + _, stderr, code := run(t, "", "accounts", "gbp", "performance", "acc_1", + "--start", "2026-09-01", "--end", "2026-09-07", + "--metric", "CALL_CLICKS", "--metric", "WEBSITE_CLICKS", "--quiet") + if code != ExitOK { + t.Fatalf("exit = %d: %s", code, stderr) + } + + // url.Values sorts the keys; what matters is that the metric repeats. + parsed, err := url.ParseQuery(query) + if err != nil { + t.Fatal(err) + } + metrics := parsed["daily_metrics"] + if len(metrics) != 2 || metrics[0] != "CALL_CLICKS" || metrics[1] != "WEBSITE_CLICKS" { + t.Fatalf("daily_metrics = %v", metrics) + } + if parsed.Get("start_date") != "2026-09-01" { + t.Fatalf("start_date = %q", parsed.Get("start_date")) + } +} + +func TestGBPSurfacesAPendingAPIGrant(t *testing.T) { + isolate(t) + api := newFakeAPI(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": "configuration_error", "message": "Not available yet", + }) + }) + if err := config.Save(&config.File{APIKey: "fp_k", BaseURL: api.URL}); err != nil { + t.Fatal(err) + } + + _, stderr, code := run(t, "", "accounts", "gbp", "location", "acc_1") + if code == ExitOK { + t.Fatal("expected a non-zero exit while the API grant is pending") + } + if stderr == "" { + t.Fatal("expected the refusal to be explained on stderr") + } +}