package monetix import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "strings" "testing" "github.com/prometheus/client_golang/prometheus" dto "github.com/prometheus/client_model/go" "go.uber.org/zap" ) type roundTripperFunc func(*http.Request) (*http.Response, error) func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } func TestSendCardPayout_SignsPayload(t *testing.T) { secret := "secret" var captured CardPayoutRequest httpClient := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path != "/v2/payment/card/payout" { t.Fatalf("expected payout path, got %q", r.URL.Path) } body, _ := io.ReadAll(r.Body) if err := json.Unmarshal(body, &captured); err != nil { t.Fatalf("failed to decode request: %v", err) } resp := APIResponse{} resp.Operation.RequestID = "req-1" payload, _ := json.Marshal(resp) return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(payload)), Header: http.Header{"Content-Type": []string{"application/json"}}, }, nil }), } cfg := Config{ BaseURL: "https://monetix.test", SecretKey: secret, } client := NewClient(cfg, httpClient, zap.NewNop()) req := CardPayoutRequest{ General: General{ProjectID: 1, PaymentID: "payout-1"}, Customer: Customer{ ID: "cust-1", FirstName: "Jane", LastName: "Doe", IP: "203.0.113.10", }, Payment: Payment{Amount: 1000, Currency: "RUB"}, Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"}, } result, err := client.CreateCardPayout(context.Background(), req) if err != nil { t.Fatalf("expected no error, got %v", err) } if !result.Accepted { t.Fatalf("expected accepted response") } if captured.General.Signature == "" { t.Fatalf("expected signature in request") } signed := captured signed.General.Signature = "" expectedSig, err := SignPayload(signed, secret) if err != nil { t.Fatalf("failed to compute signature: %v", err) } if captured.General.Signature != expectedSig { t.Fatalf("expected signature %q, got %q", expectedSig, captured.General.Signature) } } func TestSendCardPayout_NormalizesTwoDigitYearBeforeSend(t *testing.T) { var captured CardPayoutRequest httpClient := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { body, _ := io.ReadAll(r.Body) if err := json.Unmarshal(body, &captured); err != nil { t.Fatalf("failed to decode request: %v", err) } payload, _ := json.Marshal(APIResponse{}) return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(payload)), Header: http.Header{"Content-Type": []string{"application/json"}}, }, nil }), } cfg := Config{ BaseURL: "https://monetix.test", SecretKey: "secret", } client := NewClient(cfg, httpClient, zap.NewNop()) req := CardPayoutRequest{ General: General{ProjectID: 1, PaymentID: "payout-1"}, Customer: Customer{ ID: "cust-1", FirstName: "Jane", LastName: "Doe", IP: "203.0.113.10", }, Payment: Payment{Amount: 1000, Currency: "RUB"}, Card: Card{PAN: "4111111111111111", Year: 30, Month: 12, CardHolder: "JANE DOE"}, } _, err := client.CreateCardPayout(context.Background(), req) if err != nil { t.Fatalf("expected no error, got %v", err) } if captured.Card.Year != 2030 { t.Fatalf("expected normalized year 2030, got %d", captured.Card.Year) } } func TestSendCardPayout_HTTPError(t *testing.T) { httpClient := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { body := `{"code":"E100","message":"denied"}` return &http.Response{ StatusCode: http.StatusBadRequest, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{"Content-Type": []string{"application/json"}}, }, nil }), } cfg := Config{ BaseURL: "https://monetix.test", SecretKey: "secret", } client := NewClient(cfg, httpClient, zap.NewNop()) req := CardPayoutRequest{ General: General{ProjectID: 1, PaymentID: "payout-1"}, Customer: Customer{ ID: "cust-1", FirstName: "Jane", LastName: "Doe", IP: "203.0.113.10", }, Payment: Payment{Amount: 1000, Currency: "RUB"}, Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"}, } result, err := client.CreateCardPayout(context.Background(), req) if err != nil { t.Fatalf("expected no error, got %v", err) } if result.Accepted { t.Fatalf("expected rejected response") } if result.ErrorCode != "E100" { t.Fatalf("expected error code E100, got %q", result.ErrorCode) } if result.ErrorMessage != "denied" { t.Fatalf("expected error message denied, got %q", result.ErrorMessage) } } type errorReadCloser struct { err error } func (e errorReadCloser) Read(_ []byte) (int, error) { return 0, e.err } func (e errorReadCloser) Close() error { return nil } type countingReadCloser struct { data []byte offset int readBytes int chunkSize int } func (c *countingReadCloser) Read(p []byte) (int, error) { if c.offset >= len(c.data) { return 0, io.EOF } n := c.chunkSize if n <= 0 || n > len(p) { n = len(p) } remaining := len(c.data) - c.offset if n > remaining { n = remaining } copy(p[:n], c.data[c.offset:c.offset+n]) c.offset += n c.readBytes += n return n, nil } func (c *countingReadCloser) Close() error { return nil } func counterValue(t *testing.T, counter prometheus.Counter) float64 { t.Helper() metric := &dto.Metric{} if err := counter.Write(metric); err != nil { t.Fatalf("failed to read counter value: %v", err) } return metric.GetCounter().GetValue() } func TestSendCardPayout_ReadBodyError(t *testing.T) { httpClient := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Body: errorReadCloser{err: errors.New("read failed")}, Header: http.Header{"Content-Type": []string{"application/json"}}, }, nil }), } cfg := Config{ BaseURL: "https://monetix.test", SecretKey: "secret", } client := NewClient(cfg, httpClient, zap.NewNop()) req := CardPayoutRequest{ General: General{ProjectID: 1, PaymentID: "payout-1"}, Customer: Customer{ ID: "cust-1", FirstName: "Jane", LastName: "Doe", IP: "203.0.113.10", }, Payment: Payment{Amount: 1000, Currency: "RUB"}, Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"}, } _, err := client.CreateCardPayout(context.Background(), req) if err == nil { t.Fatalf("expected read error") } if !strings.Contains(err.Error(), "failed to read monetix response") { t.Fatalf("expected wrapped read error, got %v", err) } } func TestSendCardPayout_DecodeErrorTrackedAsHTTPError(t *testing.T) { initMetrics() beforeAccepted := counterValue(t, cardPayoutRequests.WithLabelValues(outcomeAccepted)) beforeHTTPError := counterValue(t, cardPayoutRequests.WithLabelValues(outcomeHTTPError)) httpClient := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("{")), Header: http.Header{"Content-Type": []string{"application/json"}}, }, nil }), } cfg := Config{ BaseURL: "https://monetix.test", SecretKey: "secret", } client := NewClient(cfg, httpClient, zap.NewNop()) req := CardPayoutRequest{ General: General{ProjectID: 1, PaymentID: "payout-1"}, Customer: Customer{ ID: "cust-1", FirstName: "Jane", LastName: "Doe", IP: "203.0.113.10", }, Payment: Payment{Amount: 1000, Currency: "RUB"}, Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"}, } _, err := client.CreateCardPayout(context.Background(), req) if err == nil { t.Fatalf("expected decode error") } if !strings.Contains(err.Error(), "failed to decode monetix response") { t.Fatalf("expected wrapped decode error, got %v", err) } afterAccepted := counterValue(t, cardPayoutRequests.WithLabelValues(outcomeAccepted)) afterHTTPError := counterValue(t, cardPayoutRequests.WithLabelValues(outcomeHTTPError)) if afterAccepted != beforeAccepted { t.Fatalf("accepted counter changed unexpectedly: before=%v after=%v", beforeAccepted, afterAccepted) } if afterHTTPError != beforeHTTPError+1 { t.Fatalf("http_error counter not incremented: before=%v after=%v", beforeHTTPError, afterHTTPError) } } func TestSendCardPayout_ReadsFullMonetixRegistrationBody(t *testing.T) { rawBody := `{"status":"waiting","request_id":"req-123","project_id":10,"payment_id":"pay-1"}` body := &countingReadCloser{ data: []byte(rawBody), chunkSize: 7, } httpClient := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Body: body, Header: http.Header{"Content-Type": []string{"application/json"}}, }, nil }), } cfg := Config{ BaseURL: "https://monetix.test", SecretKey: "secret", } client := NewClient(cfg, httpClient, zap.NewNop()) req := CardPayoutRequest{ General: General{ProjectID: 1, PaymentID: "payout-1"}, Customer: Customer{ ID: "cust-1", FirstName: "Jane", LastName: "Doe", IP: "203.0.113.10", }, Payment: Payment{Amount: 1000, Currency: "RUB"}, Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"}, } result, err := client.CreateCardPayout(context.Background(), req) if err != nil { t.Fatalf("expected no error, got %v", err) } if !result.Accepted { t.Fatalf("expected accepted response") } if result.ProviderRequestID != "req-123" { t.Fatalf("expected provider request id req-123, got %q", result.ProviderRequestID) } if result.ProviderStatus != "waiting" { t.Fatalf("expected provider status waiting, got %q", result.ProviderStatus) } if body.readBytes != len(rawBody) { t.Fatalf("expected full body read (%d), got %d", len(rawBody), body.readBytes) } } func TestSendCardPayout_ProviderStatusFallsBackToOperationStatus(t *testing.T) { httpClient := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { body := `{"operation":{"request_id":"req-1","status":"processing"}}` return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{"Content-Type": []string{"application/json"}}, }, nil }), } cfg := Config{ BaseURL: "https://monetix.test", SecretKey: "secret", } client := NewClient(cfg, httpClient, zap.NewNop()) req := CardPayoutRequest{ General: General{ProjectID: 1, PaymentID: "payout-1"}, Customer: Customer{ ID: "cust-1", FirstName: "Jane", LastName: "Doe", IP: "203.0.113.10", }, Payment: Payment{Amount: 1000, Currency: "RUB"}, Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"}, } result, err := client.CreateCardPayout(context.Background(), req) if err != nil { t.Fatalf("expected no error, got %v", err) } if result.ProviderStatus != "processing" { t.Fatalf("expected provider status processing, got %q", result.ProviderStatus) } } func TestSendCardTokenization_NormalizesTwoDigitYearBeforeSend(t *testing.T) { var captured CardTokenizeRequest httpClient := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path != "/v1/tokenize" { t.Fatalf("expected tokenization path, got %q", r.URL.Path) } body, _ := io.ReadAll(r.Body) if err := json.Unmarshal(body, &captured); err != nil { t.Fatalf("failed to decode request: %v", err) } payload, _ := json.Marshal(APIResponse{}) return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(payload)), Header: http.Header{"Content-Type": []string{"application/json"}}, }, nil }), } cfg := Config{ BaseURL: "https://monetix.test", SecretKey: "secret", } client := NewClient(cfg, httpClient, zap.NewNop()) req := CardTokenizeRequest{ General: General{ProjectID: 1, PaymentID: "tokenize-1"}, Customer: Customer{ ID: "cust-1", FirstName: "Jane", LastName: "Doe", IP: "203.0.113.10", }, Card: CardTokenize{ PAN: "4111111111111111", Year: 30, Month: 12, CardHolder: "JANE DOE", CVV: "123", }, } _, err := client.CreateCardTokenization(context.Background(), req) if err != nil { t.Fatalf("expected no error, got %v", err) } if captured.Card.Year != 2030 { t.Fatalf("expected normalized year 2030, got %d", captured.Card.Year) } }