Files
card-to-iban/backend/internal/zarinhub/client_test.go

670 lines
14 KiB
Go

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}},
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)
}
}