Files
zeekay 7d727874d5 chore: rebrand Formance Payments -> Hanzo Payments (hanzo-fi)
- 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.
2026-07-05 13:08:55 -07:00

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