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
85 changes: 85 additions & 0 deletions pkg/cmd/compositions/compositions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package compositions_test

import (
"io"
"net/http"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/algolia/cli/pkg/cmd/compositions"
compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal"
"github.com/algolia/cli/pkg/httpmock"
"github.com/algolia/cli/pkg/interactive"
"github.com/algolia/cli/test"
)

// wantBody is the exact composition the interactive build produces from the
// scripted answers below. It is a valid composition: objectID is pre-populated
// from the positional arg, name comes from the keyed Input, and behavior selects
// the injection variant whose main source is a search source pointing at a real
// index. description, sortingStrategy, and all the optional query parameters are
// unanswered and therefore omitted.
const wantBody = `{
"objectID": "my-comp",
"name": "My Composition",
"behavior": {
"injection": {
"main": {
"source": {
"search": {"index": "my-index"}
}
}
}
}
}`

// Drives `compositions upsert --interactive` through the real command tree with
// a label-keyed ScriptedPrompter on the Factory. Answers are keyed by a unique
// substring of the prompt label, so adding or reordering SDK fields does not
// break the INPUT side: unmatched prompts fall back to safe defaults (skip).
func TestCompositions_UpsertInteractive(t *testing.T) {
r := &httpmock.Registry{}
var captured []byte
r.Register(httpmock.REST("PUT", "1/compositions/my-comp"), func(req *http.Request) (*http.Response, error) {
captured, _ = io.ReadAll(req.Body)
return httpmock.StringResponse(`{"taskID":42}`)(req)
})
r.Register(httpmock.REST("GET", "1/compositions/my-comp/task/42"), httpmock.StringResponse(`{"status":"published"}`))

compinternal.PollInterval = 1 * time.Millisecond
compinternal.Timeout = 50 * time.Millisecond
t.Cleanup(func() {
compinternal.PollInterval = compinternal.DefaultPollInterval
compinternal.Timeout = compinternal.DefaultTimeout
})

f, out := test.NewFactory(true, r, nil, "")
f.Prompter = &interactive.ScriptedPrompter{
Inputs: map[string]string{
"name": "My Composition",
"index": "my-index", // behavior.injection.main.source.search.index (required)
},
Confirms: map[string]bool{
// Trailing "?" pins this to the source pointer confirm
// ("...main.source?") so it does not also match the deeper
// "...search.params?" confirm, whose path contains ".main.source.".
"main.source?": true,
},
// Both unions are keyed by their leaf "(variant)" label so the deep
// source select does not collide with the top-level behavior select.
Selects: map[string]string{
"behavior (variant)": "CompositionInjectionBehavior",
"source (variant)": "InjectionMainSearchSource",
},
}

cmd := compositions.NewCompositionsCmd(f)
_, err := test.Execute(cmd, "upsert my-comp --interactive", out)
require.NoError(t, err)

assert.JSONEq(t, wantBody, string(captured))
r.Verify(t)
}
49 changes: 43 additions & 6 deletions pkg/cmd/compositions/rules/upsert/upsert.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal"
"github.com/algolia/cli/pkg/cmdutil"
"github.com/algolia/cli/pkg/config"
"github.com/algolia/cli/pkg/interactive"
"github.com/algolia/cli/pkg/iostreams"
"github.com/algolia/cli/pkg/validators"
)
Expand All @@ -20,9 +21,11 @@ type UpsertOptions struct {
Config config.IConfig
IO *iostreams.IOStreams
CompositionClient func() (*algoliaComposition.APIClient, error)
Prompter interactive.Prompter
CompositionID string
ObjectID string
File string
Interactive bool
PrintFlags *cmdutil.PrintFlags
}

Expand All @@ -32,6 +35,7 @@ func NewUpsertCmd(f *cmdutil.Factory) *cobra.Command {
IO: f.IOStreams,
Config: f.Config,
CompositionClient: f.CompositionClient,
Prompter: f.Prompter,
PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"),
}

Expand All @@ -48,30 +52,63 @@ func NewUpsertCmd(f *cmdutil.Factory) *cobra.Command {

# Upsert from stdin
$ cat rule.json | algolia compositions rules upsert my-comp rule-1 --file -

# Build a rule interactively
$ algolia compositions rules upsert my-comp rule-1 --interactive
`),
RunE: func(cmd *cobra.Command, args []string) error {
opts.CompositionID = args[0]
opts.ObjectID = args[1]

if opts.Interactive == (opts.File != "") {
return cmdutil.FlagErrorf("exactly one of `--file` or `--interactive` is required")
}
if opts.Interactive && !opts.IO.CanPrompt() {
return cmdutil.FlagErrorf("`--interactive` requires a terminal; use `--file` instead")
}

return runUpsertCmd(opts)
},
}

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

opts.PrintFlags.AddFlags(cmd)
return cmd
}

func runUpsertCmd(opts *UpsertOptions) error {
// buildRule produces the rule body either interactively or by reading and
// parsing the JSON file.
func buildRule(opts *UpsertOptions) (algoliaComposition.CompositionRule, error) {
var rule algoliaComposition.CompositionRule

if opts.Interactive {
rule.ObjectID = opts.ObjectID
prompter := opts.Prompter
if prompter == nil {
prompter = interactive.NewSurveyPrompter(opts.IO)
}
if err := (&interactive.Builder{Prompter: prompter}).Build(&rule); err != nil {
return rule, fmt.Errorf("building rule: %w", err)
}
return rule, nil
}

raw, err := cmdutil.ReadFile(opts.File, opts.IO.In)
if err != nil {
return fmt.Errorf("reading file: %w", err)
return rule, fmt.Errorf("reading file: %w", err)
}

var rule algoliaComposition.CompositionRule
if err := json.Unmarshal(raw, &rule); err != nil {
return fmt.Errorf("parsing rule JSON: %w", err)
return rule, fmt.Errorf("parsing rule JSON: %w", err)
}
return rule, nil
}

func runUpsertCmd(opts *UpsertOptions) error {
rule, err := buildRule(opts)
if err != nil {
return err
}

client, err := opts.CompositionClient()
Expand Down
45 changes: 45 additions & 0 deletions pkg/cmd/compositions/rules/upsert/upsert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal"
"github.com/algolia/cli/pkg/cmd/compositions/rules/upsert"
"github.com/algolia/cli/pkg/httpmock"
"github.com/algolia/cli/pkg/interactive"
"github.com/algolia/cli/test"
)

Expand Down Expand Up @@ -100,3 +101,47 @@ func TestUpsertRule_MissingArgs(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "requires a <composition-id> and a <rule-id> argument")
}

func TestUpsertRule_Interactive(t *testing.T) {
r := &httpmock.Registry{}
r.Register(httpmock.REST("PUT", "1/compositions/my-comp/rules/rule-1"), httpmock.StringResponse(`{"taskID":77}`))
r.Register(httpmock.REST("GET", "1/compositions/my-comp/task/77"), httpmock.StringResponse(`{"status":"published"}`))

compinternal.PollInterval = 1 * time.Millisecond
compinternal.Timeout = 50 * time.Millisecond
t.Cleanup(func() {
compinternal.PollInterval = compinternal.DefaultPollInterval
compinternal.Timeout = compinternal.DefaultTimeout
})

f, out := test.NewFactory(true, r, nil, "")
// Empty script: pre-populated objectID, first behavior variant, optionals skipped.
f.Prompter = &interactive.ScriptedPrompter{}

cmd := upsert.NewUpsertCmd(f)
_, err := test.Execute(cmd, "my-comp rule-1 --interactive", out)
require.NoError(t, err)

// TTY factory prints a success line before the JSON, so assert on substrings.
assert.Contains(t, out.String(), `{"taskID":77}`)
assert.Contains(t, out.String(), "Upserted rule rule-1")
r.Verify(t)
}

func TestUpsertRule_InteractiveAndFileConflict(t *testing.T) {
r := &httpmock.Registry{}
f, out := test.NewFactory(true, r, nil, "")
cmd := upsert.NewUpsertCmd(f)
_, err := test.Execute(cmd, "my-comp rule-1 --interactive --file rule.json", out)
require.Error(t, err)
assert.Contains(t, err.Error(), "exactly one of `--file` or `--interactive`")
}

func TestUpsertRule_InteractiveNoTTY(t *testing.T) {
r := &httpmock.Registry{}
f, out := test.NewFactory(false, r, nil, "")
cmd := upsert.NewUpsertCmd(f)
_, err := test.Execute(cmd, "my-comp rule-1 --interactive", out)
require.Error(t, err)
assert.Contains(t, err.Error(), "requires a terminal")
}
70 changes: 55 additions & 15 deletions pkg/cmd/compositions/search/search.go
Original file line number Diff line number Diff line change
@@ -1,26 +1,30 @@
package search

import (
"fmt"

"github.com/MakeNowJust/heredoc"
algoliaComposition "github.com/algolia/algoliasearch-client-go/v4/algolia/composition"
"github.com/spf13/cobra"

"github.com/algolia/cli/pkg/cmdutil"
"github.com/algolia/cli/pkg/config"
"github.com/algolia/cli/pkg/interactive"
"github.com/algolia/cli/pkg/iostreams"
"github.com/algolia/cli/pkg/validators"
)

// SearchOptions holds the dependencies and flags for the search command.
type SearchOptions struct {
Config config.IConfig
IO *iostreams.IOStreams
CompositionClient func() (*algoliaComposition.APIClient, error)
Prompter interactive.Prompter
CompositionID string
Query string
HitsPerPage *int32
Page *int32
Filters string
Interactive bool
PrintFlags *cmdutil.PrintFlags
}

Expand All @@ -30,16 +34,17 @@ func NewSearchCmd(f *cmdutil.Factory) *cobra.Command {
IO: f.IOStreams,
Config: f.Config,
CompositionClient: f.CompositionClient,
Prompter: f.Prompter,
PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"),
}

var hitsPerPage int32
var page int32

cmd := &cobra.Command{
Use: "search <composition-id> <query>",
Use: "search <composition-id> [query]",
Short: "Search a composition",
Args: validators.ExactArgsWithMsg(2, "compositions search requires a <composition-id> and a <query> argument."),
Args: cobra.RangeArgs(1, 2),
Annotations: map[string]string{
"acls": "search",
},
Expand All @@ -52,9 +57,23 @@ func NewSearchCmd(f *cmdutil.Factory) *cobra.Command {

# Search with pagination
$ algolia compositions search my-comp "shirt" --hits-per-page 20 --page 2

# Build the search request interactively
$ algolia compositions search my-comp --interactive
`),
RunE: func(cmd *cobra.Command, args []string) error {
opts.CompositionID = args[0]

if opts.Interactive {
if !opts.IO.CanPrompt() {
return cmdutil.FlagErrorf("`--interactive` requires a terminal")
}
return runSearchCmd(opts)
}

if len(args) < 2 {
return cmdutil.FlagErrorf("a <query> argument is required (or use `--interactive`)")
}
opts.Query = args[1]
if cmd.Flags().Changed("hits-per-page") {
opts.HitsPerPage = &hitsPerPage
Expand All @@ -69,20 +88,25 @@ func NewSearchCmd(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().Int32Var(&hitsPerPage, "hits-per-page", 20, "Number of hits per page")
cmd.Flags().Int32Var(&page, "page", 0, "Page number")
cmd.Flags().StringVar(&opts.Filters, "filters", "", "Filter expression")
cmd.Flags().BoolVarP(&opts.Interactive, "interactive", "i", false, "Build the search request interactively")

opts.PrintFlags.AddFlags(cmd)
return cmd
}

func runSearchCmd(opts *SearchOptions) error {
client, err := opts.CompositionClient()
if err != nil {
return err
}

p, err := opts.PrintFlags.ToPrinter()
if err != nil {
return err
// buildRequestBody assembles the search request body, interactively when
// requested or from the query argument and flags otherwise.
func buildRequestBody(opts *SearchOptions) (*algoliaComposition.RequestBody, error) {
if opts.Interactive {
var body algoliaComposition.RequestBody
prompter := opts.Prompter
if prompter == nil {
prompter = interactive.NewSurveyPrompter(opts.IO)
}
if err := (&interactive.Builder{Prompter: prompter}).Build(&body); err != nil {
return nil, fmt.Errorf("building search request: %w", err)
}
return &body, nil
}

params := algoliaComposition.NewParams(
Expand All @@ -97,10 +121,26 @@ func runSearchCmd(opts *SearchOptions) error {
if opts.Filters != "" {
params.Filters = &opts.Filters
}

reqBody := algoliaComposition.NewRequestBody(
return algoliaComposition.NewRequestBody(
algoliaComposition.WithRequestBodyParams(*params),
)
), nil
}

func runSearchCmd(opts *SearchOptions) error {
client, err := opts.CompositionClient()
if err != nil {
return err
}

p, err := opts.PrintFlags.ToPrinter()
if err != nil {
return err
}

reqBody, err := buildRequestBody(opts)
if err != nil {
return err
}

opts.IO.StartProgressIndicatorWithLabel("Searching")

Expand Down
Loading
Loading