From b1e3668abfdd94a59a724dad0e0b2cc47f52ac7c Mon Sep 17 00:00:00 2001 From: Hami0095 Date: Mon, 14 Sep 2026 12:49:15 +0200 Subject: [PATCH] fix(config): correct aggregator type assertion in rules/synonyms export BrowseSynonyms and BrowseRules pass a *T to the WithAggregator callback (see CreateIterable in the SDK), but GetSynonyms/GetRules asserted to the value type. The comma-ok assertion silently failed on every page, so rules and synonyms were always dropped from `algolia config export`, and scoped exports of only rules/synonyms failed with "No config to export". Every other aggregator callback in this codebase (synonyms/browse, rules/browse, objects/browse, indices/analyze) already asserts to the pointer type; this brings config.go in line with that pattern. Adds unit tests for GetSynonyms, GetRules, and GetIndexConfig, which previously had no test coverage. --- pkg/cmd/shared/config/config.go | 10 +- pkg/cmd/shared/config/config_test.go | 153 +++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 pkg/cmd/shared/config/config_test.go diff --git a/pkg/cmd/shared/config/config.go b/pkg/cmd/shared/config/config.go index 5cd89230..714e7d81 100644 --- a/pkg/cmd/shared/config/config.go +++ b/pkg/cmd/shared/config/config.go @@ -15,7 +15,10 @@ func GetSynonyms(client *search.APIClient, srcIndex string) ([]search.SynonymHit srcIndex, *search.NewEmptySearchSynonymsParams(), search.WithAggregator(func(res any, _ error) { - response, _ := res.(search.SearchSynonymsResponse) + response, ok := res.(*search.SearchSynonymsResponse) + if !ok || response == nil { + return + } synonyms = append(synonyms, response.Hits...) }), ) @@ -32,7 +35,10 @@ func GetRules(client *search.APIClient, srcIndex string) ([]search.Rule, error) srcIndex, *search.NewEmptySearchRulesParams(), search.WithAggregator(func(res any, _ error) { - response, _ := res.(search.SearchRulesResponse) + response, ok := res.(*search.SearchRulesResponse) + if !ok || response == nil { + return + } rules = append(rules, response.Hits...) }), ) diff --git a/pkg/cmd/shared/config/config_test.go b/pkg/cmd/shared/config/config_test.go new file mode 100644 index 00000000..32c65384 --- /dev/null +++ b/pkg/cmd/shared/config/config_test.go @@ -0,0 +1,153 @@ +package config + +import ( + "testing" + + "github.com/algolia/algoliasearch-client-go/v4/algolia/search" + "github.com/algolia/algoliasearch-client-go/v4/algolia/transport" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/pkg/utils" +) + +func newTestClient(t *testing.T, r *httpmock.Registry) *search.APIClient { + t.Helper() + + cfg := search.SearchConfiguration{ + Configuration: transport.Configuration{ + AppID: "default", + ApiKey: "default", + Requester: r, + }, + } + client, err := search.NewClientWithConfig(cfg) + require.NoError(t, err) + + return client +} + +func Test_GetSynonyms(t *testing.T) { + r := httpmock.Registry{} + r.Register( + httpmock.REST("POST", "1/indexes/foo/synonyms/search"), + httpmock.JSONResponse(search.SearchSynonymsResponse{ + Hits: []search.SynonymHit{ + {ObjectID: "foo", Type: "synonym"}, + {ObjectID: "bar", Type: "synonym"}, + }, + }), + ) + defer r.Verify(t) + + client := newTestClient(t, &r) + + synonyms, err := GetSynonyms(client, "foo") + require.NoError(t, err) + assert.Equal(t, []search.SynonymHit{ + {ObjectID: "foo", Type: "synonym"}, + {ObjectID: "bar", Type: "synonym"}, + }, synonyms) +} + +func Test_GetRules(t *testing.T) { + r := httpmock.Registry{} + r.Register( + httpmock.REST("POST", "1/indexes/foo/rules/search"), + httpmock.JSONResponse(search.SearchRulesResponse{ + Hits: []search.Rule{ + {ObjectID: "rule-1"}, + {ObjectID: "rule-2"}, + }, + }), + ) + defer r.Verify(t) + + client := newTestClient(t, &r) + + rules, err := GetRules(client, "foo") + require.NoError(t, err) + assert.Equal(t, []search.Rule{ + {ObjectID: "rule-1"}, + {ObjectID: "rule-2"}, + }, rules) +} + +func Test_GetIndexConfig(t *testing.T) { + cs := iostreams.NewColorScheme(false, false, false) + + tests := []struct { + name string + scope []string + synonymHits []search.SynonymHit + ruleHits []search.Rule + settings search.SettingsResponse + wantErr bool + assertFn func(t *testing.T, cfg *ExportConfigJSON) + }{ + { + name: "exports synonyms", + scope: []string{"synonyms"}, + synonymHits: []search.SynonymHit{ + {ObjectID: "foo", Type: "synonym"}, + }, + assertFn: func(t *testing.T, cfg *ExportConfigJSON) { + assert.Len(t, cfg.Synonyms, 1) + assert.Equal(t, "foo", cfg.Synonyms[0].ObjectID) + }, + }, + { + name: "exports rules", + scope: []string{"rules"}, + ruleHits: []search.Rule{ + {ObjectID: "rule-1"}, + }, + assertFn: func(t *testing.T, cfg *ExportConfigJSON) { + assert.Len(t, cfg.Rules, 1) + assert.Equal(t, "rule-1", cfg.Rules[0].ObjectID) + }, + }, + { + name: "no config to export returns error", + scope: []string{"rules", "synonyms"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httpmock.Registry{} + if utils.Contains(tt.scope, "synonyms") { + r.Register( + httpmock.REST("POST", "1/indexes/foo/synonyms/search"), + httpmock.JSONResponse(search.SearchSynonymsResponse{Hits: tt.synonymHits}), + ) + } + if utils.Contains(tt.scope, "rules") { + r.Register( + httpmock.REST("POST", "1/indexes/foo/rules/search"), + httpmock.JSONResponse(search.SearchRulesResponse{Hits: tt.ruleHits}), + ) + } + if utils.Contains(tt.scope, "settings") { + r.Register( + httpmock.REST("GET", "1/indexes/foo/settings"), + httpmock.JSONResponse(tt.settings), + ) + } + defer r.Verify(t) + + client := newTestClient(t, &r) + + cfg, err := GetIndexConfig(client, "foo", tt.scope, cs) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + tt.assertFn(t, cfg) + }) + } +}