package zarinhub import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "testing" "time" ) func TestClientConvertSuccess(t *testing.T) { const cardNumber = "6037997512345678" const expectedIBAN = "IR820540102680020817909002" const token = "test-token" server := httptest.NewServer( http.HandlerFunc(func( w http.ResponseWriter, r *http.Request, ) { if r.Method != http.MethodPost { t.Errorf( "method = %s, want POST", r.Method, ) } if got := r.Header.Get("Authorization"); got != "Bearer "+token { t.Errorf( "Authorization = %q, want %q", got, "Bearer "+token, ) } if got := r.Header.Get("Content-Type"); got != "application/json" { t.Errorf( "Content-Type = %q, want application/json", got, ) } var req convertRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf( "decode request: %v", err, ) } if req.Card != cardNumber { t.Errorf( "card = %q, want %q", req.Card, cardNumber, ) } w.Header().Set( "Content-Type", "application/json", ) fmt.Fprint(w, `{ "data": { "bankName": "bankName_value", "card": "6037997512345678", "deposit": "0101234567001", "depositDescription": "depositDescription_value", "depositOwners": "depositOwners_value", "depositStatus": "depositStatus_value", "englishBankName": "englishBankName_value", "iban": "IR820540102680020817909002" }, "meta": { "code": 0, "errorMessage": null, "errorType": null, "errors": [], "isSuccess": true, "message": "success", "status": "success", "trackId": "test-track-id" } }`) }), ) defer server.Close() client := NewClient( &http.Client{ Timeout: time.Second, }, server.URL, &fakeTokenProvider{tokens: []string{token}}, ) result, err := client.Convert( context.Background(), cardNumber, ) if err != nil { t.Fatalf( "Convert() unexpected error: %v", err, ) } if result.IBAN != expectedIBAN { t.Errorf( "IBAN = %q, want %q", result.IBAN, expectedIBAN, ) } if result.UpstreamHTTPStatus != http.StatusOK { t.Errorf( "status = %d, want %d", result.UpstreamHTTPStatus, http.StatusOK, ) } if len(result.RawResponse) == 0 { t.Error( "expected raw response to be preserved", ) } } func TestClientReturnsRejectedForApplicationFailure( t *testing.T, ) { server := httptest.NewServer( http.HandlerFunc(func( w http.ResponseWriter, r *http.Request, ) { w.Header().Set( "Content-Type", "application/json", ) fmt.Fprint(w, `{ "data": {}, "meta": { "trackId": null, "status": "error", "isSuccess": false, "code": -1, "message": "request failed", "errorMessage": "invalid parameters", "errorType": "validation_error", "errors": [] } }`) }), ) defer server.Close() client := NewClient( &http.Client{ Timeout: time.Second, }, server.URL, &fakeTokenProvider{tokens: []string{"test-token"}}, ) result, err := client.Convert( context.Background(), "6037997512345678", ) if !errors.Is(err, ErrRejected) { t.Fatalf( "error = %v, want ErrRejected", err, ) } if result.UpstreamHTTPStatus != http.StatusOK { t.Errorf( "status = %d, want 200", result.UpstreamHTTPStatus, ) } if len(result.RawResponse) == 0 { t.Error( "expected raw response to be preserved", ) } } func TestClientMapsHTTPStatusErrors(t *testing.T) { tests := []struct { name string status int wantErr error }{ { name: "unauthorized", status: http.StatusUnauthorized, wantErr: ErrAuthentication, }, { name: "forbidden", status: http.StatusForbidden, wantErr: ErrAuthentication, }, { name: "request timeout", status: http.StatusRequestTimeout, wantErr: ErrTimeout, }, { name: "rate limited", status: http.StatusTooManyRequests, wantErr: ErrUnavailable, }, { name: "server error", status: http.StatusInternalServerError, wantErr: ErrUnavailable, }, { name: "bad request", status: http.StatusBadRequest, wantErr: ErrRejected, }, } for _, tt := range tests { t.Run( tt.name, func(t *testing.T) { server := httptest.NewServer( http.HandlerFunc(func( w http.ResponseWriter, r *http.Request, ) { w.WriteHeader(tt.status) fmt.Fprint( w, `{"error":"test"}`, ) }), ) defer server.Close() client := NewClient( &http.Client{ Timeout: time.Second, }, server.URL, &fakeTokenProvider{tokens: []string{"test-token"}}, ) result, err := client.Convert( context.Background(), "6037997512345678", ) if !errors.Is(err, tt.wantErr) { t.Fatalf( "error = %v, want %v", err, tt.wantErr, ) } if result.UpstreamHTTPStatus != tt.status { t.Errorf( "status = %d, want %d", result.UpstreamHTTPStatus, tt.status, ) } if len(result.RawResponse) == 0 { t.Error( "expected raw response to be preserved", ) } }, ) } } func TestClientReturnsInvalidResponseForMalformedJSON( t *testing.T, ) { server := httptest.NewServer( http.HandlerFunc(func( w http.ResponseWriter, r *http.Request, ) { w.WriteHeader(http.StatusOK) fmt.Fprint( w, `{not-json}`, ) }), ) defer server.Close() client := NewClient( &http.Client{ Timeout: time.Second, }, server.URL, &fakeTokenProvider{tokens: []string{"test-token"}}, ) result, err := client.Convert( context.Background(), "6037997512345678", ) if !errors.Is(err, ErrInvalidResponse) { t.Fatalf( "error = %v, want ErrInvalidResponse", err, ) } if result.UpstreamHTTPStatus != http.StatusOK { t.Errorf( "status = %d, want 200", result.UpstreamHTTPStatus, ) } } func TestClientReturnsTimeout(t *testing.T) { server := httptest.NewServer( http.HandlerFunc(func( w http.ResponseWriter, r *http.Request, ) { time.Sleep(200 * time.Millisecond) fmt.Fprint( w, `{}`, ) }), ) defer server.Close() client := NewClient( &http.Client{ Timeout: 50 * time.Millisecond, }, server.URL, &fakeTokenProvider{tokens: []string{"test-token"}}, ) _, err := client.Convert( context.Background(), "6037997512345678", ) if !errors.Is(err, ErrTimeout) { t.Fatalf( "error = %v, want ErrTimeout", err, ) } } func TestClientRejectsOversizedResponse(t *testing.T) { server := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) body := make([]byte, maxResponseBodySize+1) for index := range body { body[index] = 'x' } if _, err := w.Write(body); err != nil { t.Errorf("write response: %v", err) } }), ) defer server.Close() client := NewClient( &http.Client{Timeout: time.Second}, server.URL, &fakeTokenProvider{tokens: []string{"test-token"}}, ) _, err := client.Convert( context.Background(), "6037997512345678", ) if !errors.Is(err, ErrInvalidResponse) { t.Fatalf("error = %v, want ErrInvalidResponse", err) } } func TestClientRefreshesAndRetriesUnauthorizedRequest(t *testing.T) { const expectedIBAN = "IR820540102680020817909002" requestCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestCount++ authorization := r.Header.Get("Authorization") if requestCount == 1 { if authorization != "Bearer expired-token" { t.Errorf("first authorization = %q", authorization) } w.WriteHeader(http.StatusUnauthorized) return } if authorization != "Bearer fresh-token" { t.Errorf("second authorization = %q", authorization) } fmt.Fprintf(w, `{ "data":{"iban":%q}, "meta":{"isSuccess":true,"errors":[]} }`, expectedIBAN) })) defer server.Close() tokens := &fakeTokenProvider{tokens: []string{"expired-token", "fresh-token"}} client := NewClient(server.Client(), server.URL, tokens) result, err := client.Convert(context.Background(), "6037997512345678") if err != nil { t.Fatalf("Convert() error = %v", err) } if result.IBAN != expectedIBAN { t.Errorf("IBAN = %q, want %q", result.IBAN, expectedIBAN) } if requestCount != 2 { t.Errorf("request count = %d, want 2", requestCount) } if len(tokens.invalidated) != 1 || tokens.invalidated[0] != "expired-token" { t.Errorf("invalidated tokens = %v", tokens.invalidated) } } type fakeTokenProvider struct { tokens []string calls int invalidated []string } func (p *fakeTokenProvider) AccessToken(context.Context) (string, error) { index := p.calls if index >= len(p.tokens) { index = len(p.tokens) - 1 } p.calls++ return p.tokens[index], nil } func (p *fakeTokenProvider) Invalidate(token string) { p.invalidated = append(p.invalidated, token) }