Set up default currency accounts automatically (#72)
This commit is contained in:
1 parent
62df9eac5f
commit
5c255296d3
21 files changed
+675
-62
No files matched your search
@@ -0,0 +1,200 @@
|
||||
package sendico
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type accountServer struct {
|
||||
mu sync.Mutex
|
||||
rows []accountDTO
|
||||
posts []string
|
||||
capable bool
|
||||
missingCurrency bool
|
||||
failCurrency string
|
||||
loseResponse bool
|
||||
wrongResponse bool
|
||||
}
|
||||
|
||||
func customerLedger(code string) accountDTO {
|
||||
return accountDTO{ID: bson.NewObjectID().Hex(), ScopeRef: org, Currency: code, Type: "ACCOUNT_TYPE_ASSET", LedgerRole: "ACCOUNT_ROLE_OPERATING", Status: "ACCOUNT_STATUS_ACTIVE", Metadata: map[string]string{"sxchnge_subject_ref": subject, "external_subject_ref": subject}}
|
||||
}
|
||||
|
||||
func (s *accountServer) serve(t *testing.T) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
require.NotEmpty(t, r.Header.Get("Authorization"))
|
||||
var data any
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/currencies/"):
|
||||
rows := []map[string]any{{"code": "RUB", "accountingScale": 2}, {"code": "USDT", "accountingScale": 6}, {"code": "USDC", "accountingScale": 6}}
|
||||
if s.missingCurrency {
|
||||
rows = rows[:2]
|
||||
}
|
||||
data = map[string]any{"currencies": rows}
|
||||
case r.Method == http.MethodPost:
|
||||
var request createAccountDTO
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
|
||||
require.Equal(t, "asset", request.Type)
|
||||
require.Equal(t, "operating", request.LedgerRole)
|
||||
require.False(t, request.AllowNegative)
|
||||
require.Equal(t, subject, request.Metadata["external_subject_ref"])
|
||||
require.Equal(t, subject, request.Metadata["sxchnge_subject_ref"])
|
||||
s.posts = append(s.posts, request.Currency)
|
||||
if s.failCurrency == request.Currency {
|
||||
s.failCurrency = ""
|
||||
w.WriteHeader(503)
|
||||
return
|
||||
}
|
||||
var row accountDTO
|
||||
for _, a := range s.rows {
|
||||
if a.Currency == request.Currency && a.Metadata["external_subject_ref"] == subject {
|
||||
row = a
|
||||
}
|
||||
}
|
||||
if row.ID == "" {
|
||||
row = customerLedger(request.Currency)
|
||||
s.rows = append(s.rows, row)
|
||||
}
|
||||
if s.loseResponse {
|
||||
s.loseResponse = false
|
||||
w.WriteHeader(503)
|
||||
return
|
||||
}
|
||||
if s.wrongResponse {
|
||||
row.Currency = "EUR"
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
data = map[string]any{"account": row}
|
||||
case strings.HasSuffix(r.URL.Path, "/balance"):
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
data = map[string]any{"balance": balanceDTO{ID: parts[len(parts)-2], Money: moneyDTO{Amount: "45.00", Currency: "RUB"}}}
|
||||
default:
|
||||
data = map[string]any{"accounts": s.rows, "externalSubjectAccounts": s.capable}
|
||||
}
|
||||
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"status": "success", "data": data}))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCurrencyAccountsPreservesExistingAndRecoversPartialFailure(t *testing.T) {
|
||||
for _, lost := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "partial failure", true: "lost response"}[lost], func(t *testing.T) {
|
||||
s := &accountServer{capable: true, failCurrency: "USDT", loseResponse: lost}
|
||||
if lost {
|
||||
s.failCurrency = ""
|
||||
}
|
||||
server := httptest.NewServer(s.serve(t))
|
||||
defer server.Close()
|
||||
c, err := Open(config(t, server.URL))
|
||||
require.NoError(t, err)
|
||||
require.Error(t, c.EnsureAccounts(t.Context(), subject))
|
||||
require.Len(t, s.rows, 1)
|
||||
firstID := s.rows[0].ID
|
||||
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
|
||||
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
|
||||
require.Len(t, s.rows, 3)
|
||||
require.Equal(t, firstID, s.rows[0].ID)
|
||||
// Bound creates are replayed to finish interrupted wallet/route setup;
|
||||
// the provider must return the original ledger each time.
|
||||
require.Equal(t, 3, countCurrency(s.posts, "RUB"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func countCurrency(rows []string, code string) int {
|
||||
n := 0
|
||||
for _, r := range rows {
|
||||
if r == code {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestEnsureAccountsConcurrentRequestsUseStableSubjectBinding(t *testing.T) {
|
||||
s := &accountServer{capable: true}
|
||||
server := httptest.NewServer(s.serve(t))
|
||||
defer server.Close()
|
||||
c, err := Open(config(t, server.URL))
|
||||
require.NoError(t, err)
|
||||
errors := make(chan error, 12)
|
||||
for range 12 {
|
||||
go func() { errors <- c.EnsureAccounts(t.Context(), subject) }()
|
||||
}
|
||||
for range 12 {
|
||||
require.NoError(t, <-errors)
|
||||
}
|
||||
require.Len(t, s.rows, 3)
|
||||
}
|
||||
|
||||
func TestEnsureAccountsRejectsUnsafeProviderStateBeforeCreating(t *testing.T) {
|
||||
for _, kind := range []string{"old provider", "missing currency", "frozen", "owned", "negative", "duplicate", "wrong subject"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
s := &accountServer{capable: true}
|
||||
a := customerLedger("RUB")
|
||||
switch kind {
|
||||
case "old provider":
|
||||
s.capable = false
|
||||
case "missing currency":
|
||||
s.missingCurrency = true
|
||||
case "frozen":
|
||||
a.Status = "ACCOUNT_STATUS_FROZEN"
|
||||
s.rows = []accountDTO{a}
|
||||
case "owned":
|
||||
a.Owner = source
|
||||
s.rows = []accountDTO{a}
|
||||
case "negative":
|
||||
a.AllowNegative = true
|
||||
s.rows = []accountDTO{a}
|
||||
case "duplicate":
|
||||
s.rows = []accountDTO{a, customerLedger("RUB")}
|
||||
case "wrong subject":
|
||||
a.Metadata["sxchnge_subject_ref"] = source
|
||||
s.rows = []accountDTO{a}
|
||||
}
|
||||
server := httptest.NewServer(s.serve(t))
|
||||
defer server.Close()
|
||||
c, err := Open(config(t, server.URL))
|
||||
require.NoError(t, err)
|
||||
require.Error(t, c.EnsureAccounts(t.Context(), subject))
|
||||
require.Empty(t, s.posts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAccountsValidatesCreateReceipt(t *testing.T) {
|
||||
s := &accountServer{capable: true, wrongResponse: true}
|
||||
server := httptest.NewServer(s.serve(t))
|
||||
defer server.Close()
|
||||
c, err := Open(config(t, server.URL))
|
||||
require.NoError(t, err)
|
||||
require.Error(t, c.EnsureAccounts(t.Context(), subject))
|
||||
require.Len(t, s.posts, 1)
|
||||
}
|
||||
|
||||
func TestEnsureAccountsKeepsExistingBindingsAndBalances(t *testing.T) {
|
||||
a := customerLedger("RUB")
|
||||
delete(a.Metadata, "external_subject_ref")
|
||||
s := &accountServer{capable: true, rows: []accountDTO{a}}
|
||||
server := httptest.NewServer(s.serve(t))
|
||||
defer server.Close()
|
||||
c, err := Open(config(t, server.URL))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
|
||||
require.Equal(t, []string{"USDT", "USDC"}, s.posts)
|
||||
// A subsequent read uses the original RUB ledger and its authoritative funds.
|
||||
s.rows = s.rows[:1]
|
||||
accounts, err := c.Accounts(t.Context(), subject)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, accounts, 1)
|
||||
require.Equal(t, a.ID, accounts[0].ID)
|
||||
require.EqualValues(t, 4500, accounts[0].Available)
|
||||
}
|
||||
Reference in new issue
Block a user