Skip to content

Commit 4f6cadc

Browse files
committed
feat(compositions): add --interactive mode to compositions upsert
Inject a Prompter through cmdutil.Factory (defaulted to a survey-backed prompter in factory.New) and wire an --interactive/-i flag into `compositions upsert` that builds the composition body with the interactive engine instead of reading JSON from a file. Adds a group-level integration test that drives the real command tree end to end.
1 parent c17e3a6 commit 4f6cadc

5 files changed

Lines changed: 155 additions & 7 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package compositions_test
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/algolia/cli/pkg/cmd/compositions"
13+
compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal"
14+
"github.com/algolia/cli/pkg/httpmock"
15+
"github.com/algolia/cli/pkg/interactive"
16+
"github.com/algolia/cli/test"
17+
)
18+
19+
// wantBody is the exact composition the interactive build produces from the
20+
// scripted answers below. It is a valid composition: objectID is pre-populated
21+
// from the positional arg, name comes from the keyed Input, and behavior selects
22+
// the injection variant whose main source is a search source pointing at a real
23+
// index. description, sortingStrategy, and all the optional query parameters are
24+
// unanswered and therefore omitted.
25+
const wantBody = `{
26+
"objectID": "my-comp",
27+
"name": "My Composition",
28+
"behavior": {
29+
"injection": {
30+
"main": {
31+
"source": {
32+
"search": {"index": "my-index"}
33+
}
34+
}
35+
}
36+
}
37+
}`
38+
39+
// Drives `compositions upsert --interactive` through the real command tree with
40+
// a label-keyed ScriptedPrompter on the Factory. Answers are keyed by a unique
41+
// substring of the prompt label, so adding or reordering SDK fields does not
42+
// break the INPUT side: unmatched prompts fall back to safe defaults (skip).
43+
func TestCompositions_UpsertInteractive(t *testing.T) {
44+
r := &httpmock.Registry{}
45+
var captured []byte
46+
r.Register(httpmock.REST("PUT", "1/compositions/my-comp"), func(req *http.Request) (*http.Response, error) {
47+
captured, _ = io.ReadAll(req.Body)
48+
return httpmock.StringResponse(`{"taskID":42}`)(req)
49+
})
50+
r.Register(httpmock.REST("GET", "1/compositions/my-comp/task/42"), httpmock.StringResponse(`{"status":"published"}`))
51+
52+
compinternal.PollInterval = 1 * time.Millisecond
53+
compinternal.Timeout = 50 * time.Millisecond
54+
t.Cleanup(func() {
55+
compinternal.PollInterval = compinternal.DefaultPollInterval
56+
compinternal.Timeout = compinternal.DefaultTimeout
57+
})
58+
59+
f, out := test.NewFactory(true, r, nil, "")
60+
f.Prompter = &interactive.ScriptedPrompter{
61+
Inputs: map[string]string{
62+
"name": "My Composition",
63+
"index": "my-index", // behavior.injection.main.source.search.index (required)
64+
},
65+
Confirms: map[string]bool{
66+
// Trailing "?" pins this to the source pointer confirm
67+
// ("...main.source?") so it does not also match the deeper
68+
// "...search.params?" confirm, whose path contains ".main.source.".
69+
"main.source?": true,
70+
},
71+
// Both unions are keyed by their leaf "(variant)" label so the deep
72+
// source select does not collide with the top-level behavior select.
73+
Selects: map[string]string{
74+
"behavior (variant)": "CompositionInjectionBehavior",
75+
"source (variant)": "InjectionMainSearchSource",
76+
},
77+
}
78+
79+
cmd := compositions.NewCompositionsCmd(f)
80+
_, err := test.Execute(cmd, "upsert my-comp --interactive", out)
81+
require.NoError(t, err)
82+
83+
assert.JSONEq(t, wantBody, string(captured))
84+
r.Verify(t)
85+
}

pkg/cmd/compositions/upsert/upsert.go

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal"
1212
"github.com/algolia/cli/pkg/cmdutil"
1313
"github.com/algolia/cli/pkg/config"
14+
"github.com/algolia/cli/pkg/interactive"
1415
"github.com/algolia/cli/pkg/iostreams"
1516
"github.com/algolia/cli/pkg/validators"
1617
)
@@ -20,8 +21,10 @@ type UpsertOptions struct {
2021
Config config.IConfig
2122
IO *iostreams.IOStreams
2223
CompositionClient func() (*algoliaComposition.APIClient, error)
24+
Prompter interactive.Prompter
2325
CompositionID string
2426
File string
27+
Interactive bool
2528
PrintFlags *cmdutil.PrintFlags
2629
}
2730

@@ -31,6 +34,7 @@ func NewUpsertCmd(f *cmdutil.Factory) *cobra.Command {
3134
IO: f.IOStreams,
3235
Config: f.Config,
3336
CompositionClient: f.CompositionClient,
37+
Prompter: f.Prompter,
3438
PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"),
3539
}
3640

@@ -47,29 +51,63 @@ func NewUpsertCmd(f *cmdutil.Factory) *cobra.Command {
4751
4852
# Upsert from stdin
4953
$ cat body.json | algolia compositions upsert my-comp --file -
54+
55+
# Build a composition interactively
56+
$ algolia compositions upsert my-comp --interactive
5057
`),
5158
RunE: func(cmd *cobra.Command, args []string) error {
5259
opts.CompositionID = args[0]
60+
61+
if opts.Interactive == (opts.File != "") {
62+
return cmdutil.FlagErrorf("exactly one of `--file` or `--interactive` is required")
63+
}
64+
if opts.Interactive && !opts.IO.CanPrompt() {
65+
return cmdutil.FlagErrorf("`--interactive` requires a terminal; use `--file` instead")
66+
}
67+
5368
return runUpsertCmd(opts)
5469
},
5570
}
5671

5772
cmd.Flags().StringVarP(&opts.File, "file", "f", "", "JSON file path (use - for stdin)")
58-
_ = cmd.MarkFlagRequired("file")
73+
cmd.Flags().BoolVarP(&opts.Interactive, "interactive", "i", false, "Build the composition interactively")
5974

6075
opts.PrintFlags.AddFlags(cmd)
6176
return cmd
6277
}
6378

64-
func runUpsertCmd(opts *UpsertOptions) error {
79+
// buildComposition produces the composition body either interactively or by
80+
// reading and parsing the JSON file.
81+
func buildComposition(opts *UpsertOptions) (algoliaComposition.Composition, error) {
82+
var comp algoliaComposition.Composition
83+
84+
if opts.Interactive {
85+
comp.ObjectID = opts.CompositionID
86+
prompter := opts.Prompter
87+
if prompter == nil {
88+
prompter = interactive.NewSurveyPrompter(opts.IO)
89+
}
90+
builder := &interactive.Builder{Prompter: prompter}
91+
if err := builder.Build(&comp); err != nil {
92+
return comp, fmt.Errorf("building composition: %w", err)
93+
}
94+
return comp, nil
95+
}
96+
6597
raw, err := cmdutil.ReadFile(opts.File, opts.IO.In)
6698
if err != nil {
67-
return fmt.Errorf("reading file: %w", err)
99+
return comp, fmt.Errorf("reading file: %w", err)
68100
}
69-
70-
var comp algoliaComposition.Composition
71101
if err := json.Unmarshal(raw, &comp); err != nil {
72-
return fmt.Errorf("parsing composition JSON: %w", err)
102+
return comp, fmt.Errorf("parsing composition JSON: %w", err)
103+
}
104+
return comp, nil
105+
}
106+
107+
func runUpsertCmd(opts *UpsertOptions) error {
108+
comp, err := buildComposition(opts)
109+
if err != nil {
110+
return err
73111
}
74112

75113
client, err := opts.CompositionClient()

pkg/cmd/compositions/upsert/upsert_test.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ func TestUpsertComposition_MissingFile(t *testing.T) {
7878
cmd := upsert.NewUpsertCmd(f)
7979
_, err := test.Execute(cmd, "my-comp", out)
8080
require.Error(t, err)
81-
assert.Contains(t, err.Error(), "file")
81+
assert.Contains(t, err.Error(), "exactly one of `--file` or `--interactive`")
8282
}
8383

8484
func TestUpsertComposition_InvalidJSON(t *testing.T) {
@@ -99,3 +99,21 @@ func TestUpsertComposition_MissingArg(t *testing.T) {
9999
require.Error(t, err)
100100
assert.Contains(t, err.Error(), "requires a <composition-id> argument")
101101
}
102+
103+
func TestUpsertComposition_InteractiveAndFileConflict(t *testing.T) {
104+
r := &httpmock.Registry{}
105+
f, out := test.NewFactory(true, r, nil, "")
106+
cmd := upsert.NewUpsertCmd(f)
107+
_, err := test.Execute(cmd, "my-comp --interactive --file body.json", out)
108+
require.Error(t, err)
109+
assert.Contains(t, err.Error(), "exactly one of `--file` or `--interactive`")
110+
}
111+
112+
func TestUpsertComposition_InteractiveNoTTY(t *testing.T) {
113+
r := &httpmock.Registry{}
114+
f, out := test.NewFactory(false, r, nil, "") // not a TTY
115+
cmd := upsert.NewUpsertCmd(f)
116+
_, err := test.Execute(cmd, "my-comp --interactive", out)
117+
require.Error(t, err)
118+
assert.Contains(t, err.Error(), "requires a terminal")
119+
}

pkg/cmd/factory/default.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/algolia/cli/api/crawler"
1414
"github.com/algolia/cli/pkg/cmdutil"
1515
"github.com/algolia/cli/pkg/config"
16+
"github.com/algolia/cli/pkg/interactive"
1617
"github.com/algolia/cli/pkg/iostreams"
1718
)
1819

@@ -22,6 +23,7 @@ func New(appVersion string, cfg config.IConfig) *cmdutil.Factory {
2223
ExecutableName: "gh",
2324
}
2425
f.IOStreams = ioStreams(f)
26+
f.Prompter = interactive.NewSurveyPrompter(f.IOStreams)
2527
f.SearchClient = searchClient(f, appVersion)
2628
f.CrawlerClient = crawlerClient(f)
2729
f.CompositionClient = compositionClient(f, appVersion)

pkg/cmdutil/factory.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/algolia/cli/api/crawler"
1212
"github.com/algolia/cli/pkg/config"
13+
"github.com/algolia/cli/pkg/interactive"
1314
"github.com/algolia/cli/pkg/iostreams"
1415
)
1516

@@ -20,6 +21,10 @@ type Factory struct {
2021
CrawlerClient func() (*crawler.Client, error)
2122
CompositionClient func() (*composition.APIClient, error)
2223

24+
// Prompter is the interactive input source used by commands that support
25+
// an --interactive mode. Defaulted to a real SurveyPrompter in factory.New;
26+
// tests set it to an interactive.ScriptedPrompter.
27+
Prompter interactive.Prompter
2328
ExecutableName string
2429
}
2530

0 commit comments

Comments
 (0)