Skip to content

Commit f76c0c3

Browse files
committed
test(v5preview): integration coverage for union decode surface
Add integration tests that drive real requests against the cluster and assert the decoded shape of each union branch, covering the merged single-pass decode and lazy As<T>() paths end-to-end per server version: - aggregation: lazy As<T>() accessors (terms, date_histogram, stats, avg, sum, min, max, value_count, cardinality) - mget: merged success|error decode (GetResult found/not-found vs MGetMultiGetError) - msearch: merged success|error decode (MSearchMultiSearchItem vs ErrorRespBase), the first-byte-switch SearchHitsMetadataTotal union, and the MultiSearchItemError partial-failure surface Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
1 parent 34ac4e5 commit f76c0c3

3 files changed

Lines changed: 342 additions & 17 deletions

File tree

v5preview/opensearchapi/api_aggregation_test.go

Lines changed: 106 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -57,43 +57,132 @@ func TestManual_Aggregation(t *testing.T) {
5757
})
5858
require.NoError(t, err)
5959

60+
// Each case drives a real aggregation against the cluster and decodes the
61+
// result through its As<T>() accessor, asserting the decoded shape. This
62+
// exercises the SearchResultAggregationsValue union surface (the response
63+
// half, which a request can't cover) and validates that the running server
64+
// version returns what the generated client can decode.
6065
tests := []struct {
6166
name string
67+
key string
6268
query string
63-
check func(t *testing.T, resp *opensearchapi.SearchResp)
69+
check func(t *testing.T, agg opensearchapi.SearchResultAggregationsValue)
6470
}{
6571
{
66-
name: "terms aggregation",
72+
name: "terms (string) decodes via AsSterms",
73+
key: "by_category",
6774
query: `{"size":0,"aggs":{"by_category":{"terms":{"field":"category"}}}}`,
68-
check: func(t *testing.T, resp *opensearchapi.SearchResp) {
75+
check: func(t *testing.T, agg opensearchapi.SearchResultAggregationsValue) {
6976
t.Helper()
70-
require.Contains(t, resp.Aggregations, "by_category")
77+
v, err := agg.AsSterms()
78+
require.NoError(t, err)
79+
require.Len(t, v.Buckets, 3)
80+
got := map[string]int64{}
81+
for _, b := range v.Buckets {
82+
got[b.Key] = b.DocCount
83+
}
84+
require.Equal(t, int64(2), got["electronics"])
85+
require.Equal(t, int64(2), got["books"])
86+
require.Equal(t, int64(1), got["clothing"])
7187
},
7288
},
7389
{
74-
name: "date histogram aggregation",
90+
name: "date_histogram decodes via AsDateHistogram",
91+
key: "by_month",
7592
query: `{"size":0,"aggs":{"by_month":{"date_histogram":` +
7693
`{"field":"timestamp","calendar_interval":"month"}}}}`,
77-
check: func(t *testing.T, resp *opensearchapi.SearchResp) {
94+
check: func(t *testing.T, agg opensearchapi.SearchResultAggregationsValue) {
7895
t.Helper()
79-
require.Contains(t, resp.Aggregations, "by_month")
96+
v, err := agg.AsDateHistogram()
97+
require.NoError(t, err)
98+
require.NotEmpty(t, v.Buckets)
8099
},
81100
},
82101
{
83-
name: "stats aggregation",
102+
name: "stats decodes via AsStats",
103+
key: "price_stats",
84104
query: `{"size":0,"aggs":{"price_stats":{"stats":{"field":"price"}}}}`,
85-
check: func(t *testing.T, resp *opensearchapi.SearchResp) {
105+
check: func(t *testing.T, agg opensearchapi.SearchResultAggregationsValue) {
86106
t.Helper()
87-
require.Contains(t, resp.Aggregations, "price_stats")
107+
v, err := agg.AsStats()
108+
require.NoError(t, err)
109+
require.Equal(t, int64(5), v.Count)
110+
require.InDelta(t, 390, v.Sum, 1e-9)
111+
require.NotNil(t, v.Min)
112+
require.InDelta(t, 15, *v.Min, 1e-9)
113+
require.NotNil(t, v.Max)
114+
require.InDelta(t, 200, *v.Max, 1e-9)
88115
},
89116
},
90117
{
91-
name: "nested terms with stats",
92-
query: `{"size":0,"aggs":{"by_category":{"terms":{"field":"category"},` +
93-
`"aggs":{"avg_price":{"avg":{"field":"price"}}}}}}`,
94-
check: func(t *testing.T, resp *opensearchapi.SearchResp) {
118+
name: "avg decodes via AsAvg",
119+
key: "price_avg",
120+
query: `{"size":0,"aggs":{"price_avg":{"avg":{"field":"price"}}}}`,
121+
check: func(t *testing.T, agg opensearchapi.SearchResultAggregationsValue) {
95122
t.Helper()
96-
require.Contains(t, resp.Aggregations, "by_category")
123+
v, err := agg.AsAvg()
124+
require.NoError(t, err)
125+
require.NotNil(t, v.Value)
126+
require.InDelta(t, 78, *v.Value, 1e-9)
127+
},
128+
},
129+
{
130+
name: "sum decodes via AsSum",
131+
key: "price_sum",
132+
query: `{"size":0,"aggs":{"price_sum":{"sum":{"field":"price"}}}}`,
133+
check: func(t *testing.T, agg opensearchapi.SearchResultAggregationsValue) {
134+
t.Helper()
135+
v, err := agg.AsSum()
136+
require.NoError(t, err)
137+
require.NotNil(t, v.Value)
138+
require.InDelta(t, 390, *v.Value, 1e-9)
139+
},
140+
},
141+
{
142+
name: "min decodes via AsMin",
143+
key: "price_min",
144+
query: `{"size":0,"aggs":{"price_min":{"min":{"field":"price"}}}}`,
145+
check: func(t *testing.T, agg opensearchapi.SearchResultAggregationsValue) {
146+
t.Helper()
147+
v, err := agg.AsMin()
148+
require.NoError(t, err)
149+
require.NotNil(t, v.Value)
150+
require.InDelta(t, 15, *v.Value, 1e-9)
151+
},
152+
},
153+
{
154+
name: "max decodes via AsMax",
155+
key: "price_max",
156+
query: `{"size":0,"aggs":{"price_max":{"max":{"field":"price"}}}}`,
157+
check: func(t *testing.T, agg opensearchapi.SearchResultAggregationsValue) {
158+
t.Helper()
159+
v, err := agg.AsMax()
160+
require.NoError(t, err)
161+
require.NotNil(t, v.Value)
162+
require.InDelta(t, 200, *v.Value, 1e-9)
163+
},
164+
},
165+
{
166+
name: "value_count decodes via AsValueCount",
167+
key: "price_count",
168+
query: `{"size":0,"aggs":{"price_count":{"value_count":{"field":"price"}}}}`,
169+
check: func(t *testing.T, agg opensearchapi.SearchResultAggregationsValue) {
170+
t.Helper()
171+
v, err := agg.AsValueCount()
172+
require.NoError(t, err)
173+
require.NotNil(t, v.Value)
174+
require.InDelta(t, 5, *v.Value, 1e-9)
175+
},
176+
},
177+
{
178+
name: "cardinality decodes via AsCardinality",
179+
key: "distinct_categories",
180+
query: `{"size":0,"aggs":{"distinct_categories":{"cardinality":{"field":"category"}}}}`,
181+
check: func(t *testing.T, agg opensearchapi.SearchResultAggregationsValue) {
182+
t.Helper()
183+
v, err := agg.AsCardinality()
184+
require.NoError(t, err)
185+
require.Equal(t, int64(3), v.Value) // exact for small cardinalities
97186
},
98187
},
99188
}
@@ -105,8 +194,8 @@ func TestManual_Aggregation(t *testing.T) {
105194
BodyReader: strings.NewReader(tt.query),
106195
})
107196
require.NoError(t, err)
108-
require.NotNil(t, resp.Aggregations)
109-
tt.check(t, resp)
197+
require.Contains(t, resp.Aggregations, tt.key)
198+
tt.check(t, resp.Aggregations[tt.key])
110199
})
111200
}
112201

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
//
3+
// The OpenSearch Contributors require contributions made to
4+
// this file be licensed under the Apache-2.0 license or a
5+
// compatible open source license.
6+
//
7+
//go:build integration
8+
9+
package opensearchapi_test
10+
11+
import (
12+
"context"
13+
"strings"
14+
"testing"
15+
16+
"github.com/stretchr/testify/require"
17+
18+
"github.com/opensearch-project/opensearch-go/v4/v5preview/opensearchapi"
19+
"github.com/opensearch-project/opensearch-go/v4/v5preview/opensearchapi/testutil"
20+
)
21+
22+
// TestManual_MGet drives a real mget against the cluster and asserts the
23+
// decoded shape of each per-item union branch. mget responses intermix
24+
// success items ({_index,_id,found,_source,...}) and error items
25+
// ({_index,_id,error:{...}}) as sibling array elements; this exercises the
26+
// MGetRespBodyDocsItem merged single-pass decode (the success|error fan-in)
27+
// and validates that the running server version returns what the generated
28+
// client can decode.
29+
func TestManual_MGet(t *testing.T) {
30+
client, err := testutil.NewClient(t)
31+
require.NoError(t, err)
32+
33+
index := testutil.MustUniqueString(t, "test-mget")
34+
t.Cleanup(func() {
35+
_, _ = client.Indices.Delete(context.Background(), &opensearchapi.IndicesDeleteReq{Index: []string{index}})
36+
})
37+
38+
_, err = client.Indices.Create(t.Context(), opensearchapi.IndicesCreateReq{
39+
Index: index,
40+
BodyReader: strings.NewReader(`{"mappings":{"properties":{"title":{"type":"keyword"}}}}`),
41+
})
42+
require.NoError(t, err)
43+
44+
_, err = client.Index(t.Context(), opensearchapi.IndexReq{
45+
Index: index,
46+
ID: "1",
47+
Body: strings.NewReader(`{"title":"present"}`),
48+
Params: &opensearchapi.IndexParams{Refresh: "true"},
49+
})
50+
require.NoError(t, err)
51+
52+
missingIndex := testutil.MustUniqueString(t, "test-mget-missing")
53+
54+
resp, err := client.MGet(t.Context(), opensearchapi.MGetReq{
55+
Body: &opensearchapi.MGetBody{
56+
Docs: []opensearchapi.MGetOperation{
57+
{ID: "1", Index: &index}, // success, found
58+
{ID: "404", Index: &index}, // success, not found
59+
{ID: "1", Index: &missingIndex}, // per-item error: index missing
60+
},
61+
},
62+
})
63+
require.NoError(t, err)
64+
require.Len(t, resp.Docs, 3)
65+
66+
// Each case asserts the decoded branch of one docs[] element, exercising the
67+
// MGetRespBodyDocsItem success|error fan-in: GetResult for found/not-found
68+
// documents, MGetMultiGetError for a per-item index-missing error.
69+
tests := []struct {
70+
name string
71+
idx int
72+
check func(t *testing.T, item opensearchapi.MGetRespBodyDocsItem)
73+
}{
74+
{
75+
name: "found document decodes via GetResult branch",
76+
idx: 0,
77+
check: func(t *testing.T, item opensearchapi.MGetRespBodyDocsItem) {
78+
t.Helper()
79+
require.Equal(t, opensearchapi.MGetRespBodyDocsItemGetResultType, item.Type())
80+
v := item.GetResult()
81+
require.Equal(t, "1", v.ID)
82+
require.Equal(t, index, v.Index)
83+
require.True(t, v.Found)
84+
require.JSONEq(t, `{"title":"present"}`, string(v.Source))
85+
},
86+
},
87+
{
88+
name: "missing document decodes via GetResult branch with found=false",
89+
idx: 1,
90+
check: func(t *testing.T, item opensearchapi.MGetRespBodyDocsItem) {
91+
t.Helper()
92+
require.Equal(t, opensearchapi.MGetRespBodyDocsItemGetResultType, item.Type())
93+
v := item.GetResult()
94+
require.Equal(t, "404", v.ID)
95+
require.Equal(t, index, v.Index)
96+
require.False(t, v.Found)
97+
},
98+
},
99+
{
100+
name: "missing index decodes via MGetMultiGetError branch",
101+
idx: 2,
102+
check: func(t *testing.T, item opensearchapi.MGetRespBodyDocsItem) {
103+
t.Helper()
104+
require.Equal(t, opensearchapi.MGetRespBodyDocsItemMGetMultiGetErrorType, item.Type())
105+
v := item.MGetMultiGetError()
106+
require.Equal(t, "1", v.ID)
107+
require.Equal(t, missingIndex, v.Index)
108+
require.NotEmpty(t, v.Error.Type)
109+
},
110+
},
111+
}
112+
113+
for _, tt := range tests {
114+
t.Run(tt.name, func(t *testing.T) {
115+
tt.check(t, resp.Docs[tt.idx])
116+
})
117+
}
118+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
//
3+
// The OpenSearch Contributors require contributions made to
4+
// this file be licensed under the Apache-2.0 license or a
5+
// compatible open source license.
6+
//
7+
//go:build integration
8+
9+
package opensearchapi_test
10+
11+
import (
12+
"context"
13+
"strings"
14+
"testing"
15+
16+
"github.com/stretchr/testify/require"
17+
18+
"github.com/opensearch-project/opensearch-go/v4/v5preview/opensearchapi"
19+
"github.com/opensearch-project/opensearch-go/v4/v5preview/opensearchapi/testutil"
20+
)
21+
22+
// TestManual_MSearch drives a real msearch against the cluster and asserts the
23+
// decoded shape of each per-item union branch. An msearch responses[] element
24+
// is either a search result ({hits,took,...}) or a per-search error
25+
// ({error:{...},status}); this exercises the
26+
// MSearchMultiSearchResultResponsesItem merged single-pass decode (the
27+
// success|error fan-in) and validates that the running server version returns
28+
// what the generated client can decode.
29+
func TestManual_MSearch(t *testing.T) {
30+
client, err := testutil.NewClient(t)
31+
require.NoError(t, err)
32+
33+
index := testutil.MustUniqueString(t, "test-msearch")
34+
t.Cleanup(func() {
35+
_, _ = client.Indices.Delete(context.Background(), &opensearchapi.IndicesDeleteReq{Index: []string{index}})
36+
})
37+
38+
_, err = client.Indices.Create(t.Context(), opensearchapi.IndicesCreateReq{
39+
Index: index,
40+
BodyReader: strings.NewReader(`{"mappings":{"properties":{"title":{"type":"keyword"}}}}`),
41+
})
42+
require.NoError(t, err)
43+
44+
_, err = client.Index(t.Context(), opensearchapi.IndexReq{
45+
Index: index,
46+
ID: "1",
47+
Body: strings.NewReader(`{"title":"present"}`),
48+
Params: &opensearchapi.IndexParams{Refresh: "true"},
49+
})
50+
require.NoError(t, err)
51+
52+
missingIndex := testutil.MustUniqueString(t, "test-msearch-missing")
53+
54+
// NDJSON: a header line (which index to target) followed by a query line,
55+
// per sub-search. The first targets the populated index (success); the
56+
// second targets a non-existent index (per-item error).
57+
body := `{"index":"` + index + `"}` + "\n" +
58+
`{"query":{"match_all":{}}}` + "\n" +
59+
`{"index":"` + missingIndex + `"}` + "\n" +
60+
`{"query":{"match_all":{}}}` + "\n"
61+
62+
resp, err := client.MSearch(t.Context(), &opensearchapi.MSearchReq{
63+
Body: strings.NewReader(body),
64+
})
65+
// A per-sub-query error surfaces as a partial-failure error (the response is
66+
// still fully populated). With a single failed sub-query the per-op
67+
// aggregate collapses to a bare *MultiSearchItemError.
68+
require.Error(t, err)
69+
var itemErr *opensearchapi.MultiSearchItemError
70+
require.ErrorAs(t, err, &itemErr)
71+
require.Equal(t, 1, itemErr.SucceededCount)
72+
require.Len(t, itemErr.Items, 1)
73+
require.Equal(t, 1, itemErr.Items[0].Index)
74+
require.Equal(t, 404, itemErr.Items[0].Status)
75+
76+
require.Len(t, resp.Responses, 2)
77+
78+
// Each case asserts the decoded branch of one responses[] element,
79+
// exercising the success|error fan-in: MSearchMultiSearchItem for a
80+
// successful sub-search, ErrorRespBase for the index-missing one.
81+
tests := []struct {
82+
name string
83+
idx int
84+
check func(t *testing.T, item opensearchapi.MSearchMultiSearchResultResponsesItem)
85+
}{
86+
{
87+
name: "successful sub-search decodes via MSearchMultiSearchItem branch",
88+
idx: 0,
89+
check: func(t *testing.T, item opensearchapi.MSearchMultiSearchResultResponsesItem) {
90+
t.Helper()
91+
require.Equal(t, opensearchapi.MSearchMultiSearchResultResponsesItemMSearchMultiSearchItemType, item.Type())
92+
v := item.MSearchMultiSearchItem()
93+
require.False(t, v.TimedOut)
94+
require.NotNil(t, v.Hits.Total)
95+
require.Equal(t, opensearchapi.SearchHitsMetadataTotalSearchTotalHitsType, v.Hits.Total.Type())
96+
require.Equal(t, int64(1), v.Hits.Total.SearchTotalHits().Value)
97+
require.Len(t, v.Hits.Hits, 1)
98+
},
99+
},
100+
{
101+
name: "missing index decodes via ErrorRespBase branch",
102+
idx: 1,
103+
check: func(t *testing.T, item opensearchapi.MSearchMultiSearchResultResponsesItem) {
104+
t.Helper()
105+
require.Equal(t, opensearchapi.MSearchMultiSearchResultResponsesItemErrorRespBaseType, item.Type())
106+
v := item.ErrorRespBase()
107+
require.Equal(t, 404, v.Status)
108+
require.NotEmpty(t, v.Error.Type)
109+
},
110+
},
111+
}
112+
113+
for _, tt := range tests {
114+
t.Run(tt.name, func(t *testing.T) {
115+
tt.check(t, resp.Responses[tt.idx])
116+
})
117+
}
118+
}

0 commit comments

Comments
 (0)