package zarinhub import ( "bytes" "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "net/http/httptest" "strings" "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}}, 0, ) 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"}}, 0, ) 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"}}, 0, ) 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"}}, 0, ) 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"}}, 0, ) _, 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"}}, 0, ) _, 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, 0) 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) } func TestRetryDelay(t *testing.T) { tests := []struct { attempt int want time.Duration }{ {attempt: -1, want: 200 * time.Millisecond}, {attempt: 0, want: 200 * time.Millisecond}, {attempt: 1, want: 400 * time.Millisecond}, {attempt: 2, want: 800 * time.Millisecond}, {attempt: 3, want: 1600 * time.Millisecond}, {attempt: 4, want: 2 * time.Second}, {attempt: 10, want: 2 * time.Second}, } for _, tt := range tests { got := retryDelay(tt.attempt) if got != tt.want { t.Errorf("retryDelay(%d) = %v, want %v", tt.attempt, got, tt.want) } } } func TestNewClientSanitizesNegativeRetries(t *testing.T) { client := NewClient(nil, "", nil, -1) if client.maxRetries != 0 { t.Errorf("maxRetries = %d, want 0", client.maxRetries) } } func TestClientRetriesOnTransientErrorsUntilSuccess(t *testing.T) { const expectedIBAN = "IR820540102680020817909002" requestCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestCount++ if requestCount == 1 { w.WriteHeader(http.StatusServiceUnavailable) fmt.Fprint(w, `{"error":"temporary outage"}`) return } w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{ "data":{"iban":%q}, "meta":{"isSuccess":true,"errors":[]} }`, expectedIBAN) })) defer server.Close() var recordedDelays []time.Duration client := NewClient( &http.Client{Timeout: time.Second}, server.URL, &fakeTokenProvider{tokens: []string{"test-token"}}, 2, ) client.retryWait = func(ctx context.Context, delay time.Duration) bool { recordedDelays = append(recordedDelays, delay) return true } result, err := client.Convert(context.Background(), "6037997512345678") if err != nil { t.Fatalf("Convert() unexpected 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(recordedDelays) != 1 { t.Fatalf("len(recordedDelays) = %d, want 1", len(recordedDelays)) } if recordedDelays[0] != 200*time.Millisecond { t.Errorf("delay[0] = %v, want 200ms", recordedDelays[0]) } } func TestClientRetriesExhausted(t *testing.T) { requestCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestCount++ w.WriteHeader(http.StatusGatewayTimeout) fmt.Fprint(w, `{"error":"gateway timeout"}`) })) defer server.Close() var recordedDelays []time.Duration client := NewClient( &http.Client{Timeout: time.Second}, server.URL, &fakeTokenProvider{tokens: []string{"test-token"}}, 2, ) client.retryWait = func(ctx context.Context, delay time.Duration) bool { recordedDelays = append(recordedDelays, delay) return true } _, err := client.Convert(context.Background(), "6037997512345678") if !errors.Is(err, ErrUnavailable) { t.Fatalf("error = %v, want ErrUnavailable", err) } if requestCount != 3 { t.Errorf("request count = %d, want 3", requestCount) } if len(recordedDelays) != 2 { t.Fatalf("len(recordedDelays) = %d, want 2", len(recordedDelays)) } if recordedDelays[0] != 200*time.Millisecond { t.Errorf("delay[0] = %v, want 200ms", recordedDelays[0]) } if recordedDelays[1] != 400*time.Millisecond { t.Errorf("delay[1] = %v, want 400ms", recordedDelays[1]) } } func TestClientDoesNotRetryNonTransientErrors(t *testing.T) { requestCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestCount++ w.WriteHeader(http.StatusBadRequest) fmt.Fprint(w, `{"error":"invalid card format"}`) })) defer server.Close() client := NewClient( &http.Client{Timeout: time.Second}, server.URL, &fakeTokenProvider{tokens: []string{"test-token"}}, 2, ) client.retryWait = func(ctx context.Context, delay time.Duration) bool { t.Error("retryWait should not be called for non-transient errors") return true } _, err := client.Convert(context.Background(), "6037997512345678") if !errors.Is(err, ErrRejected) { t.Fatalf("error = %v, want ErrRejected", err) } if requestCount != 1 { t.Errorf("request count = %d, want 1", requestCount) } } func TestClientStopsRetryOnContextCancellation(t *testing.T) { requestCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestCount++ w.WriteHeader(http.StatusServiceUnavailable) })) defer server.Close() ctx, cancel := context.WithCancel(context.Background()) client := NewClient( &http.Client{Timeout: time.Second}, server.URL, &fakeTokenProvider{tokens: []string{"test-token"}}, 2, ) client.retryWait = func(c context.Context, delay time.Duration) bool { cancel() return waitForRetry(ctx, delay) } _, err := client.Convert(ctx, "6037997512345678") if err == nil { t.Fatal("expected error, got nil") } if !errors.Is(err, context.Canceled) { t.Errorf("error = %v, want context.Canceled", err) } if requestCount != 1 { t.Errorf("request count = %d, want 1", requestCount) } } func TestClientRetryLogging(t *testing.T) { const expectedIBAN = "IR820540102680020817909002" requestCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestCount++ if requestCount == 1 { w.WriteHeader(http.StatusServiceUnavailable) fmt.Fprint(w, `{"error":"temporary outage"}`) return } w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{ "data":{"iban":%q}, "meta":{"isSuccess":true,"errors":[]} }`, expectedIBAN) })) defer server.Close() var logBuf bytes.Buffer logger := slog.New(slog.NewJSONHandler(&logBuf, nil)) client := NewClient( &http.Client{Timeout: time.Second}, server.URL, &fakeTokenProvider{tokens: []string{"test-token"}}, 2, logger, ) client.retryWait = func(ctx context.Context, delay time.Duration) bool { return true } result, err := client.Convert(context.Background(), "6037997512345678") if err != nil { t.Fatalf("Convert() unexpected error = %v", err) } if result.IBAN != expectedIBAN { t.Errorf("IBAN = %q, want %q", result.IBAN, expectedIBAN) } logs := logBuf.String() if !strings.Contains(logs, "[INITIAL] zarinhub request") { t.Errorf("expected logs to contain '[INITIAL] zarinhub request', got:\n%s", logs) } if !strings.Contains(logs, `"is_retry":false`) { t.Errorf("expected logs to contain '\"is_retry\":false', got:\n%s", logs) } if !strings.Contains(logs, "[RETRY] zarinhub request attempt 1") { t.Errorf("expected logs to contain '[RETRY] zarinhub request attempt 1', got:\n%s", logs) } if !strings.Contains(logs, "[RETRY] zarinhub request failed, retrying in") { t.Errorf("expected logs to contain '[RETRY] zarinhub request failed, retrying in', got:\n%s", logs) } if !strings.Contains(logs, `"is_retry":true`) { t.Errorf("expected logs to contain '\"is_retry\":true', got:\n%s", logs) } } func TestClientRetryLoggingExhausted(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) fmt.Fprint(w, `{"error":"down"}`) })) defer server.Close() var logBuf bytes.Buffer logger := slog.New(slog.NewJSONHandler(&logBuf, nil)) client := NewClient( &http.Client{Timeout: time.Second}, server.URL, &fakeTokenProvider{tokens: []string{"test-token"}}, 1, logger, ) client.retryWait = func(ctx context.Context, delay time.Duration) bool { return true } _, err := client.Convert(context.Background(), "6037997512345678") if !errors.Is(err, ErrUnavailable) { t.Fatalf("error = %v, want ErrUnavailable", err) } logs := logBuf.String() if !strings.Contains(logs, "[RETRY] zarinhub request failed: all retries exhausted") { t.Errorf("expected logs to contain exhausted retries message, got:\n%s", logs) } }