mirror of
https://github.com/luxfi/broker.git
synced 2026-11-02 12:40:46 +00:00
378 lines
11 KiB
Go
378 lines
11 KiB
Go
package alpaca
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/luxfi/broker/pkg/types"
|
|
)
|
|
|
|
func TestACATSDisclosureNotEmpty(t *testing.T) {
|
|
if ACATSDisclosure == "" {
|
|
t.Fatal("ACATSDisclosure constant must not be empty")
|
|
}
|
|
if !strings.Contains(ACATSDisclosure, "Alpaca Securities LLC") {
|
|
t.Fatal("ACATSDisclosure must mention Alpaca Securities LLC")
|
|
}
|
|
}
|
|
|
|
func TestValidateACATSAssets(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
assets []ACATSAsset
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "empty assets",
|
|
assets: nil,
|
|
wantErr: "at least one asset",
|
|
},
|
|
{
|
|
name: "crypto symbol",
|
|
assets: []ACATSAsset{{Symbol: "BTC/USD", Qty: "1"}},
|
|
wantErr: "crypto",
|
|
},
|
|
{
|
|
name: "options OCC symbol",
|
|
assets: []ACATSAsset{{Symbol: "AAPL260418C00150000", Qty: "1"}},
|
|
wantErr: "options",
|
|
},
|
|
{
|
|
name: "fractional shares",
|
|
assets: []ACATSAsset{{Symbol: "AAPL", Qty: "1.5"}},
|
|
wantErr: "whole shares",
|
|
},
|
|
{
|
|
name: "zero qty",
|
|
assets: []ACATSAsset{{Symbol: "AAPL", Qty: "0"}},
|
|
wantErr: "positive",
|
|
},
|
|
{
|
|
name: "negative qty",
|
|
assets: []ACATSAsset{{Symbol: "AAPL", Qty: "-5"}},
|
|
wantErr: "positive",
|
|
},
|
|
{
|
|
name: "invalid qty",
|
|
assets: []ACATSAsset{{Symbol: "AAPL", Qty: "abc"}},
|
|
wantErr: "positive",
|
|
},
|
|
{
|
|
name: "valid single asset",
|
|
assets: []ACATSAsset{{Symbol: "AAPL", Qty: "10"}},
|
|
},
|
|
{
|
|
name: "valid multiple assets",
|
|
assets: []ACATSAsset{
|
|
{Symbol: "AAPL", Qty: "10"},
|
|
{Symbol: "MSFT", Qty: "5"},
|
|
{Symbol: "GOOG", Qty: "1"},
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := ValidateACATSAssets(tt.assets)
|
|
if tt.wantErr == "" {
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Fatalf("error %q does not contain %q", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateACATSAccount_Active(t *testing.T) {
|
|
_, p := testServer(t, map[string]http.HandlerFunc{
|
|
"/v1/accounts/acct-1": func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"id": "acct-1",
|
|
"account_number": "123",
|
|
"status": "ACTIVE",
|
|
"currency": "USD",
|
|
})
|
|
},
|
|
})
|
|
|
|
err := p.ValidateACATSAccount(context.Background(), "acct-1")
|
|
if err != nil {
|
|
t.Fatalf("expected no error for ACTIVE account, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateACATSAccount_Rejected(t *testing.T) {
|
|
_, p := testServer(t, map[string]http.HandlerFunc{
|
|
"/v1/accounts/acct-2": func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"id": "acct-2",
|
|
"account_number": "456",
|
|
"status": "SUBMITTED",
|
|
"currency": "USD",
|
|
})
|
|
},
|
|
})
|
|
|
|
err := p.ValidateACATSAccount(context.Background(), "acct-2")
|
|
if err == nil {
|
|
t.Fatal("expected error for SUBMITTED account")
|
|
}
|
|
if !strings.Contains(err.Error(), "not eligible") {
|
|
t.Fatalf("error %q does not mention eligibility", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateACATSTransfer_Full(t *testing.T) {
|
|
_, p := testServer(t, map[string]http.HandlerFunc{
|
|
"/v1/accounts/acct-1": func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"id": "acct-1",
|
|
"status": "ACTIVE",
|
|
})
|
|
},
|
|
"/v1/accounts/acct-1/transfers": func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"id": "xfer-1",
|
|
"account_id": "acct-1",
|
|
"direction": "INCOMING",
|
|
"status": "QUEUED",
|
|
"type": "ACATS",
|
|
"transfer_type": "FULL",
|
|
"contra_account_number": "999-888",
|
|
"contra_broker_number": "0123",
|
|
"created_at": "2026-04-05T10:00:00Z",
|
|
"updated_at": "2026-04-05T10:00:00Z",
|
|
})
|
|
},
|
|
})
|
|
|
|
transfer, err := p.CreateACATSTransfer(context.Background(), "acct-1", &types.CreateACATSTransferRequest{
|
|
ContraAccount: "999-888",
|
|
ContraBroker: "0123",
|
|
Type: "FULL",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if transfer.ID != "xfer-1" {
|
|
t.Fatalf("ID = %q, want xfer-1", transfer.ID)
|
|
}
|
|
if transfer.Status != "QUEUED" {
|
|
t.Fatalf("Status = %q, want QUEUED", transfer.Status)
|
|
}
|
|
if transfer.Type != "ACATS" {
|
|
t.Fatalf("Type = %q, want ACATS", transfer.Type)
|
|
}
|
|
}
|
|
|
|
func TestCreateACATSTransfer_PartialValidationFails(t *testing.T) {
|
|
_, p := testServer(t, map[string]http.HandlerFunc{
|
|
"/v1/accounts/acct-1": func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"id": "acct-1",
|
|
"status": "ACTIVE",
|
|
})
|
|
},
|
|
})
|
|
|
|
_, err := p.CreateACATSTransfer(context.Background(), "acct-1", &types.CreateACATSTransferRequest{
|
|
ContraAccount: "999-888",
|
|
ContraBroker: "0123",
|
|
Type: "PARTIAL",
|
|
Assets: []types.ACATSAsset{{Symbol: "BTC/USD", Qty: "1"}},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for crypto asset in ACATS")
|
|
}
|
|
if !strings.Contains(err.Error(), "crypto") {
|
|
t.Fatalf("error %q does not mention crypto", err)
|
|
}
|
|
}
|
|
|
|
func TestGetACATSTransfer(t *testing.T) {
|
|
_, p := testServer(t, map[string]http.HandlerFunc{
|
|
"/v1/accounts/acct-1/transfers/xfer-1": func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"id": "xfer-1",
|
|
"account_id": "acct-1",
|
|
"status": "APPROVED",
|
|
"direction": "INCOMING",
|
|
"type": "ACATS",
|
|
"reject_reason": "",
|
|
"created_at": "2026-04-05T10:00:00Z",
|
|
"updated_at": "2026-04-05T10:30:00Z",
|
|
})
|
|
},
|
|
})
|
|
|
|
transfer, err := p.GetACATSTransfer(context.Background(), "acct-1", "xfer-1")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if transfer.Status != "APPROVED" {
|
|
t.Fatalf("Status = %q, want APPROVED", transfer.Status)
|
|
}
|
|
}
|
|
|
|
func TestGetACATSRejection(t *testing.T) {
|
|
_, p := testServer(t, map[string]http.HandlerFunc{
|
|
"/v1/accounts/acct-1/transfers/xfer-rej": func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"id": "xfer-rej",
|
|
"account_id": "acct-1",
|
|
"status": "REJECTED",
|
|
"direction": "INCOMING",
|
|
"type": "ACATS",
|
|
"reject_reason": "Account number mismatch at contra broker",
|
|
"created_at": "2026-04-05T10:00:00Z",
|
|
"updated_at": "2026-04-05T11:00:00Z",
|
|
})
|
|
},
|
|
})
|
|
|
|
reason, err := p.GetACATSRejection(context.Background(), "acct-1", "xfer-rej")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if reason != "Account number mismatch at contra broker" {
|
|
t.Fatalf("reason = %q, want account number mismatch", reason)
|
|
}
|
|
}
|
|
|
|
func TestGetACATSRejection_NotRejected(t *testing.T) {
|
|
_, p := testServer(t, map[string]http.HandlerFunc{
|
|
"/v1/accounts/acct-1/transfers/xfer-ok": func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"id": "xfer-ok",
|
|
"account_id": "acct-1",
|
|
"status": "APPROVED",
|
|
"direction": "INCOMING",
|
|
"type": "ACATS",
|
|
"created_at": "2026-04-05T10:00:00Z",
|
|
"updated_at": "2026-04-05T10:30:00Z",
|
|
})
|
|
},
|
|
})
|
|
|
|
_, err := p.GetACATSRejection(context.Background(), "acct-1", "xfer-ok")
|
|
if err == nil {
|
|
t.Fatal("expected error for non-rejected transfer")
|
|
}
|
|
if !strings.Contains(err.Error(), "not rejected") {
|
|
t.Fatalf("error %q does not mention 'not rejected'", err)
|
|
}
|
|
}
|
|
|
|
func TestListACATSTransfers(t *testing.T) {
|
|
_, p := testServer(t, map[string]http.HandlerFunc{
|
|
"/v1/accounts/acct-1/transfers": func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Query().Get("type") != "ACATS" {
|
|
http.Error(w, "missing type=ACATS", http.StatusBadRequest)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode([]map[string]interface{}{
|
|
{"id": "xfer-1", "status": "COMPLETED", "type": "ACATS", "created_at": "2026-04-01T00:00:00Z", "updated_at": "2026-04-03T00:00:00Z"},
|
|
{"id": "xfer-2", "status": "QUEUED", "type": "ACATS", "created_at": "2026-04-05T00:00:00Z", "updated_at": "2026-04-05T00:00:00Z"},
|
|
})
|
|
},
|
|
})
|
|
|
|
transfers, err := p.ListACATSTransfers(context.Background(), "acct-1")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(transfers) != 2 {
|
|
t.Fatalf("got %d transfers, want 2", len(transfers))
|
|
}
|
|
if transfers[0].ID != "xfer-1" || transfers[1].ID != "xfer-2" {
|
|
t.Fatalf("unexpected transfer IDs: %s, %s", transfers[0].ID, transfers[1].ID)
|
|
}
|
|
}
|
|
|
|
func TestListACATSActivities(t *testing.T) {
|
|
_, p := testServer(t, map[string]http.HandlerFunc{
|
|
"/v1/accounts/acct-1/activities": func(w http.ResponseWriter, r *http.Request) {
|
|
actType := r.URL.Query().Get("activity_type")
|
|
if !strings.Contains(actType, "ACATC") || !strings.Contains(actType, "ACATS") {
|
|
http.Error(w, "expected activity_type=ACATC,ACATS", http.StatusBadRequest)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode([]map[string]interface{}{
|
|
{"id": "act-1", "activity_type": "ACATC", "account_id": "acct-1", "date": "2026-04-03"},
|
|
{"id": "act-2", "activity_type": "ACATS", "account_id": "acct-1", "date": "2026-04-05"},
|
|
})
|
|
},
|
|
})
|
|
|
|
activities, err := p.ListACATSActivities(context.Background(), "acct-1")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(activities) != 2 {
|
|
t.Fatalf("got %d activities, want 2", len(activities))
|
|
}
|
|
if activities[0].ActivityType != "ACATC" {
|
|
t.Fatalf("first activity type = %q, want ACATC", activities[0].ActivityType)
|
|
}
|
|
if activities[1].ActivityType != "ACATS" {
|
|
t.Fatalf("second activity type = %q, want ACATS", activities[1].ActivityType)
|
|
}
|
|
}
|
|
|
|
func TestCancelACATSTransfer(t *testing.T) {
|
|
_, p := testServer(t, map[string]http.HandlerFunc{
|
|
"/v1/accounts/acct-1/transfers/xfer-1": func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
},
|
|
})
|
|
|
|
err := p.CancelACATSTransfer(context.Background(), "acct-1", "xfer-1")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestParseACATSTransfer_TransferTypeFallback(t *testing.T) {
|
|
data := `{
|
|
"id": "xfer-3",
|
|
"account_id": "acct-1",
|
|
"status": "QUEUED",
|
|
"direction": "INCOMING",
|
|
"type": "",
|
|
"transfer_type": "PARTIAL",
|
|
"assets": [{"symbol": "AAPL", "qty": "10", "status": "QUEUED"}],
|
|
"created_at": "2026-04-05T10:00:00Z",
|
|
"updated_at": "2026-04-05T10:00:00Z"
|
|
}`
|
|
transfer, err := parseACATSTransfer([]byte(data))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if transfer.Type != "PARTIAL" {
|
|
t.Fatalf("Type = %q, want PARTIAL (from transfer_type fallback)", transfer.Type)
|
|
}
|
|
if len(transfer.Assets) != 1 {
|
|
t.Fatalf("got %d assets, want 1", len(transfer.Assets))
|
|
}
|
|
if transfer.Assets[0].Symbol != "AAPL" {
|
|
t.Fatalf("asset symbol = %q, want AAPL", transfer.Assets[0].Symbol)
|
|
}
|
|
}
|