From 67b921b4dc43baa8884525cba1c83c9448c4e800 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 03:36:39 +0200 Subject: [PATCH] feat(google-business): manage a Business Profile location from the CLI fopost accounts gbp covers the profile, attributes, food menus, services, photos, place action links, verification, performance and search keywords, plus assign, which hands the location to another workspace. The payloads are Google's own shape, so the human view is the JSON rather than a table that would go stale the moment Google adds a field. fopost-go is pinned to the branch commit that carries the GoogleBusiness service, so this builds before that SDK is released; the pin becomes a version bump once it is. --- README.md | 10 +- go.mod | 2 +- go.sum | 10 +- internal/cmd/accounts.go | 1 + internal/cmd/accounts_gbp.go | 502 ++++++++++++++++++++++++++++++ internal/cmd/accounts_gbp_test.go | 178 +++++++++++ 6 files changed, 691 insertions(+), 12 deletions(-) create mode 100644 internal/cmd/accounts_gbp.go create mode 100644 internal/cmd/accounts_gbp_test.go 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") + } +}