package meilisearch import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "testing" "time" "github.com/stretchr/testify/require" ) // Mock structures for testing type mockResponse struct { Message string `json:"message"` } type mockJsonMarshaller struct { valid bool null bool Foo string `json:"foo"` Bar string `json:"bar"` } // failingEncoder is used to simulate encoder failure type failingEncoder struct{} func (fe failingEncoder) Encode(r io.Reader) (*bytes.Buffer, error) { return nil, errors.New("dummy encoding failure") } // Implement Decode method to satisfy the encoder interface, though it won't be used here func (fe failingEncoder) Decode(b []byte, v interface{}) error { return errors.New("dummy decode failure") } func TestExecuteRequest(t *testing.T) { retryCount := 0 // Create a mock server ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet && r.URL.Path == "/test-get" { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"message":"get successful"}`)) } else if r.Method == http.MethodGet && r.URL.Path == "/test-get-encoding" { encode := r.Header.Get("Accept-Encoding") if len(encode) != 0 { enc := newEncoding(ContentEncoding(encode), DefaultCompression) d := &mockData{Name: "foo", Age: 30} b, err := json.Marshal(d) require.NoError(t, err) res, err := enc.Encode(bytes.NewReader(b)) require.NoError(t, err) _, _ = w.Write(res.Bytes()) w.WriteHeader(http.StatusOK) return } _, _ = w.Write([]byte("invalid message")) w.WriteHeader(http.StatusInternalServerError) } else if r.Method == http.MethodPost && r.URL.Path == "/test-req-resp-encoding" { accept := r.Header.Get("Accept-Encoding") ce := r.Header.Get("Content-Encoding") reqEnc := newEncoding(ContentEncoding(ce), DefaultCompression) respEnc := newEncoding(ContentEncoding(accept), DefaultCompression) req := new(mockData) if len(ce) != 0 { b, err := io.ReadAll(r.Body) require.NoError(t, err) err = reqEnc.Decode(b, req) require.NoError(t, err) } if len(accept) != 0 { d, err := json.Marshal(req) require.NoError(t, err) res, err := respEnc.Encode(bytes.NewReader(d)) require.NoError(t, err) _, _ = w.Write(res.Bytes()) w.WriteHeader(http.StatusOK) } } else if r.Method == http.MethodPost && r.URL.Path == "/test-post" { w.WriteHeader(http.StatusCreated) msg := []byte(`{"message":"post successful"}`) _, _ = w.Write(msg) } else if r.Method == http.MethodGet && r.URL.Path == "/test-null-body" { w.WriteHeader(http.StatusOK) msg := []byte(`null`) _, _ = w.Write(msg) } else if r.Method == http.MethodPost && r.URL.Path == "/test-post-encoding" { w.WriteHeader(http.StatusCreated) msg := []byte(`{"message":"post successful"}`) enc := r.Header.Get("Accept-Encoding") if len(enc) != 0 { e := newEncoding(ContentEncoding(enc), DefaultCompression) b, err := e.Encode(bytes.NewReader(msg)) require.NoError(t, err) _, _ = w.Write(b.Bytes()) return } _, _ = w.Write(msg) } else if r.URL.Path == "/test-bad-request" { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"message":"bad request"}`)) } else if r.URL.Path == "/invalid-response-body" { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"message":"bad response body"}`)) } else if r.URL.Path == "/io-reader" { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"message":"io reader"}`)) } else if r.URL.Path == "/failed-retry" { w.WriteHeader(http.StatusBadGateway) } else if r.URL.Path == "/success-retry" { if retryCount == 2 { w.WriteHeader(http.StatusOK) return } w.WriteHeader(http.StatusBadGateway) retryCount++ } else if r.URL.Path == "/dummy" { w.WriteHeader(http.StatusOK) } else { w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() tests := []struct { name string internalReq *internalRequest expectedResp interface{} contentEncoding ContentEncoding withTimeout bool disableRetry bool wantErr bool }{ { name: "Successful GET request", internalReq: &internalRequest{ endpoint: "/test-get", method: http.MethodGet, withResponse: &mockResponse{}, acceptedStatusCodes: []int{http.StatusOK}, }, expectedResp: &mockResponse{Message: "get successful"}, wantErr: false, }, { name: "Successful POST request", internalReq: &internalRequest{ endpoint: "/test-post", method: http.MethodPost, withRequest: map[string]string{"key": "value"}, contentType: contentTypeJSON, withResponse: &mockResponse{}, acceptedStatusCodes: []int{http.StatusCreated}, }, expectedResp: &mockResponse{Message: "post successful"}, wantErr: false, }, { name: "404 Not Found", internalReq: &internalRequest{ endpoint: "/not-found", method: http.MethodGet, withResponse: &mockResponse{}, acceptedStatusCodes: []int{http.StatusOK}, }, expectedResp: nil, wantErr: true, }, { name: "Invalid URL", internalReq: &internalRequest{ endpoint: "/invalid-url$%^*()*#", method: http.MethodGet, withResponse: &mockResponse{}, acceptedStatusCodes: []int{http.StatusOK}, }, expectedResp: nil, wantErr: true, }, { name: "Invalid response body", internalReq: &internalRequest{ endpoint: "/invalid-response-body", method: http.MethodGet, withResponse: struct{}{}, acceptedStatusCodes: []int{http.StatusInternalServerError}, }, expectedResp: nil, wantErr: true, }, { name: "Invalid request method", internalReq: &internalRequest{ endpoint: "/invalid-request-method", method: http.MethodGet, withResponse: nil, withRequest: struct{}{}, acceptedStatusCodes: []int{http.StatusBadRequest}, }, expectedResp: nil, wantErr: true, }, { name: "Invalid request content type", internalReq: &internalRequest{ endpoint: "/invalid-request-content-type", method: http.MethodPost, withResponse: nil, contentType: "", withRequest: struct{}{}, acceptedStatusCodes: []int{http.StatusBadRequest}, }, expectedResp: nil, wantErr: true, }, { name: "Invalid json marshaler", internalReq: &internalRequest{ endpoint: "/invalid-marshaler", method: http.MethodPost, withResponse: nil, withRequest: &mockJsonMarshaller{ valid: false, }, contentType: "application/json", }, expectedResp: nil, wantErr: true, }, { name: "Null data marshaler", internalReq: &internalRequest{ endpoint: "/null-data-marshaler", method: http.MethodPost, withResponse: nil, withRequest: &mockJsonMarshaller{ valid: true, null: true, }, contentType: "application/json", }, expectedResp: nil, wantErr: true, }, { name: "Test null body response", internalReq: &internalRequest{ endpoint: "/test-null-body", method: http.MethodGet, withResponse: make([]byte, 0), contentType: "application/json", acceptedStatusCodes: []int{http.StatusOK}, }, expectedResp: nil, wantErr: false, }, { name: "400 Bad Request", internalReq: &internalRequest{ endpoint: "/test-bad-request", method: http.MethodGet, withResponse: &mockResponse{}, acceptedStatusCodes: []int{http.StatusOK}, }, expectedResp: nil, wantErr: true, }, { name: "Test request encoding gzip", internalReq: &internalRequest{ endpoint: "/test-post-encoding", method: http.MethodPost, withRequest: map[string]string{"key": "value"}, contentType: contentTypeJSON, withResponse: &mockResponse{}, acceptedStatusCodes: []int{http.StatusCreated}, }, expectedResp: &mockResponse{Message: "post successful"}, contentEncoding: GzipEncoding, wantErr: false, }, { name: "Test request encoding deflate", internalReq: &internalRequest{ endpoint: "/test-post-encoding", method: http.MethodPost, withRequest: map[string]string{"key": "value"}, contentType: contentTypeJSON, withResponse: &mockResponse{}, acceptedStatusCodes: []int{http.StatusCreated}, }, expectedResp: &mockResponse{Message: "post successful"}, contentEncoding: DeflateEncoding, wantErr: false, }, { name: "Test request encoding brotli", internalReq: &internalRequest{ endpoint: "/test-post-encoding", method: http.MethodPost, withRequest: map[string]string{"key": "value"}, contentType: contentTypeJSON, withResponse: &mockResponse{}, acceptedStatusCodes: []int{http.StatusCreated}, }, expectedResp: &mockResponse{Message: "post successful"}, contentEncoding: BrotliEncoding, wantErr: false, }, { name: "Test response decoding gzip", internalReq: &internalRequest{ endpoint: "/test-get-encoding", method: http.MethodGet, withRequest: nil, withResponse: &mockData{}, acceptedStatusCodes: []int{http.StatusOK}, }, expectedResp: &mockData{Name: "foo", Age: 30}, contentEncoding: GzipEncoding, wantErr: false, }, { name: "Test response decoding deflate", internalReq: &internalRequest{ endpoint: "/test-get-encoding", method: http.MethodGet, withRequest: nil, withResponse: &mockData{}, acceptedStatusCodes: []int{http.StatusOK}, }, expectedResp: &mockData{Name: "foo", Age: 30}, contentEncoding: DeflateEncoding, wantErr: false, }, { name: "Test response decoding brotli", internalReq: &internalRequest{ endpoint: "/test-get-encoding", method: http.MethodGet, withRequest: nil, withResponse: &mockData{}, acceptedStatusCodes: []int{http.StatusOK}, }, expectedResp: &mockData{Name: "foo", Age: 30}, contentEncoding: BrotliEncoding, wantErr: false, }, { name: "Test request and response encoding", internalReq: &internalRequest{ endpoint: "/test-req-resp-encoding", method: http.MethodPost, contentType: contentTypeJSON, withRequest: &mockData{Name: "foo", Age: 30}, withResponse: &mockData{}, acceptedStatusCodes: []int{http.StatusOK}, }, expectedResp: &mockData{Name: "foo", Age: 30}, contentEncoding: GzipEncoding, wantErr: false, }, { name: "Test successful retries", internalReq: &internalRequest{ endpoint: "/success-retry", method: http.MethodGet, withResponse: nil, withRequest: nil, acceptedStatusCodes: []int{http.StatusOK}, }, expectedResp: nil, wantErr: false, }, { name: "Test failed retries", internalReq: &internalRequest{ endpoint: "/failed-retry", method: http.MethodGet, withResponse: nil, withRequest: nil, acceptedStatusCodes: []int{http.StatusOK}, }, expectedResp: nil, wantErr: true, }, { name: "Test disable retries", internalReq: &internalRequest{ endpoint: "/test-get", method: http.MethodGet, withResponse: nil, withRequest: nil, acceptedStatusCodes: []int{http.StatusOK}, }, expectedResp: nil, disableRetry: true, wantErr: false, }, { name: "Test request timeout on retries", internalReq: &internalRequest{ endpoint: "/failed-retry", method: http.MethodGet, withResponse: nil, withRequest: nil, acceptedStatusCodes: []int{http.StatusOK}, }, expectedResp: nil, withTimeout: true, wantErr: true, }, { name: "Test request encoding with []byte encode failure", internalReq: &internalRequest{ endpoint: "/dummy", method: http.MethodPost, withRequest: []byte("test data"), contentType: "text/plain", acceptedStatusCodes: []int{http.StatusOK}, }, expectedResp: nil, contentEncoding: GzipEncoding, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := newClient(&http.Client{}, ts.URL, "testApiKey", clientConfig{ contentEncoding: tt.contentEncoding, encodingCompressionLevel: DefaultCompression, maxRetries: 3, disableRetry: tt.disableRetry, retryOnStatus: map[int]bool{ 502: true, 503: true, 504: true, }, }) // For the specific test case, override the encoder to force an error if tt.name == "Test request encoding with []byte encode failure" { c.encoder = failingEncoder{} } ctx := context.Background() if tt.withTimeout { timeoutCtx, cancel := context.WithTimeout(ctx, 1*time.Second) ctx = timeoutCtx defer cancel() } err := c.executeRequest(ctx, tt.internalReq) if tt.wantErr { require.Error(t, err) } else { require.NoError(t, err) require.Equal(t, tt.expectedResp, tt.internalReq.withResponse) } }) } } func TestNewClientNilRetryOnStatus(t *testing.T) { c := newClient(&http.Client{}, "", "", clientConfig{ maxRetries: 3, retryOnStatus: nil, }) require.NotNil(t, c.retryOnStatus) } func (m mockJsonMarshaller) MarshalJSON() ([]byte, error) { type Alias mockJsonMarshaller if !m.valid { return nil, errors.New("mockJsonMarshaller not valid") } if m.null { return nil, nil } return json.Marshal(&struct { Alias }{ Alias: Alias(m), }) }