mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
- go.mod module path + all self-imports: github.com/formancehq/payments -> github.com/hanzo-fi/payments (1590 .go files / 3152 refs; 20 modules: root, pkg/client, pkg/domain, 18 ce/plugins). External dep kept: formancehq/go-libs. - plugins: each ce/plugins/* consumed a *published* pkg/domain; added local 'replace .../pkg/domain => ../../../pkg/domain' (consistent with root) so every standalone plugin module builds in-monorepo. compile-plugin.sh MOD var rebranded too. - branding: README, LICENSE (dual MIT+EE; upstream copyright retained + Hanzo added), goreleaser (release images -> registry.hanzo.ai/hanzo-fi, OCI labels, homepage), proto go_package, renovate, speakeasy gen.yaml, connector-template *.gotpl. - CI: removed Numary/Formance workflows + composite action; added canonical hanzo.yml + cicd.yml (hanzoai/ci, gated HANZO_CI_ENABLED) + secretless ci.yml (go build/vet). - kept as data/compat keys: wire capability enums ALLOW_FORMANCE_*, generated SDK type client.Formance, domain field FormanceRedirectURL, OTEL_SERVICE_NAME, upstream base image. - verified: go build ./... (default + -tags ee) + all 20 nested modules green.
528 lines
16 KiB
Go
528 lines
16 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzo-fi/payments/pkg/domain/httpwrapper"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func mockAPIClient(handler http.HandlerFunc) (*client, *httptest.Server) {
|
|
server := httptest.NewServer(handler)
|
|
httpClient := httpwrapper.NewClient(&httpwrapper.Config{
|
|
Timeout: 10 * time.Second,
|
|
})
|
|
return &client{httpClient: httpClient, baseURL: server.URL}, server
|
|
}
|
|
|
|
// --- Payout tests ---
|
|
|
|
func TestCreatePayout_Success(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
expectedResp := PayoutResponse{
|
|
Id: "payout_123",
|
|
IdempotencyKey: "ref_123",
|
|
Amount: "10000",
|
|
Currency: "USD/2",
|
|
SourceAccountID: "src_acc",
|
|
DestinationAccountID: "dst_acc",
|
|
Status: "PENDING",
|
|
CreatedAt: now,
|
|
}
|
|
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
require.Equal(t, http.MethodPost, r.Method)
|
|
require.Equal(t, "/payouts", r.URL.Path)
|
|
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
|
|
|
var req PayoutRequest
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
|
require.Equal(t, "ref_123", req.IdempotencyKey)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(expectedResp)
|
|
})
|
|
defer server.Close()
|
|
|
|
resp, err := c.CreatePayout(context.Background(), &PayoutRequest{
|
|
IdempotencyKey: "ref_123",
|
|
Amount: "10000",
|
|
Currency: "USD/2",
|
|
SourceAccountID: "src_acc",
|
|
DestinationAccountID: "dst_acc",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, expectedResp.Id, resp.Id)
|
|
require.Equal(t, expectedResp.Status, resp.Status)
|
|
}
|
|
|
|
func TestCreatePayout_HTTPError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"title": "bad request", "detail": "invalid input"}`))
|
|
})
|
|
defer server.Close()
|
|
|
|
resp, err := c.CreatePayout(context.Background(), &PayoutRequest{IdempotencyKey: "ref_123"})
|
|
require.Error(t, err)
|
|
require.Nil(t, resp)
|
|
require.ErrorIs(t, err, httpwrapper.ErrStatusCodeClientError)
|
|
}
|
|
|
|
func TestCreatePayout_InvalidJSON(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{invalid json`))
|
|
})
|
|
defer server.Close()
|
|
|
|
resp, err := c.CreatePayout(context.Background(), &PayoutRequest{IdempotencyKey: "ref_123"})
|
|
require.Error(t, err)
|
|
require.Nil(t, resp)
|
|
require.Contains(t, err.Error(), "unmarshal")
|
|
}
|
|
|
|
func TestCreatePayout_WithMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
var req PayoutRequest
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
require.Equal(t, "value1", req.Metadata["key1"])
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(PayoutResponse{
|
|
Id: "payout_meta", CreatedAt: now, Status: "PENDING", Metadata: req.Metadata,
|
|
})
|
|
})
|
|
defer server.Close()
|
|
|
|
resp, err := c.CreatePayout(context.Background(), &PayoutRequest{
|
|
IdempotencyKey: "ref_meta",
|
|
Metadata: map[string]string{"key1": "value1"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "value1", resp.Metadata["key1"])
|
|
}
|
|
|
|
// --- Transfer tests ---
|
|
|
|
func TestCreateTransfer_Success(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
expectedResp := TransferResponse{
|
|
Id: "transfer_123",
|
|
IdempotencyKey: "ref_456",
|
|
Amount: "50000",
|
|
Currency: "EUR/2",
|
|
SourceAccountID: "src_acc",
|
|
DestinationAccountID: "dst_acc",
|
|
Status: "PENDING",
|
|
CreatedAt: now,
|
|
}
|
|
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
require.Equal(t, http.MethodPost, r.Method)
|
|
require.Equal(t, "/transfers", r.URL.Path)
|
|
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
|
|
|
var req TransferRequest
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
|
require.Equal(t, "ref_456", req.IdempotencyKey)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(expectedResp)
|
|
})
|
|
defer server.Close()
|
|
|
|
desc := "Test transfer"
|
|
resp, err := c.CreateTransfer(context.Background(), &TransferRequest{
|
|
IdempotencyKey: "ref_456",
|
|
Amount: "50000",
|
|
Currency: "EUR/2",
|
|
SourceAccountID: "src_acc",
|
|
DestinationAccountID: "dst_acc",
|
|
Description: &desc,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, expectedResp.Id, resp.Id)
|
|
require.Equal(t, expectedResp.Status, resp.Status)
|
|
}
|
|
|
|
func TestCreateTransfer_HTTPError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(`{"title": "internal error", "detail": "server fault"}`))
|
|
})
|
|
defer server.Close()
|
|
|
|
resp, err := c.CreateTransfer(context.Background(), &TransferRequest{IdempotencyKey: "ref_456"})
|
|
require.Error(t, err)
|
|
require.Nil(t, resp)
|
|
require.ErrorIs(t, err, httpwrapper.ErrStatusCodeServerError)
|
|
}
|
|
|
|
func TestCreateTransfer_InvalidJSON(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`not json`))
|
|
})
|
|
defer server.Close()
|
|
|
|
resp, err := c.CreateTransfer(context.Background(), &TransferRequest{IdempotencyKey: "ref_456"})
|
|
require.Error(t, err)
|
|
require.Nil(t, resp)
|
|
require.Contains(t, err.Error(), "unmarshal")
|
|
}
|
|
|
|
func TestCreateTransfer_WithDescription(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
var req TransferRequest
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
require.NotNil(t, req.Description)
|
|
require.Equal(t, "Payment for services", *req.Description)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(TransferResponse{
|
|
Id: "transfer_desc", CreatedAt: now, Status: "PENDING", Description: req.Description,
|
|
})
|
|
})
|
|
defer server.Close()
|
|
|
|
desc := "Payment for services"
|
|
resp, err := c.CreateTransfer(context.Background(), &TransferRequest{
|
|
IdempotencyKey: "ref_desc",
|
|
Description: &desc,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp.Description)
|
|
require.Equal(t, desc, *resp.Description)
|
|
}
|
|
|
|
// --- Fetch tests (ListAccounts, GetBalances, ListBeneficiaries, ListTransactions) ---
|
|
|
|
func TestListAccounts_Success(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Now().UTC()
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
require.Equal(t, http.MethodGet, r.Method)
|
|
require.Contains(t, r.URL.Path, "/accounts")
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode([]map[string]interface{}{
|
|
{"id": "acc_1", "accountName": "Test Account", "createdAt": now.Format(time.RFC3339)},
|
|
})
|
|
})
|
|
defer server.Close()
|
|
|
|
accounts, err := c.ListAccounts(context.Background(), 1, 10, time.Time{})
|
|
require.NoError(t, err)
|
|
require.Len(t, accounts, 1)
|
|
require.Equal(t, "acc_1", accounts[0].Id)
|
|
}
|
|
|
|
func TestListAccounts_WithCreatedAtFrom(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Now().UTC()
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
require.Contains(t, r.URL.RawQuery, "createdAtFrom")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode([]map[string]interface{}{})
|
|
})
|
|
defer server.Close()
|
|
|
|
accounts, err := c.ListAccounts(context.Background(), 1, 10, now.Add(-time.Hour))
|
|
require.NoError(t, err)
|
|
require.Len(t, accounts, 0)
|
|
}
|
|
|
|
func TestListAccounts_Error(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(`{"title": "server error", "detail": "internal"}`))
|
|
})
|
|
defer server.Close()
|
|
|
|
_, err := c.ListAccounts(context.Background(), 1, 10, time.Time{})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestGetBalances_Success(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Now().UTC()
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
require.Equal(t, http.MethodGet, r.Method)
|
|
require.Contains(t, r.URL.Path, "/accounts/acc_123/balances")
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"id": "bal_1", "accountID": "acc_123", "at": now.Format(time.RFC3339),
|
|
"balances": []map[string]interface{}{{"currency": "USD", "amount": "1000"}},
|
|
})
|
|
})
|
|
defer server.Close()
|
|
|
|
balances, err := c.GetBalances(context.Background(), "acc_123")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, balances)
|
|
}
|
|
|
|
func TestGetBalances_Error(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"title": "not found", "detail": "account missing"}`))
|
|
})
|
|
defer server.Close()
|
|
|
|
_, err := c.GetBalances(context.Background(), "acc_123")
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestListBeneficiaries_Success(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Now().UTC()
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
require.Equal(t, http.MethodGet, r.Method)
|
|
require.Contains(t, r.URL.Path, "/beneficiaries")
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode([]map[string]interface{}{
|
|
{"id": "ben_1", "ownerName": "Test Beneficiary", "createdAt": now.Format(time.RFC3339)},
|
|
})
|
|
})
|
|
defer server.Close()
|
|
|
|
beneficiaries, err := c.ListBeneficiaries(context.Background(), 1, 10, time.Time{})
|
|
require.NoError(t, err)
|
|
require.Len(t, beneficiaries, 1)
|
|
require.Equal(t, "ben_1", beneficiaries[0].Id)
|
|
}
|
|
|
|
func TestListBeneficiaries_Error(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(`{"title": "server error", "detail": "internal"}`))
|
|
})
|
|
defer server.Close()
|
|
|
|
_, err := c.ListBeneficiaries(context.Background(), 1, 10, time.Time{})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestListTransactions_Success(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Now().UTC()
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
require.Equal(t, http.MethodGet, r.Method)
|
|
require.Contains(t, r.URL.Path, "/transactions")
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode([]map[string]interface{}{
|
|
{"id": "tx_1", "amount": "1000", "currency": "USD", "type": "PAYIN", "status": "SUCCEEDED",
|
|
"createdAt": now.Format(time.RFC3339), "updatedAt": now.Format(time.RFC3339)},
|
|
})
|
|
})
|
|
defer server.Close()
|
|
|
|
transactions, err := c.ListTransactions(context.Background(), 1, 10, time.Time{})
|
|
require.NoError(t, err)
|
|
require.Len(t, transactions, 1)
|
|
require.Equal(t, "tx_1", transactions[0].Id)
|
|
}
|
|
|
|
func TestListTransactions_Error(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(`{"title": "server error", "detail": "internal"}`))
|
|
})
|
|
defer server.Close()
|
|
|
|
_, err := c.ListTransactions(context.Background(), 1, 10, time.Time{})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
// --- Infrastructure tests ---
|
|
|
|
func TestNew_CreatesClient(t *testing.T) {
|
|
t.Parallel()
|
|
c := New("test-connector", "api-key-123", "https://api.example.com")
|
|
require.NotNil(t, c)
|
|
}
|
|
|
|
func TestAPITransport_AddsAuthHeader(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var capturedHeader string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
capturedHeader = r.Header.Get("Authorization")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`[]`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
transport := &apiTransport{APIKey: "test-api-key", underlying: http.DefaultTransport}
|
|
httpClient := &http.Client{Transport: transport}
|
|
req, _ := http.NewRequest(http.MethodGet, server.URL, nil)
|
|
_, err := httpClient.Do(req)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "Bearer test-api-key", capturedHeader)
|
|
}
|
|
|
|
// --- Empty body / read error tests ---
|
|
|
|
func TestCreatePayout_ReadBodyError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
defer server.Close()
|
|
|
|
_, err := c.CreatePayout(context.Background(), &PayoutRequest{IdempotencyKey: "ref_123", Amount: "10000", Currency: "USD/2"})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestCreateTransfer_ReadBodyError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c, server := mockAPIClient(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
defer server.Close()
|
|
|
|
_, err := c.CreateTransfer(context.Background(), &TransferRequest{IdempotencyKey: "ref_123", Amount: "10000", Currency: "USD/2"})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
// --- Network error tests ---
|
|
|
|
type failingTransport struct{}
|
|
|
|
func (f *failingTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
|
return nil, fmt.Errorf("network error: connection refused")
|
|
}
|
|
|
|
func TestCreatePayout_NetworkError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c := &client{
|
|
httpClient: httpwrapper.NewClient(&httpwrapper.Config{
|
|
Transport: &failingTransport{},
|
|
}),
|
|
baseURL: "http://localhost:9999",
|
|
}
|
|
|
|
resp, err := c.CreatePayout(context.Background(), &PayoutRequest{IdempotencyKey: "ref_123"})
|
|
require.Error(t, err)
|
|
require.Nil(t, resp)
|
|
}
|
|
|
|
func TestCreateTransfer_NetworkError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c := &client{
|
|
httpClient: httpwrapper.NewClient(&httpwrapper.Config{
|
|
Transport: &failingTransport{},
|
|
}),
|
|
baseURL: "http://localhost:9999",
|
|
}
|
|
|
|
resp, err := c.CreateTransfer(context.Background(), &TransferRequest{IdempotencyKey: "ref_123"})
|
|
require.Error(t, err)
|
|
require.Nil(t, resp)
|
|
require.Contains(t, err.Error(), "failed to make request")
|
|
}
|
|
|
|
// --- Read response error tests ---
|
|
|
|
type errorReader struct{}
|
|
|
|
func (e *errorReader) Read(p []byte) (n int, err error) {
|
|
return 0, fmt.Errorf("read error: broken pipe")
|
|
}
|
|
|
|
func (e *errorReader) Close() error { return nil }
|
|
|
|
type errorBodyTransport struct{ statusCode int }
|
|
|
|
func (e *errorBodyTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: e.statusCode, Body: &errorReader{}, Header: make(http.Header)}, nil
|
|
}
|
|
|
|
func TestCreatePayout_ReadResponseError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c := &client{
|
|
httpClient: httpwrapper.NewClient(&httpwrapper.Config{
|
|
Transport: &errorBodyTransport{statusCode: http.StatusOK},
|
|
}),
|
|
baseURL: "http://localhost:9999",
|
|
}
|
|
|
|
resp, err := c.CreatePayout(context.Background(), &PayoutRequest{IdempotencyKey: "ref_123"})
|
|
require.Error(t, err)
|
|
require.Nil(t, resp)
|
|
require.Contains(t, err.Error(), "failed to read response body")
|
|
}
|
|
|
|
func TestCreateTransfer_ReadResponseError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c := &client{
|
|
httpClient: httpwrapper.NewClient(&httpwrapper.Config{
|
|
Transport: &errorBodyTransport{statusCode: http.StatusOK},
|
|
}),
|
|
baseURL: "http://localhost:9999",
|
|
}
|
|
|
|
resp, err := c.CreateTransfer(context.Background(), &TransferRequest{IdempotencyKey: "ref_123"})
|
|
require.Error(t, err)
|
|
require.Nil(t, resp)
|
|
require.Contains(t, err.Error(), "failed to read response body")
|
|
}
|