mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
515 lines
14 KiB
Go
515 lines
14 KiB
Go
package accounts
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/luxfi/broker/pkg/provider"
|
|
"github.com/luxfi/broker/pkg/types"
|
|
)
|
|
|
|
// mockProviderForResolver implements provider.Provider for testing AutoDiscover.
|
|
type mockProviderForResolver struct {
|
|
name string
|
|
accounts []*types.Account
|
|
listErr error
|
|
}
|
|
|
|
func (m *mockProviderForResolver) Name() string { return m.name }
|
|
func (m *mockProviderForResolver) CreateAccount(_ context.Context, _ *types.CreateAccountRequest) (*types.Account, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockProviderForResolver) GetAccount(_ context.Context, _ string) (*types.Account, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockProviderForResolver) ListAccounts(_ context.Context) ([]*types.Account, error) {
|
|
if m.listErr != nil {
|
|
return nil, m.listErr
|
|
}
|
|
return m.accounts, nil
|
|
}
|
|
func (m *mockProviderForResolver) GetPortfolio(_ context.Context, _ string) (*types.Portfolio, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockProviderForResolver) CreateOrder(_ context.Context, _ string, _ *types.CreateOrderRequest) (*types.Order, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockProviderForResolver) ListOrders(_ context.Context, _ string) ([]*types.Order, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockProviderForResolver) GetOrder(_ context.Context, _, _ string) (*types.Order, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockProviderForResolver) CancelOrder(_ context.Context, _, _ string) error { return nil }
|
|
func (m *mockProviderForResolver) CreateTransfer(_ context.Context, _ string, _ *types.CreateTransferRequest) (*types.Transfer, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockProviderForResolver) ListTransfers(_ context.Context, _ string) ([]*types.Transfer, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockProviderForResolver) CreateBankRelationship(_ context.Context, _, _, _, _, _ string) (*types.BankRelationship, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockProviderForResolver) ListBankRelationships(_ context.Context, _ string) ([]*types.BankRelationship, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockProviderForResolver) ListAssets(_ context.Context, _ string) ([]*types.Asset, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockProviderForResolver) GetAsset(_ context.Context, _ string) (*types.Asset, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockProviderForResolver) GetSnapshot(_ context.Context, _ string) (*types.MarketSnapshot, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockProviderForResolver) GetSnapshots(_ context.Context, _ []string) (map[string]*types.MarketSnapshot, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockProviderForResolver) GetBars(_ context.Context, _, _, _, _ string, _ int) ([]*types.Bar, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockProviderForResolver) GetLatestTrades(_ context.Context, _ []string) (map[string]*types.Trade, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockProviderForResolver) GetLatestQuotes(_ context.Context, _ []string) (map[string]*types.Quote, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockProviderForResolver) GetClock(_ context.Context) (*types.MarketClock, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockProviderForResolver) GetCalendar(_ context.Context, _, _ string) ([]*types.MarketCalendarDay, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func TestSetGetMapping(t *testing.T) {
|
|
r := NewResolver()
|
|
r.SetMapping("user1", "org1", "alpaca", "acct-abc")
|
|
|
|
got, ok := r.ResolveAccount("user1", "alpaca")
|
|
if !ok {
|
|
t.Fatal("expected mapping to exist")
|
|
}
|
|
if got != "acct-abc" {
|
|
t.Fatalf("expected acct-abc, got %s", got)
|
|
}
|
|
|
|
// Nonexistent lookup returns false.
|
|
_, ok = r.ResolveAccount("user1", "ibkr")
|
|
if ok {
|
|
t.Fatal("expected no mapping for ibkr")
|
|
}
|
|
}
|
|
|
|
func TestSetMappingOverwrite(t *testing.T) {
|
|
r := NewResolver()
|
|
r.SetMapping("user1", "org1", "alpaca", "acct-old")
|
|
r.SetMapping("user1", "org1", "alpaca", "acct-new")
|
|
|
|
got, ok := r.ResolveAccount("user1", "alpaca")
|
|
if !ok {
|
|
t.Fatal("expected mapping to exist after overwrite")
|
|
}
|
|
if got != "acct-new" {
|
|
t.Fatalf("expected acct-new, got %s", got)
|
|
}
|
|
|
|
// Should have exactly one mapping, not two.
|
|
mappings := r.ListMappings("user1")
|
|
if len(mappings) != 1 {
|
|
t.Fatalf("expected 1 mapping after overwrite, got %d", len(mappings))
|
|
}
|
|
}
|
|
|
|
func TestRemoveMapping(t *testing.T) {
|
|
r := NewResolver()
|
|
r.SetMapping("user1", "org1", "alpaca", "acct-abc")
|
|
r.RemoveMapping("user1", "alpaca")
|
|
|
|
_, ok := r.ResolveAccount("user1", "alpaca")
|
|
if ok {
|
|
t.Fatal("expected mapping to be removed")
|
|
}
|
|
|
|
mappings := r.ListMappings("user1")
|
|
if len(mappings) != 0 {
|
|
t.Fatalf("expected 0 mappings, got %d", len(mappings))
|
|
}
|
|
|
|
// Removing nonexistent mapping is a no-op.
|
|
r.RemoveMapping("user1", "alpaca")
|
|
}
|
|
|
|
func TestResolveAnyAccount(t *testing.T) {
|
|
r := NewResolver()
|
|
|
|
// No mappings returns false.
|
|
_, _, ok := r.ResolveAnyAccount("user1")
|
|
if ok {
|
|
t.Fatal("expected no account for unknown user")
|
|
}
|
|
|
|
r.SetMapping("user1", "org1", "alpaca", "acct-abc")
|
|
|
|
prov, acctID, ok := r.ResolveAnyAccount("user1")
|
|
if !ok {
|
|
t.Fatal("expected to resolve any account")
|
|
}
|
|
if prov != "alpaca" || acctID != "acct-abc" {
|
|
t.Fatalf("unexpected result: provider=%s account=%s", prov, acctID)
|
|
}
|
|
}
|
|
|
|
func TestListMappings(t *testing.T) {
|
|
r := NewResolver()
|
|
r.SetMapping("user1", "org1", "alpaca", "acct-a")
|
|
r.SetMapping("user1", "org1", "ibkr", "acct-b")
|
|
r.SetMapping("user1", "org1", "coinbase", "acct-c")
|
|
|
|
mappings := r.ListMappings("user1")
|
|
if len(mappings) != 3 {
|
|
t.Fatalf("expected 3 mappings, got %d", len(mappings))
|
|
}
|
|
|
|
// Verify returned slice is a copy (modifying it doesn't affect internal state).
|
|
mappings[0].AccountID = "tampered"
|
|
original := r.ListMappings("user1")
|
|
if original[0].AccountID == "tampered" {
|
|
t.Fatal("ListMappings returned a reference, not a copy")
|
|
}
|
|
|
|
// Unknown user returns nil.
|
|
if got := r.ListMappings("nobody"); got != nil {
|
|
t.Fatalf("expected nil for unknown user, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestMultipleProvidersPerUser(t *testing.T) {
|
|
r := NewResolver()
|
|
r.SetMapping("user1", "org1", "alpaca", "acct-a")
|
|
r.SetMapping("user1", "org1", "ibkr", "acct-b")
|
|
|
|
a, ok := r.ResolveAccount("user1", "alpaca")
|
|
if !ok || a != "acct-a" {
|
|
t.Fatalf("alpaca: expected acct-a, got %s (ok=%v)", a, ok)
|
|
}
|
|
|
|
b, ok := r.ResolveAccount("user1", "ibkr")
|
|
if !ok || b != "acct-b" {
|
|
t.Fatalf("ibkr: expected acct-b, got %s (ok=%v)", b, ok)
|
|
}
|
|
|
|
// Remove one, the other remains.
|
|
r.RemoveMapping("user1", "alpaca")
|
|
_, ok = r.ResolveAccount("user1", "alpaca")
|
|
if ok {
|
|
t.Fatal("alpaca mapping should be gone")
|
|
}
|
|
b, ok = r.ResolveAccount("user1", "ibkr")
|
|
if !ok || b != "acct-b" {
|
|
t.Fatal("ibkr mapping should still exist")
|
|
}
|
|
}
|
|
|
|
func TestConcurrentAccess(t *testing.T) {
|
|
r := NewResolver()
|
|
var wg sync.WaitGroup
|
|
|
|
// Concurrent writes.
|
|
for i := 0; i < 100; i++ {
|
|
wg.Add(1)
|
|
go func(n int) {
|
|
defer wg.Done()
|
|
userID := "user1"
|
|
provider := "alpaca"
|
|
if n%2 == 0 {
|
|
provider = "ibkr"
|
|
}
|
|
r.SetMapping(userID, "org1", provider, "acct")
|
|
}(i)
|
|
}
|
|
|
|
// Concurrent reads interleaved with writes.
|
|
for i := 0; i < 100; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
r.ResolveAccount("user1", "alpaca")
|
|
r.ResolveAnyAccount("user1")
|
|
r.ListMappings("user1")
|
|
}()
|
|
}
|
|
|
|
// Concurrent removes.
|
|
for i := 0; i < 50; i++ {
|
|
wg.Add(1)
|
|
go func(n int) {
|
|
defer wg.Done()
|
|
provider := "alpaca"
|
|
if n%2 == 0 {
|
|
provider = "ibkr"
|
|
}
|
|
r.RemoveMapping("user1", provider)
|
|
}(i)
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
// No race conditions — if we got here without -race failing, it's correct.
|
|
}
|
|
|
|
func TestAutoDiscover(t *testing.T) {
|
|
mp := &mockProviderForResolver{
|
|
name: "alpaca",
|
|
accounts: []*types.Account{
|
|
{ProviderID: "acct-1", UserID: "user1", OrgID: "org1"},
|
|
{ProviderID: "acct-2", UserID: "user2", OrgID: "org2"},
|
|
},
|
|
}
|
|
mp2 := &mockProviderForResolver{
|
|
name: "ibkr",
|
|
accounts: []*types.Account{
|
|
{ProviderID: "ibkr-1", UserID: "user1", OrgID: "org1"},
|
|
},
|
|
}
|
|
|
|
registry := provider.NewRegistry()
|
|
registry.Register(mp)
|
|
registry.Register(mp2)
|
|
|
|
r := NewResolver()
|
|
err := r.AutoDiscover(context.Background(), registry, "user1", "org1")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// user1 should have accounts from both providers
|
|
mappings := r.ListMappings("user1")
|
|
if len(mappings) != 2 {
|
|
t.Fatalf("expected 2 mappings for user1, got %d", len(mappings))
|
|
}
|
|
|
|
acctA, ok := r.ResolveAccount("user1", "alpaca")
|
|
if !ok || acctA != "acct-1" {
|
|
t.Fatalf("expected alpaca acct-1, got %s (ok=%v)", acctA, ok)
|
|
}
|
|
|
|
acctB, ok := r.ResolveAccount("user1", "ibkr")
|
|
if !ok || acctB != "ibkr-1" {
|
|
t.Fatalf("expected ibkr ibkr-1, got %s (ok=%v)", acctB, ok)
|
|
}
|
|
}
|
|
|
|
func TestAutoDiscoverByOrgID(t *testing.T) {
|
|
// Account belongs to org but has a different userID
|
|
mp := &mockProviderForResolver{
|
|
name: "alpaca",
|
|
accounts: []*types.Account{
|
|
{ProviderID: "acct-org", UserID: "other-user", OrgID: "shared-org"},
|
|
},
|
|
}
|
|
|
|
registry := provider.NewRegistry()
|
|
registry.Register(mp)
|
|
|
|
r := NewResolver()
|
|
err := r.AutoDiscover(context.Background(), registry, "user1", "shared-org")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Should match by orgID
|
|
acct, ok := r.ResolveAccount("user1", "alpaca")
|
|
if !ok || acct != "acct-org" {
|
|
t.Fatalf("expected acct-org via orgID match, got %s (ok=%v)", acct, ok)
|
|
}
|
|
}
|
|
|
|
func TestAutoDiscoverNoMatchingAccounts(t *testing.T) {
|
|
mp := &mockProviderForResolver{
|
|
name: "alpaca",
|
|
accounts: []*types.Account{
|
|
{ProviderID: "acct-1", UserID: "other-user", OrgID: "other-org"},
|
|
},
|
|
}
|
|
|
|
registry := provider.NewRegistry()
|
|
registry.Register(mp)
|
|
|
|
r := NewResolver()
|
|
err := r.AutoDiscover(context.Background(), registry, "user1", "org1")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
mappings := r.ListMappings("user1")
|
|
if len(mappings) != 0 {
|
|
t.Fatalf("expected 0 mappings, got %d", len(mappings))
|
|
}
|
|
}
|
|
|
|
func TestAutoDiscoverProviderError(t *testing.T) {
|
|
mp := &mockProviderForResolver{
|
|
name: "broken",
|
|
listErr: fmt.Errorf("connection refused"),
|
|
}
|
|
|
|
registry := provider.NewRegistry()
|
|
registry.Register(mp)
|
|
|
|
r := NewResolver()
|
|
err := r.AutoDiscover(context.Background(), registry, "user1", "org1")
|
|
if err == nil {
|
|
t.Fatal("expected error when provider fails")
|
|
}
|
|
if !strings.Contains(err.Error(), "1 errors") {
|
|
t.Fatalf("expected error count in message, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAutoDiscoverEmptyRegistry(t *testing.T) {
|
|
registry := provider.NewRegistry()
|
|
r := NewResolver()
|
|
err := r.AutoDiscover(context.Background(), registry, "user1", "org1")
|
|
if err != nil {
|
|
t.Fatalf("expected no error for empty registry, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestImportFromJWT(t *testing.T) {
|
|
r := NewResolver()
|
|
payload := map[string]interface{}{
|
|
"sub": "user1",
|
|
"owner": "org1",
|
|
"accounts": map[string]interface{}{
|
|
"alpaca": "acct-abc",
|
|
"ibkr": "acct-xyz",
|
|
},
|
|
}
|
|
|
|
err := r.ImportFromJWT(payload)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
acctA, ok := r.ResolveAccount("user1", "alpaca")
|
|
if !ok || acctA != "acct-abc" {
|
|
t.Fatalf("expected alpaca acct-abc, got %s (ok=%v)", acctA, ok)
|
|
}
|
|
acctB, ok := r.ResolveAccount("user1", "ibkr")
|
|
if !ok || acctB != "acct-xyz" {
|
|
t.Fatalf("expected ibkr acct-xyz, got %s (ok=%v)", acctB, ok)
|
|
}
|
|
|
|
// Verify org was set
|
|
mappings := r.ListMappings("user1")
|
|
for _, m := range mappings {
|
|
if m.OrgID != "org1" {
|
|
t.Fatalf("expected orgID org1, got %s", m.OrgID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestImportFromJWTMissingSub(t *testing.T) {
|
|
r := NewResolver()
|
|
err := r.ImportFromJWT(map[string]interface{}{
|
|
"owner": "org1",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for missing sub claim")
|
|
}
|
|
if !strings.Contains(err.Error(), "sub") {
|
|
t.Fatalf("expected error about 'sub', got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestImportFromJWTNoAccounts(t *testing.T) {
|
|
r := NewResolver()
|
|
err := r.ImportFromJWT(map[string]interface{}{
|
|
"sub": "user1",
|
|
"owner": "org1",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// No accounts key means no mappings created, but no error
|
|
mappings := r.ListMappings("user1")
|
|
if len(mappings) != 0 {
|
|
t.Fatalf("expected 0 mappings, got %d", len(mappings))
|
|
}
|
|
}
|
|
|
|
func TestImportFromJWTNoOwner(t *testing.T) {
|
|
r := NewResolver()
|
|
err := r.ImportFromJWT(map[string]interface{}{
|
|
"sub": "user1",
|
|
"accounts": map[string]interface{}{
|
|
"alpaca": "acct-1",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
acct, ok := r.ResolveAccount("user1", "alpaca")
|
|
if !ok || acct != "acct-1" {
|
|
t.Fatalf("expected acct-1, got %s (ok=%v)", acct, ok)
|
|
}
|
|
// OrgID should be empty
|
|
mappings := r.ListMappings("user1")
|
|
if mappings[0].OrgID != "" {
|
|
t.Fatalf("expected empty orgID, got %s", mappings[0].OrgID)
|
|
}
|
|
}
|
|
|
|
func TestImportFromJWTNonStringAccountValue(t *testing.T) {
|
|
r := NewResolver()
|
|
err := r.ImportFromJWT(map[string]interface{}{
|
|
"sub": "user1",
|
|
"accounts": map[string]interface{}{
|
|
"alpaca": "acct-good",
|
|
"broken": 12345, // non-string value, should be skipped
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
_, ok := r.ResolveAccount("user1", "broken")
|
|
if ok {
|
|
t.Fatal("expected non-string account value to be skipped")
|
|
}
|
|
acct, ok := r.ResolveAccount("user1", "alpaca")
|
|
if !ok || acct != "acct-good" {
|
|
t.Fatalf("expected acct-good, got %s", acct)
|
|
}
|
|
}
|
|
|
|
func TestImportFromJWTEmptySub(t *testing.T) {
|
|
r := NewResolver()
|
|
err := r.ImportFromJWT(map[string]interface{}{
|
|
"sub": "",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for empty sub")
|
|
}
|
|
}
|
|
|
|
func TestMappingCreatedAtIsSet(t *testing.T) {
|
|
r := NewResolver()
|
|
before := time.Now()
|
|
r.SetMapping("user1", "org1", "alpaca", "acct-1")
|
|
after := time.Now()
|
|
|
|
mappings := r.ListMappings("user1")
|
|
if len(mappings) != 1 {
|
|
t.Fatalf("expected 1 mapping, got %d", len(mappings))
|
|
}
|
|
if mappings[0].CreatedAt.Before(before) || mappings[0].CreatedAt.After(after) {
|
|
t.Fatalf("CreatedAt %v not between %v and %v", mappings[0].CreatedAt, before, after)
|
|
}
|
|
}
|