From dd0e5a407809e0e9d1102bb8b68b9aeb2763093d Mon Sep 17 00:00:00 2001 From: jsanmartin123 Date: Mon, 28 Sep 2026 11:26:48 -0400 Subject: [PATCH 1/4] BUILD-320 Updates to BUILD CLI --- AGENTS.md | 27 ++++++++--- README.md | 17 ++++--- cmd/account/account_test.go | 47 ++++++++++++++++++- cmd/account/register.go | 50 +++++++++++++-------- cmd/account/send_code.go | 85 +++++++++++++++++++++++++++++++++++ cmd/account/send_code_test.go | 37 +++++++++++++++ cmd/account/verify.go | 71 +++++++++++++++++++++++++++++ 7 files changed, 301 insertions(+), 33 deletions(-) create mode 100644 cmd/account/send_code.go create mode 100644 cmd/account/send_code_test.go create mode 100644 cmd/account/verify.go diff --git a/AGENTS.md b/AGENTS.md index 98d7308..e03230e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -389,27 +389,40 @@ When `--wait` times out (exit code 5), the operation may have succeeded — the ### Build Registration: Create a new account from zero -Use when no credentials exist yet. The CLI submits the registration request; the remaining setup happens in the browser. **An agent cannot complete this flow autonomously** — it requires a human (or an agent with web/phone access) to finish. +Use when no credentials exist yet. The CLI can drive registration and phone +verification end to end; only setting a password still happens in the +browser. **An agent cannot complete the whole flow autonomously** — it can +register and verify the phone number itself, but setting a password (via the +link Bandwidth emails to the registered address) requires a human (or an +agent with email/web access) to finish. ```bash band account register --phone +15555550100 --email you@example.com --first-name Jane --last-name Doe --accept-tos -# → registration submitted; remaining steps happen outside the CLI: -# 1. Check email for a registration link from Bandwidth -# 2. Enter the OTP code sent via SMS to verify the phone number -# 3. Set a password and enter the OTP code from the email -# 4. Go to Account > API Credentials to generate OAuth2 credentials +# → registration submitted (POST /v1/express/registration) + +band account send-code --phone +15555550100 --email you@example.com --delivery-channel sms # or "voice" +# → verification code sent. Choosing "sms" IS the customer's consent to +# receive that one-time code by text — there is no separate flag for it. + +band account verify --phone +15555550100 --email you@example.com --code 123456 +# → phone verified (PHONE_VERIFIED); account provisioning begins. Remaining +# steps happen outside the CLI: +# 1. Check email for a registration link from Bandwidth to set a password +# 2. Go to Account > API Credentials to generate OAuth2 credentials # → once credentials are available: band auth login --client-id --client-secret band auth status # confirm ``` +**`register`'s `--sms-opt-in` is marketing consent, not verification-code delivery consent.** It maps to `promotionalCommsAccepted` — consent to receive marketing/PFT-campaign SMS from Bandwidth, independent of the implicit MFA-delivery consent from choosing `--delivery-channel sms` on `send-code`. It is optional; registration succeeds whether it is set or not. + **Important for agents:** Registration requires accepting the [Bandwidth Build Terms of Service](https://www.bandwidth.com/legal/build-terms-of-service/). Before passing `--accept-tos`, you **must** present the full Terms of Service URL to the user and get their explicit confirmation. Do not accept on the user's behalf without showing them the terms first. The flow should be: 1. Show the user: "Registration requires accepting the Bandwidth Build Terms of Service: https://www.bandwidth.com/legal/build-terms-of-service/" 2. Ask the user to review and confirm they accept 3. Only after confirmation, run the command with `--accept-tos` -After calling `band account register`, stop and tell the user they need to complete setup in their browser. Do not attempt to poll or wait — the next CLI step (`band auth login`) requires credentials that are only available after the human finishes the browser flow. +After calling `band account verify`, stop and tell the user they need to check their email to set a password before generating API credentials. Do not attempt to poll or wait for that step — the next CLI step (`band auth login`) requires credentials that are only available after the human finishes the browser flow. **After login, the account already has a voice app and a phone number.** Build accounts ship with both pre-provisioned. Run `band app list --plain` to discover the voice app — do **not** call `app create` or `number order` on a fresh Build account, you already have what you need to make a call. (`band number list` doesn't work on Build yet; the pre-provisioned number is reachable via the account portal and already wired to the default voice app.) diff --git a/README.md b/README.md index f8ada9f..cd7ef29 100644 --- a/README.md +++ b/README.md @@ -86,14 +86,19 @@ You can sign up for a Bandwidth Build trial account from the CLI: band account register --phone +15555550100 --email you@example.com --first-name Jane --last-name Doe ``` -You'll be prompted to accept the [Bandwidth Build Terms of Service](https://www.bandwidth.com/legal/build-terms-of-service/) before registration proceeds. For scripted usage, pass `--accept-tos`. +You'll be prompted to accept the [Bandwidth Build Terms of Service](https://www.bandwidth.com/legal/build-terms-of-service/) before registration proceeds. For scripted usage, pass `--accept-tos`. Add `--sms-opt-in` if you'd also like to opt in to marketing SMS from Bandwidth (optional). -Then complete setup in your browser: +Then verify your phone number: -1. Check your email for a registration link from Bandwidth -2. Enter the OTP code sent via SMS to verify your phone number -3. Set your password and enter the OTP code from your email -4. Go to **Account > API Credentials** to generate your OAuth2 credentials +```sh +band account send-code --phone +15555550100 --email you@example.com --delivery-channel sms # or "voice" for a phone call +band account verify --phone +15555550100 --email you@example.com --code 123456 +``` + +Then finish setup in your browser: + +1. Check your email for a registration link from Bandwidth to set your password +2. Go to **Account > API Credentials** to generate your OAuth2 credentials Once your credentials are ready, run `band auth login` and you're off. diff --git a/cmd/account/account_test.go b/cmd/account/account_test.go index 3b53e33..ff17373 100644 --- a/cmd/account/account_test.go +++ b/cmd/account/account_test.go @@ -13,8 +13,10 @@ func TestCmdStructure(t *testing.T) { for _, c := range Cmd.Commands() { subs[c.Use] = true } - if !subs["register"] { - t.Errorf("missing subcommand %q", "register") + for _, want := range []string{"register", "send-code", "verify"} { + if !subs[want] { + t.Errorf("missing subcommand %q", want) + } } } @@ -31,3 +33,44 @@ func TestRegisterRequiredFlags(t *testing.T) { } } } + +func TestRegisterSmsOptInFlagNotRequired(t *testing.T) { + f := registerCmd.Flags().Lookup("sms-opt-in") + if f == nil { + t.Fatal("missing flag \"sms-opt-in\"") + } + if _, ok := f.Annotations["cobra_annotation_bash_completion_one_required_flag"]; ok { + t.Error("flag \"sms-opt-in\" should not be required") + } +} + +func TestSendCodeRequiredFlags(t *testing.T) { + for _, flag := range []string{"phone", "email"} { + f := sendCodeCmd.Flags().Lookup(flag) + if f == nil { + t.Errorf("missing flag %q", flag) + continue + } + if _, ok := f.Annotations["cobra_annotation_bash_completion_one_required_flag"]; !ok { + t.Errorf("flag %q should be required", flag) + } + } + if f := sendCodeCmd.Flags().Lookup("delivery-channel"); f == nil { + t.Error("missing flag \"delivery-channel\"") + } else if _, ok := f.Annotations["cobra_annotation_bash_completion_one_required_flag"]; ok { + t.Error("flag \"delivery-channel\" should not be required") + } +} + +func TestVerifyRequiredFlags(t *testing.T) { + for _, flag := range []string{"phone", "email", "code"} { + f := verifyCmd.Flags().Lookup(flag) + if f == nil { + t.Errorf("missing flag %q", flag) + continue + } + if _, ok := f.Annotations["cobra_annotation_bash_completion_one_required_flag"]; !ok { + t.Errorf("flag %q should be required", flag) + } + } +} diff --git a/cmd/account/register.go b/cmd/account/register.go index 276e757..b28d8c7 100644 --- a/cmd/account/register.go +++ b/cmd/account/register.go @@ -20,16 +20,22 @@ var ( registerFirstName string registerLastName string registerAcceptTOS bool + registerSmsOptIn bool ) const tosURL = "https://www.bandwidth.com/legal/build-terms-of-service/" +// registrationBaseURL is the legacy Build registration host/path prefix. +// Unauthenticated: there is no account yet at this point in the flow. +const registrationBaseURL = "https://api.bandwidth.com/v1/express" + func init() { registerCmd.Flags().StringVar(®isterPhone, "phone", "", "Phone number (required)") registerCmd.Flags().StringVar(®isterEmail, "email", "", "Email address (required)") registerCmd.Flags().StringVar(®isterFirstName, "first-name", "", "First name (required)") registerCmd.Flags().StringVar(®isterLastName, "last-name", "", "Last name (required)") registerCmd.Flags().BoolVar(®isterAcceptTOS, "accept-tos", false, "Accept the Build Terms of Service (required; use for non-interactive mode)") + registerCmd.Flags().BoolVar(®isterSmsOptIn, "sms-opt-in", false, "Opt in to marketing SMS/communications from Bandwidth (optional; independent of MFA delivery consent)") _ = registerCmd.MarkFlagRequired("phone") _ = registerCmd.MarkFlagRequired("email") _ = registerCmd.MarkFlagRequired("first-name") @@ -42,13 +48,18 @@ var registerCmd = &cobra.Command{ Short: "Create a new Bandwidth Build account", Long: `Creates a new Bandwidth Build account. -After registration, complete account setup in your browser: - 1. Check your email for a registration link from Bandwidth - 2. Enter the OTP code sent via SMS to verify your phone number - 3. Set your password and enter the OTP code from your email +After registration, verify your phone number and complete setup: + 1. band account send-code --phone --email --delivery-channel sms (or "voice") + 2. band account verify --phone --email --code + 3. Check your email for a registration link from Bandwidth to set your password 4. Go to Account > API Credentials to generate OAuth2 credentials - 5. Run "band auth login" with those credentials`, - Example: ` band account register --phone +19195551234 --email user@example.com --first-name John --last-name Doe`, + 5. Run "band auth login" with those credentials + +--sms-opt-in records consent to marketing/PFT-campaign SMS. It is independent +of the MFA delivery consent implied by choosing "sms" as the delivery channel +on "band account send-code" — omit it (or pass --sms-opt-in=false) for no +marketing consent; registration succeeds either way.`, + Example: ` band account register --phone +19195551234 --email user@example.com --first-name John --last-name Doe --sms-opt-in`, RunE: runRegister, } @@ -83,14 +94,15 @@ func runRegister(cmd *cobra.Command, args []string) error { return fmt.Errorf("registration cancelled — you must accept the Build Terms of Service to proceed") } - client := api.NewClientNoAuth("https://api.bandwidth.com/v1/express") + client := api.NewClientNoAuth(registrationBaseURL) reqBody := map[string]interface{}{ - "phoneNumber": registerPhone, - "email": registerEmail, - "firstName": registerFirstName, - "lastName": registerLastName, - "tosAccepted": true, + "phoneNumber": registerPhone, + "email": registerEmail, + "firstName": registerFirstName, + "lastName": registerLastName, + "tosAccepted": true, + "promotionalCommsAccepted": registerSmsOptIn, } var result interface{} @@ -105,12 +117,14 @@ func runRegister(cmd *cobra.Command, args []string) error { fmt.Fprintln(os.Stderr) ui.Successf("Registration submitted!") - ui.Headerf("Next steps (complete in your browser):") - ui.Infof("1. Check your email (%s) for a registration link from Bandwidth", registerEmail) - ui.Infof("2. Enter the OTP code sent via SMS to %s", registerPhone) - ui.Infof("3. Set your password and enter the OTP code from your email") - ui.Infof("4. Go to Account > API Credentials to generate your OAuth2 credentials") - ui.Infof("5. Run: band auth login --client-id --client-secret ") + ui.Headerf("Next steps:") + ui.Infof("1. Verify your phone number:") + ui.Infof(" band account send-code --phone %s --email %s --delivery-channel sms", registerPhone, registerEmail) + ui.Infof(" band account verify --phone %s --email %s --code ", registerPhone, registerEmail) + ui.Infof(" (pass --delivery-channel voice on send-code for a phone call instead of a text)") + ui.Infof("2. Check your email (%s) for a registration link from Bandwidth to set your password", registerEmail) + ui.Infof("3. Go to Account > API Credentials to generate your OAuth2 credentials") + ui.Infof("4. Run: band auth login --client-id --client-secret ") return nil } diff --git a/cmd/account/send_code.go b/cmd/account/send_code.go new file mode 100644 index 0000000..886ca05 --- /dev/null +++ b/cmd/account/send_code.go @@ -0,0 +1,85 @@ +package account + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" + "github.com/Bandwidth/cli/internal/ui" +) + +var ( + sendCodePhone string + sendCodeEmail string + sendCodeDeliveryChannel string +) + +func init() { + sendCodeCmd.Flags().StringVar(&sendCodePhone, "phone", "", "Phone number in E.164 format, matching a pending registration (required)") + sendCodeCmd.Flags().StringVar(&sendCodeEmail, "email", "", "Email address used during registration (required)") + sendCodeCmd.Flags().StringVar(&sendCodeDeliveryChannel, "delivery-channel", "sms", "Verification code delivery channel: sms or voice") + _ = sendCodeCmd.MarkFlagRequired("phone") + _ = sendCodeCmd.MarkFlagRequired("email") + Cmd.AddCommand(sendCodeCmd) +} + +var sendCodeCmd = &cobra.Command{ + Use: "send-code", + Short: "Send (or resend) a phone verification code for a pending registration", + Long: `Sends a verification code to the phone number from a pending "band account register" call. + +There is no separate MFA-consent flag: choosing "sms" is itself the +customer's consent to receive that one-time code by text message. Choosing +"voice" places a phone call instead and records no such consent. This is +independent of "band account register"'s --sms-opt-in, which is marketing +consent, not verification-code delivery consent.`, + Example: ` band account send-code --phone +19195551234 --email user@example.com --delivery-channel sms + band account send-code --phone +19195551234 --email user@example.com --delivery-channel voice`, + RunE: runSendCode, +} + +// normalizeDeliveryChannel upper-cases and validates a --delivery-channel +// value against the wire enum (SMS, VOICE). +func normalizeDeliveryChannel(raw string) (string, error) { + channel := strings.ToUpper(strings.TrimSpace(raw)) + if channel != "SMS" && channel != "VOICE" { + return "", cmdutil.NewFlagError("--delivery-channel must be one of: sms, voice") + } + return channel, nil +} + +func runSendCode(cmd *cobra.Command, args []string) error { + channel, err := normalizeDeliveryChannel(sendCodeDeliveryChannel) + if err != nil { + return err + } + + client := api.NewClientNoAuth(registrationBaseURL) + + reqBody := map[string]interface{}{ + "phoneNumber": sendCodePhone, + "email": sendCodeEmail, + "deliveryChannel": channel, + } + + var result interface{} + if err := client.Post(cmd.Context(), "/registration/code", reqBody, &result); err != nil { + return fmt.Errorf("sending verification code: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + if err := output.StdoutAuto(format, plain, result); err != nil { + return err + } + + fmt.Fprintln(os.Stderr) + ui.Successf("Verification code sent via %s", strings.ToLower(channel)) + ui.Infof("Next: band account verify --phone %s --email %s --code ", sendCodePhone, sendCodeEmail) + + return nil +} diff --git a/cmd/account/send_code_test.go b/cmd/account/send_code_test.go new file mode 100644 index 0000000..8a49d4d --- /dev/null +++ b/cmd/account/send_code_test.go @@ -0,0 +1,37 @@ +package account + +import "testing" + +func TestNormalizeDeliveryChannel(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantErr bool + }{ + {name: "lowercase sms", raw: "sms", want: "SMS"}, + {name: "lowercase voice", raw: "voice", want: "VOICE"}, + {name: "uppercase already", raw: "SMS", want: "SMS"}, + {name: "mixed case with whitespace", raw: " Voice ", want: "VOICE"}, + {name: "invalid value", raw: "email", wantErr: true}, + {name: "empty value", raw: "", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeDeliveryChannel(tt.raw) + if tt.wantErr { + if err == nil { + t.Fatalf("normalizeDeliveryChannel(%q) = %q, want error", tt.raw, got) + } + return + } + if err != nil { + t.Fatalf("normalizeDeliveryChannel(%q) unexpected error: %v", tt.raw, err) + } + if got != tt.want { + t.Errorf("normalizeDeliveryChannel(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} diff --git a/cmd/account/verify.go b/cmd/account/verify.go new file mode 100644 index 0000000..156f452 --- /dev/null +++ b/cmd/account/verify.go @@ -0,0 +1,71 @@ +package account + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" + "github.com/Bandwidth/cli/internal/ui" +) + +var ( + verifyPhone string + verifyEmail string + verifyCode string +) + +func init() { + verifyCmd.Flags().StringVar(&verifyPhone, "phone", "", "Phone number in E.164 format being verified (required)") + verifyCmd.Flags().StringVar(&verifyEmail, "email", "", "Email address used during registration (required)") + verifyCmd.Flags().StringVar(&verifyCode, "code", "", "6-digit verification code received via SMS or voice call (required)") + _ = verifyCmd.MarkFlagRequired("phone") + _ = verifyCmd.MarkFlagRequired("email") + _ = verifyCmd.MarkFlagRequired("code") + Cmd.AddCommand(verifyCmd) +} + +var verifyCmd = &cobra.Command{ + Use: "verify", + Short: "Verify a phone number with the code from \"band account send-code\"", + Long: `Validates the verification code sent to a registered phone number. + +On success, the registration moves to PHONE_VERIFIED and account +provisioning begins in the background. Setting a password still requires +following the link Bandwidth emails to the registered address — that step +has no CLI equivalent.`, + Example: ` band account verify --phone +19195551234 --email user@example.com --code 123456`, + RunE: runVerify, +} + +func runVerify(cmd *cobra.Command, args []string) error { + client := api.NewClientNoAuth(registrationBaseURL) + + reqBody := map[string]interface{}{ + "phoneNumber": verifyPhone, + "email": verifyEmail, + "code": verifyCode, + } + + var result interface{} + if err := client.Post(cmd.Context(), "/registration/code/verify", reqBody, &result); err != nil { + return fmt.Errorf("verifying phone number: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + if err := output.StdoutAuto(format, plain, result); err != nil { + return err + } + + fmt.Fprintln(os.Stderr) + ui.Successf("Phone number verified! Account provisioning has begun.") + ui.Headerf("Next steps:") + ui.Infof("1. Check your email (%s) for a registration link from Bandwidth to set your password", verifyEmail) + ui.Infof("2. Go to Account > API Credentials to generate your OAuth2 credentials") + ui.Infof("3. Run: band auth login --client-id --client-secret ") + + return nil +} From 47e356c30f704ce86ed3e25cbb7add34a3777732 Mon Sep 17 00:00:00 2001 From: jsanmartin123 Date: Mon, 28 Sep 2026 15:19:15 -0400 Subject: [PATCH 2/4] BUILD-320 Some improvements --- AGENTS.md | 4 ++++ cmd/account/account_test.go | 10 ++++------ cmd/account/send_code.go | 3 ++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e03230e..4df38cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -404,6 +404,10 @@ band account send-code --phone +15555550100 --email you@example.com --delivery-c # → verification code sent. Choosing "sms" IS the customer's consent to # receive that one-time code by text — there is no separate flag for it. +# STOP: the code is delivered out-of-band (a text message or phone call to +# the registered number) — an agent cannot read it. Wait for a human to +# supply the real code before running verify; do not fabricate one or reuse +# a code from a different registration. "123456" below is illustrative only. band account verify --phone +15555550100 --email you@example.com --code 123456 # → phone verified (PHONE_VERIFIED); account provisioning begins. Remaining # steps happen outside the CLI: diff --git a/cmd/account/account_test.go b/cmd/account/account_test.go index ff17373..d431b5d 100644 --- a/cmd/account/account_test.go +++ b/cmd/account/account_test.go @@ -45,7 +45,10 @@ func TestRegisterSmsOptInFlagNotRequired(t *testing.T) { } func TestSendCodeRequiredFlags(t *testing.T) { - for _, flag := range []string{"phone", "email"} { + // delivery-channel is required, not defaulted: the choice of "sms" is + // itself the customer's MFA-delivery consent, so it must be explicit + // rather than silently assumed when the caller omits the flag. + for _, flag := range []string{"phone", "email", "delivery-channel"} { f := sendCodeCmd.Flags().Lookup(flag) if f == nil { t.Errorf("missing flag %q", flag) @@ -55,11 +58,6 @@ func TestSendCodeRequiredFlags(t *testing.T) { t.Errorf("flag %q should be required", flag) } } - if f := sendCodeCmd.Flags().Lookup("delivery-channel"); f == nil { - t.Error("missing flag \"delivery-channel\"") - } else if _, ok := f.Annotations["cobra_annotation_bash_completion_one_required_flag"]; ok { - t.Error("flag \"delivery-channel\" should not be required") - } } func TestVerifyRequiredFlags(t *testing.T) { diff --git a/cmd/account/send_code.go b/cmd/account/send_code.go index 886ca05..4588be2 100644 --- a/cmd/account/send_code.go +++ b/cmd/account/send_code.go @@ -22,9 +22,10 @@ var ( func init() { sendCodeCmd.Flags().StringVar(&sendCodePhone, "phone", "", "Phone number in E.164 format, matching a pending registration (required)") sendCodeCmd.Flags().StringVar(&sendCodeEmail, "email", "", "Email address used during registration (required)") - sendCodeCmd.Flags().StringVar(&sendCodeDeliveryChannel, "delivery-channel", "sms", "Verification code delivery channel: sms or voice") + sendCodeCmd.Flags().StringVar(&sendCodeDeliveryChannel, "delivery-channel", "", "Verification code delivery channel: sms or voice (required — the choice itself is the customer's consent to receive the code via that channel)") _ = sendCodeCmd.MarkFlagRequired("phone") _ = sendCodeCmd.MarkFlagRequired("email") + _ = sendCodeCmd.MarkFlagRequired("delivery-channel") Cmd.AddCommand(sendCodeCmd) } From 5a9d2f332b4f540dad427283b929480588f18b25 Mon Sep 17 00:00:00 2001 From: jsanmartin123 Date: Tue, 29 Sep 2026 08:12:55 -0400 Subject: [PATCH 3/4] BUILD-320 Readme updates --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cd7ef29..3b54211 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Then verify your phone number: ```sh band account send-code --phone +15555550100 --email you@example.com --delivery-channel sms # or "voice" for a phone call -band account verify --phone +15555550100 --email you@example.com --code 123456 +band account verify --phone +15555550100 --email you@example.com --code 123456 # use the code you actually received ``` Then finish setup in your browser: @@ -392,7 +392,9 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f | Command | What it does | |---------|-------------| -| `band account register` | Register a new Bandwidth account | +| `band account register` | Register a new Bandwidth account (`--sms-opt-in` to opt in to marketing SMS) | +| `band account send-code` | Send (or resend) a phone verification code (`--delivery-channel sms\|voice`, required) | +| `band account verify` | Verify a phone number with the code from `send-code` | ### Applications From 14e67645d1adff8582d4fdcbfbb70824f641ee6f Mon Sep 17 00:00:00 2001 From: jsanmartin123 Date: Tue, 29 Sep 2026 08:27:02 -0400 Subject: [PATCH 4/4] BUILD-320 Add more robust testing --- cmd/account/golden_test.go | 158 ++++++++++++++++++++++++++++++++++++ cmd/account/register.go | 12 ++- cmd/account/send_code.go | 6 +- cmd/account/verify.go | 6 +- internal/cmdutil/helpers.go | 5 ++ internal/testutil/golden.go | 18 +++- 6 files changed, 193 insertions(+), 12 deletions(-) create mode 100644 cmd/account/golden_test.go diff --git a/cmd/account/golden_test.go b/cmd/account/golden_test.go new file mode 100644 index 0000000..f6e457a --- /dev/null +++ b/cmd/account/golden_test.go @@ -0,0 +1,158 @@ +package account + +import ( + "testing" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/testutil" +) + +// swapRegistrationClient substitutes registrationClient with fake, restoring it on cleanup; no t.Parallel() on callers (mutates a global, like cmdutil.VoiceClient). +func swapRegistrationClient(t *testing.T, fake *testutil.FakeClient) { + t.Helper() + orig := registrationClient + t.Cleanup(func() { registrationClient = orig }) + registrationClient = func(string) (api.Requester, string, error) { + return fake, "", nil + } +} + +func TestRegisterPlainOutput(t *testing.T) { + fake := &testutil.FakeClient{PostResult: map[string]interface{}{ + "links": []interface{}{}, + "data": map[string]interface{}{ + "message": "Onboarding request received successfully", + "status": "USER_CREATION_PENDING", + "registrationId": "reg-123", + }, + "errors": []interface{}{}, + }} + swapRegistrationClient(t, fake) + + root := testutil.NewTestRoot(registerCmd) + root.SetArgs([]string{ + "register", + "--phone", "+19195551234", + "--email", "user@example.com", + "--first-name", "Jane", + "--last-name", "Doe", + "--accept-tos", + "--sms-opt-in", + "--plain", + }) + + out := testutil.CaptureStdout(t, func() { + if err := root.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + }) + + want := "{\n \"message\": \"Onboarding request received successfully\",\n \"registrationId\": \"reg-123\",\n \"status\": \"USER_CREATION_PENDING\"\n}\n" + if out != want { + t.Fatalf("golden mismatch:\n got: %q\nwant: %q", out, want) + } + + if fake.PostPath != "/registration" { + t.Errorf("PostPath = %q, want %q", fake.PostPath, "/registration") + } + body, ok := fake.PostBody.(map[string]interface{}) + if !ok { + t.Fatalf("PostBody is %T, want map[string]interface{}", fake.PostBody) + } + if body["phoneNumber"] != "+19195551234" || body["email"] != "user@example.com" { + t.Errorf("unexpected phone/email in request body: %+v", body) + } + if body["tosAccepted"] != true { + t.Errorf("tosAccepted = %v, want true", body["tosAccepted"]) + } + if body["promotionalCommsAccepted"] != true { + t.Errorf("promotionalCommsAccepted = %v, want true (--sms-opt-in was passed)", body["promotionalCommsAccepted"]) + } +} + +func TestSendCodePlainOutput(t *testing.T) { + fake := &testutil.FakeClient{PostResult: map[string]interface{}{ + "links": []interface{}{}, + "data": map[string]interface{}{ + "message": "Code successfully sent to +19195551234", + "status": "VERIFICATION_CODE_SENT", + }, + "errors": []interface{}{}, + }} + swapRegistrationClient(t, fake) + + root := testutil.NewTestRoot(sendCodeCmd) + root.SetArgs([]string{ + "send-code", + "--phone", "+19195551234", + "--email", "user@example.com", + "--delivery-channel", " Voice ", // normalization: mixed case + whitespace + "--plain", + }) + + out := testutil.CaptureStdout(t, func() { + if err := root.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + }) + + want := "{\n \"message\": \"Code successfully sent to +19195551234\",\n \"status\": \"VERIFICATION_CODE_SENT\"\n}\n" + if out != want { + t.Fatalf("golden mismatch:\n got: %q\nwant: %q", out, want) + } + + if fake.PostPath != "/registration/code" { + t.Errorf("PostPath = %q, want %q", fake.PostPath, "/registration/code") + } + body, ok := fake.PostBody.(map[string]interface{}) + if !ok { + t.Fatalf("PostBody is %T, want map[string]interface{}", fake.PostBody) + } + if body["deliveryChannel"] != "VOICE" { + t.Errorf("deliveryChannel = %v, want normalized %q", body["deliveryChannel"], "VOICE") + } +} + +func TestVerifyPlainOutput(t *testing.T) { + fake := &testutil.FakeClient{PostResult: map[string]interface{}{ + "links": []interface{}{}, + "data": map[string]interface{}{ + "message": "+19195551234 successfully verified", + "status": "PHONE_VERIFIED", + "registrationId": "reg-123", + }, + "errors": []interface{}{}, + }} + swapRegistrationClient(t, fake) + + root := testutil.NewTestRoot(verifyCmd) + root.SetArgs([]string{ + "verify", + "--phone", "+19195551234", + "--email", "user@example.com", + "--code", "123456", + "--plain", + }) + + out := testutil.CaptureStdout(t, func() { + if err := root.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + }) + + want := "{\n \"message\": \"+19195551234 successfully verified\",\n \"registrationId\": \"reg-123\",\n \"status\": \"PHONE_VERIFIED\"\n}\n" + if out != want { + t.Fatalf("golden mismatch:\n got: %q\nwant: %q", out, want) + } + + if fake.PostPath != "/registration/code/verify" { + t.Errorf("PostPath = %q, want %q", fake.PostPath, "/registration/code/verify") + } + body, ok := fake.PostBody.(map[string]interface{}) + if !ok { + t.Fatalf("PostBody is %T, want map[string]interface{}", fake.PostBody) + } + if body["code"] != "123456" { + t.Errorf("code = %v, want %q", body["code"], "123456") + } +} diff --git a/cmd/account/register.go b/cmd/account/register.go index b28d8c7..fffeef3 100644 --- a/cmd/account/register.go +++ b/cmd/account/register.go @@ -25,9 +25,10 @@ var ( const tosURL = "https://www.bandwidth.com/legal/build-terms-of-service/" -// registrationBaseURL is the legacy Build registration host/path prefix. -// Unauthenticated: there is no account yet at this point in the flow. -const registrationBaseURL = "https://api.bandwidth.com/v1/express" +// registrationClient is a swappable ClientFunc seam for tests (see cmdutil.VoiceClient); accountIDOverride is unused — no account exists yet. +var registrationClient cmdutil.ClientFunc = func(string) (api.Requester, string, error) { + return api.NewClientNoAuth(cmdutil.RegistrationHost()), "", nil +} func init() { registerCmd.Flags().StringVar(®isterPhone, "phone", "", "Phone number (required)") @@ -94,7 +95,10 @@ func runRegister(cmd *cobra.Command, args []string) error { return fmt.Errorf("registration cancelled — you must accept the Build Terms of Service to proceed") } - client := api.NewClientNoAuth(registrationBaseURL) + client, _, err := registrationClient("") + if err != nil { + return err + } reqBody := map[string]interface{}{ "phoneNumber": registerPhone, diff --git a/cmd/account/send_code.go b/cmd/account/send_code.go index 4588be2..fba8a94 100644 --- a/cmd/account/send_code.go +++ b/cmd/account/send_code.go @@ -7,7 +7,6 @@ import ( "github.com/spf13/cobra" - "github.com/Bandwidth/cli/internal/api" "github.com/Bandwidth/cli/internal/cmdutil" "github.com/Bandwidth/cli/internal/output" "github.com/Bandwidth/cli/internal/ui" @@ -60,7 +59,10 @@ func runSendCode(cmd *cobra.Command, args []string) error { return err } - client := api.NewClientNoAuth(registrationBaseURL) + client, _, err := registrationClient("") + if err != nil { + return err + } reqBody := map[string]interface{}{ "phoneNumber": sendCodePhone, diff --git a/cmd/account/verify.go b/cmd/account/verify.go index 156f452..57bf2a8 100644 --- a/cmd/account/verify.go +++ b/cmd/account/verify.go @@ -6,7 +6,6 @@ import ( "github.com/spf13/cobra" - "github.com/Bandwidth/cli/internal/api" "github.com/Bandwidth/cli/internal/cmdutil" "github.com/Bandwidth/cli/internal/output" "github.com/Bandwidth/cli/internal/ui" @@ -42,7 +41,10 @@ has no CLI equivalent.`, } func runVerify(cmd *cobra.Command, args []string) error { - client := api.NewClientNoAuth(registrationBaseURL) + client, _, err := registrationClient("") + if err != nil { + return err + } reqBody := map[string]interface{}{ "phoneNumber": verifyPhone, diff --git a/internal/cmdutil/helpers.go b/internal/cmdutil/helpers.go index e0c2f51..3834dab 100644 --- a/internal/cmdutil/helpers.go +++ b/internal/cmdutil/helpers.go @@ -76,6 +76,11 @@ func apiHostForEnvironment(env string) string { } } +// RegistrationHost returns the Build registration API base URL, honoring BW_API_URL like every other client in this file. +func RegistrationHost() string { + return apiHostForEnvironment("") + "/v1/express" +} + // voiceHostForEnvironment maps an environment name to its Voice API host. // Non-production environments can be overridden with BW_VOICE_URL. func voiceHostForEnvironment(env string) string { diff --git a/internal/testutil/golden.go b/internal/testutil/golden.go index 5091d88..55a3b03 100644 --- a/internal/testutil/golden.go +++ b/internal/testutil/golden.go @@ -15,18 +15,28 @@ import ( "github.com/spf13/cobra" ) -// FakeClient implements api.Requester. Get marshals GetResult into the caller's -// result pointer (a JSON round-trip), mimicking a real API response; the other -// methods are no-ops. Set GetResult to the canned fixture for the command. +// FakeClient implements api.Requester; Get/Post marshal GetResult/PostResult into the result pointer, and Post also records the last path/body sent. type FakeClient struct { GetResult interface{} + + PostResult interface{} + PostPath string // path from the most recent Post call + PostBody interface{} // body from the most recent Post call } func (f *FakeClient) Get(_ context.Context, path string, result interface{}) error { b, _ := json.Marshal(f.GetResult) return json.Unmarshal(b, result) } -func (f *FakeClient) Post(context.Context, string, interface{}, interface{}) error { return nil } +func (f *FakeClient) Post(_ context.Context, path string, body, result interface{}) error { + f.PostPath = path + f.PostBody = body + if result == nil { + return nil + } + b, _ := json.Marshal(f.PostResult) + return json.Unmarshal(b, result) +} func (f *FakeClient) Put(context.Context, string, interface{}, interface{}) error { return nil } func (f *FakeClient) Patch(context.Context, string, interface{}, interface{}) error { return nil } func (f *FakeClient) Delete(context.Context, string, interface{}) error { return nil }