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