Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions internal/cli/cmdtest/xcode_cloud_build_runs_workflow_hint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package cmdtest

import (
"context"
"errors"
"flag"
"io"
"strings"
"testing"

"github.com/rudrankriyam/App-Store-Connect-CLI/internal/cli/shared"
)

// TestXcodeCloudBuildRunsListMissingWorkflowIDPointsAtDiscoveryCommand asserts
// that the bare invocation tells callers how to obtain a workflow ID instead of
// dead-ending on the bare required-flag message.
func TestXcodeCloudBuildRunsListMissingWorkflowIDPointsAtDiscoveryCommand(t *testing.T) {
for _, args := range [][]string{
{"xcode-cloud", "build-runs"},
{"xcode-cloud", "build-runs", "list"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
root := RootCommand("1.2.3")
root.FlagSet.SetOutput(io.Discard)

var runErr error
_, stderr := captureOutput(t, func() {
if err := root.Parse(args); err != nil {
t.Fatalf("parse error: %v", err)
}
runErr = root.Run(context.Background())
})

if !errors.Is(runErr, flag.ErrHelp) {
t.Fatalf("expected ErrHelp, got %v", runErr)
}
if !strings.Contains(stderr, "Error: --workflow-id is required.") {
t.Fatalf("expected required-flag error in stderr, got %q", stderr)
}
if !strings.Contains(stderr, `Find workflow IDs with: asc xcode-cloud workflows list --app "APP_ID"`) {
t.Fatalf("expected workflow discovery hint in stderr, got %q", stderr)
}
if strings.ContainsAny(stderr, "<>") {
t.Fatalf("workflow discovery hint must not contain shell redirection characters: %q", stderr)
}

diagnostic, ok := shared.DiagnosticFromError(runErr)
if !ok {
t.Fatalf("expected structured diagnostic, got %v", runErr)
}
if diagnostic.Code != shared.DiagnosticRequiredInputMissing || diagnostic.Parameter != "--workflow-id" {
t.Fatalf("diagnostic = %+v, want required_input_missing for --workflow-id", diagnostic)
}
})
}
}

// TestXcodeCloudParentListMissingIDsKeepUnhintedMessage guards the sibling
// commands that share the parent-list helper: they must keep the plain
// required-flag message and their own structured parameter.
func TestXcodeCloudParentListMissingIDsKeepUnhintedMessage(t *testing.T) {
tests := []struct {
name string
args []string
wantParameter string
}{
{
name: "build run builds",
args: []string{"xcode-cloud", "build-runs", "builds"},
wantParameter: "--run-id",
},
{
name: "build action list",
args: []string{"xcode-cloud", "actions", "list"},
wantParameter: "--run-id",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := RootCommand("1.2.3")
root.FlagSet.SetOutput(io.Discard)

var runErr error
_, stderr := captureOutput(t, func() {
if err := root.Parse(test.args); err != nil {
t.Fatalf("parse error: %v", err)
}
runErr = root.Run(context.Background())
})

if !errors.Is(runErr, flag.ErrHelp) {
t.Fatalf("expected ErrHelp, got %v", runErr)
}
if !strings.Contains(stderr, "Error: "+test.wantParameter+" is required\n") {
t.Fatalf("expected plain required-flag error in stderr, got %q", stderr)
}
if strings.Contains(stderr, "Find workflow IDs with") {
t.Fatalf("did not expect workflow hint for %v, got %q", test.args, stderr)
}

diagnostic, ok := shared.DiagnosticFromError(runErr)
if !ok {
t.Fatalf("expected structured diagnostic, got %v", runErr)
}
if diagnostic.Code != shared.DiagnosticRequiredInputMissing || diagnostic.Parameter != test.wantParameter {
t.Fatalf("diagnostic = %+v, want required_input_missing for %q", diagnostic, test.wantParameter)
}
})
}
}
12 changes: 8 additions & 4 deletions internal/cli/web/web_review_subscriptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -450,20 +450,24 @@ Subcommands:
func WebReviewSubscriptionsListCommand() *ffcli.Command {
fs := flag.NewFlagSet("web review subscriptions list", flag.ExitOnError)

appID := fs.String("app", "", "App ID")
appID := fs.String("app", "", "App ID (or ASC_APP_ID env)")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
authFlags := bindWebSessionFlags(fs)
output := shared.BindOutputFlags(fs)

return &ffcli.Command{
Name: "list",
ShortUsage: "asc web review subscriptions list --app APP_ID [flags]",
ShortUsage: "asc web review subscriptions list [--app APP_ID] [flags]",
ShortHelp: "List subscriptions and next-version attach state.",
FlagSet: fs,
UsageFunc: shared.DefaultUsageFunc,
Exec: func(ctx context.Context, args []string) error {
trimmedAppID := strings.TrimSpace(*appID)
trimmedAppID := strings.TrimSpace(shared.ResolveAppID(*appID))
if trimmedAppID == "" {
return shared.UsageError("--app is required")
return shared.WithDiagnostic(
shared.UsageError("--app is required (or set ASC_APP_ID)"),
shared.DiagnosticRequiredInputMissing,
"--app",
)
}

requestCtx, cancel := shared.ContextWithTimeout(ctx)
Expand Down
146 changes: 146 additions & 0 deletions internal/cli/web/web_review_subscriptions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"flag"
"io"
"net/http"
"strings"
Expand Down Expand Up @@ -1558,6 +1559,151 @@ func TestBuildReviewSubscriptionMutationRowsFallbacks(t *testing.T) {
}
}

func TestWebReviewSubscriptionsListCommandResolvesAppFromEnv(t *testing.T) {
_ = stubWebProgressLabels(t)
t.Setenv("ASC_APP_ID", "123")

origResolveSession := resolveSessionFn
t.Cleanup(func() { resolveSessionFn = origResolveSession })

var requestedPaths []string
resolveSessionFn = func(ctx context.Context, appleID, password, twoFactorCode string) (*webcore.AuthSession, string, error) {
return &webcore.AuthSession{
Client: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requestedPaths = append(requestedPaths, req.URL.Path)
body := `{"data": [], "included": []}`
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
}),
},
}, "cache", nil
}

cmd := WebReviewSubscriptionsListCommand()
if err := cmd.FlagSet.Parse([]string{"--output", "json"}); err != nil {
t.Fatalf("parse error: %v", err)
}

stdout, _ := captureOutput(t, func() {
if err := cmd.Exec(context.Background(), nil); err != nil {
t.Fatalf("exec error: %v", err)
}
})

if len(requestedPaths) != 1 || requestedPaths[0] != "/iris/v1/apps/123/subscriptionGroups" {
t.Fatalf("expected request scoped to ASC_APP_ID app 123, got %#v", requestedPaths)
}

var payload reviewSubscriptionsListOutput
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
t.Fatalf("failed to parse stdout JSON: %v\nstdout=%s", err, stdout)
}
if payload.AppID != "123" {
t.Fatalf("expected payload app id 123, got %#v", payload)
}
}

func TestWebReviewSubscriptionsListCommandExplicitFlagWinsOverEnv(t *testing.T) {
_ = stubWebProgressLabels(t)
t.Setenv("ASC_APP_ID", "999")

origResolveSession := resolveSessionFn
t.Cleanup(func() { resolveSessionFn = origResolveSession })

var requestedPaths []string
resolveSessionFn = func(ctx context.Context, appleID, password, twoFactorCode string) (*webcore.AuthSession, string, error) {
return &webcore.AuthSession{
Client: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requestedPaths = append(requestedPaths, req.URL.Path)
body := `{"data": [], "included": []}`
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
}),
},
}, "cache", nil
}

cmd := WebReviewSubscriptionsListCommand()
if err := cmd.FlagSet.Parse([]string{"--app", "app-1", "--output", "json"}); err != nil {
t.Fatalf("parse error: %v", err)
}

stdout, _ := captureOutput(t, func() {
if err := cmd.Exec(context.Background(), nil); err != nil {
t.Fatalf("exec error: %v", err)
}
})

if len(requestedPaths) != 1 || requestedPaths[0] != "/iris/v1/apps/app-1/subscriptionGroups" {
t.Fatalf("expected explicit --app to win over ASC_APP_ID, got %#v", requestedPaths)
}

var payload reviewSubscriptionsListOutput
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
t.Fatalf("failed to parse stdout JSON: %v\nstdout=%s", err, stdout)
}
if payload.AppID != "app-1" {
t.Fatalf("expected payload app id app-1, got %#v", payload)
}
}

func TestWebReviewSubscriptionsListCommandMissingAppReportsFallbackAndDiagnostic(t *testing.T) {
t.Setenv("ASC_APP_ID", "")
origResolveSession := resolveSessionFn
t.Cleanup(func() { resolveSessionFn = origResolveSession })
sessionCalls := 0
resolveSessionFn = func(ctx context.Context, appleID, password, twoFactorCode string) (*webcore.AuthSession, string, error) {
sessionCalls++
return nil, "", errors.New("session resolution must not run")
}

cmd := WebReviewSubscriptionsListCommand()
if cmd.ShortUsage != "asc web review subscriptions list [--app APP_ID] [flags]" {
t.Fatalf("ShortUsage = %q, want optional --app", cmd.ShortUsage)
}
if err := cmd.FlagSet.Parse(nil); err != nil {
t.Fatalf("parse error: %v", err)
}

var runErr error
stdout, stderr := captureOutput(t, func() {
runErr = cmd.Exec(context.Background(), nil)
})
if !errors.Is(runErr, flag.ErrHelp) {
t.Fatalf("error = %v, want flag.ErrHelp", runErr)
}
if runErr.Error() != "--app is required (or set ASC_APP_ID)" {
t.Fatalf("error = %q, want exact missing-app message", runErr)
}
if stdout != "" {
t.Fatalf("stdout = %q, want empty", stdout)
}
if stderr != "Error: --app is required (or set ASC_APP_ID)\n" {
t.Fatalf("stderr = %q, want exact missing-app diagnostic", stderr)
}
if sessionCalls != 0 {
t.Fatalf("session resolver called %d time(s), want 0", sessionCalls)
}

diagnostic, ok := shared.DiagnosticFromError(runErr)
if !ok {
t.Fatalf("expected structured diagnostic, got %v", runErr)
}
if diagnostic.Code != shared.DiagnosticRequiredInputMissing || diagnostic.Parameter != "--app" {
t.Fatalf("diagnostic = %+v, want required_input_missing for --app", diagnostic)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func TestReviewSubscriptionGroupLabelFallsBackToGroupID(t *testing.T) {
name := reviewSubscriptionGroupLabel(
[]webcore.ReviewSubscription{
Expand Down
1 change: 1 addition & 0 deletions internal/cli/xcodecloud/xcode_cloud_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ func xcodeCloudActionsList(ctx context.Context, runID string, limit int, next st
ctx,
runID,
"run-id",
"",
limit,
next,
paginate,
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/xcodecloud/xcode_cloud_build_runs.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ Examples:
ctx,
*runID,
"run-id",
"",
*limit,
*next,
*paginate,
Expand Down Expand Up @@ -160,6 +161,7 @@ func xcodeCloudBuildRunsList(ctx context.Context, workflowID string, sort string
ctx,
workflowID,
"workflow-id",
`Find workflow IDs with: asc xcode-cloud workflows list --app "APP_ID"`,
limit,
next,
paginate,
Expand Down
10 changes: 9 additions & 1 deletion internal/cli/xcodecloud/xcode_cloud_list_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,14 @@ func runXcodeCloudPaginatedList(
return shared.PrintOutput(resp, output, pretty)
}

// runXcodeCloudPaginatedParentList lists a paginated child collection scoped to
// a parent resource. parentHint, when non-empty, is appended to the missing-flag
// error so callers learn how to discover the parent ID.
func runXcodeCloudPaginatedParentList(
ctx context.Context,
parentID string,
parentFlag string,
parentHint string,
limit int,
next string,
paginate bool,
Expand All @@ -77,7 +81,11 @@ func runXcodeCloudPaginatedParentList(
) error {
resolvedParentID := strings.TrimSpace(parentID)
if resolvedParentID == "" && strings.TrimSpace(next) == "" {
fmt.Fprintf(os.Stderr, "Error: --%s is required\n", parentFlag)
if hint := strings.TrimSpace(parentHint); hint != "" {
fmt.Fprintf(os.Stderr, "Error: --%s is required. %s\n", parentFlag, hint)
} else {
fmt.Fprintf(os.Stderr, "Error: --%s is required\n", parentFlag)
}
return shared.MissingRequiredUsageError("--" + parentFlag)
}

Expand Down