mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
264 lines
7.0 KiB
Go
264 lines
7.0 KiB
Go
package webhooks
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestTradeWebhookPayloadUnmarshal(t *testing.T) {
|
|
schemaDir := filepath.Join("schemas")
|
|
files := []string{
|
|
"trade__corporation.json",
|
|
"trade__llc.json",
|
|
"trade__trust.json",
|
|
"trade__partnership.json",
|
|
"trade__sole_proprietorship.json",
|
|
"trade__public_company.json",
|
|
"trade__spv.json",
|
|
}
|
|
|
|
for _, f := range files {
|
|
t.Run(f, func(t *testing.T) {
|
|
data, err := os.ReadFile(filepath.Join(schemaDir, f))
|
|
if err != nil {
|
|
t.Fatalf("read schema file: %v", err)
|
|
}
|
|
|
|
var payload TradeWebhookPayload
|
|
if err := json.Unmarshal(data, &payload); err != nil {
|
|
t.Fatalf("unmarshal %s: %v", f, err)
|
|
}
|
|
|
|
// Verify key fields are populated.
|
|
if payload.WebhookEvent.EventType != "trade.executed" {
|
|
t.Errorf("expected event_type trade.executed, got %q", payload.WebhookEvent.EventType)
|
|
}
|
|
if payload.WebhookEvent.EventID == "" {
|
|
t.Error("event_id is empty")
|
|
}
|
|
if len(payload.WebhookEvent.Recipients) != 3 {
|
|
t.Errorf("expected 3 recipients, got %d", len(payload.WebhookEvent.Recipients))
|
|
}
|
|
if payload.Transaction.TransactionID == "" {
|
|
t.Error("transaction_id is empty")
|
|
}
|
|
if payload.Transaction.Security.AssetID == "" {
|
|
t.Error("asset_id is empty")
|
|
}
|
|
if payload.Transaction.Security.GrossTradeAmount <= 0 {
|
|
t.Error("gross_trade_amount should be positive")
|
|
}
|
|
if payload.Buyer.InvestorID == "" {
|
|
t.Error("buyer investor_id is empty")
|
|
}
|
|
if payload.Seller.InvestorID == "" {
|
|
t.Error("seller investor_id is empty")
|
|
}
|
|
if payload.TransferAgent.FirmName == "" {
|
|
t.Error("transfer_agent firm_name is empty")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTradeWebhookPayloadRoundtrip(t *testing.T) {
|
|
now := time.Now().UTC().Truncate(time.Millisecond)
|
|
payload := TradeWebhookPayload{
|
|
WebhookEvent: WebhookEvent{
|
|
EventID: "evt_test_001",
|
|
EventType: EventTradeExecuted,
|
|
Endpoint: "POST /v1/webhooks/trade",
|
|
Timestamp: now,
|
|
Version: "2.0.0",
|
|
TransactionType: "trade",
|
|
},
|
|
Transaction: Transaction{
|
|
TransactionID: "txn_test_001",
|
|
TransactionType: "secondary_market_transfer",
|
|
Status: "pending_compliance_clearance",
|
|
InitiatedAt: now,
|
|
Security: Security{
|
|
AssetID: "asset_test_001",
|
|
NumberOfShares: 100,
|
|
PricePerShare: 50.0,
|
|
GrossTradeAmount: 5000.0,
|
|
Currency: "USD",
|
|
},
|
|
},
|
|
Buyer: Party{
|
|
InvestorID: "inv_buyer_001",
|
|
AccountID: "acct_buyer_001",
|
|
},
|
|
Seller: Party{
|
|
InvestorID: "inv_seller_001",
|
|
AccountID: "acct_seller_001",
|
|
},
|
|
TransferAgent: TransferAgent{
|
|
FirmName: "Test TA",
|
|
SECRegistered: true,
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
|
|
var decoded TradeWebhookPayload
|
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
|
|
if decoded.WebhookEvent.EventID != "evt_test_001" {
|
|
t.Errorf("roundtrip event_id mismatch: %q", decoded.WebhookEvent.EventID)
|
|
}
|
|
if decoded.Transaction.Security.GrossTradeAmount != 5000.0 {
|
|
t.Errorf("roundtrip gross_trade_amount mismatch: %v", decoded.Transaction.Security.GrossTradeAmount)
|
|
}
|
|
}
|
|
|
|
func TestDispatcherFireTradeExecuted(t *testing.T) {
|
|
var deliveries atomic.Int32
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
if r.Header.Get("Content-Type") != "application/json" {
|
|
t.Errorf("expected application/json, got %s", r.Header.Get("Content-Type"))
|
|
}
|
|
sig := r.Header.Get("X-Webhook-Signature")
|
|
if sig == "" {
|
|
t.Error("missing X-Webhook-Signature header")
|
|
}
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Errorf("read body: %v", err)
|
|
}
|
|
if len(body) == 0 {
|
|
t.Error("empty body")
|
|
}
|
|
|
|
// Verify signature.
|
|
if !VerifySignature(body, sig, "test-secret") {
|
|
t.Error("signature verification failed")
|
|
}
|
|
|
|
deliveries.Add(1)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
d := NewDispatcher()
|
|
payload := &TradeWebhookPayload{
|
|
WebhookEvent: WebhookEvent{
|
|
EventID: "evt_test_dispatch",
|
|
TransactionType: "trade",
|
|
},
|
|
Transaction: Transaction{
|
|
TransactionID: "txn_dispatch_001",
|
|
Security: Security{
|
|
AssetID: "asset_dispatch_001",
|
|
GrossTradeAmount: 10000.0,
|
|
},
|
|
},
|
|
Buyer: Party{
|
|
InvestorID: "inv_buyer_dispatch",
|
|
},
|
|
Seller: Party{
|
|
InvestorID: "inv_seller_dispatch",
|
|
},
|
|
TransferAgent: TransferAgent{
|
|
FirmName: "Test TA",
|
|
},
|
|
}
|
|
|
|
targets := []WebhookTarget{
|
|
{RecipientID: "rec_001", Name: "BD Alpha", Role: "buyer_broker_dealer", URL: srv.URL, HMACSecret: "test-secret"},
|
|
{RecipientID: "rec_002", Name: "BD Beta", Role: "seller_broker_dealer", URL: srv.URL, HMACSecret: "test-secret"},
|
|
{RecipientID: "rec_003", Name: "TA Gamma", Role: "transfer_agent", URL: srv.URL, HMACSecret: "test-secret"},
|
|
}
|
|
|
|
ctx := context.Background()
|
|
err := d.FireTradeExecuted(ctx, payload, targets)
|
|
if err != nil {
|
|
t.Fatalf("FireTradeExecuted: %v", err)
|
|
}
|
|
|
|
if deliveries.Load() != 3 {
|
|
t.Errorf("expected 3 deliveries, got %d", deliveries.Load())
|
|
}
|
|
|
|
// Verify the payload was enriched.
|
|
if payload.WebhookEvent.EventType != EventTradeExecuted {
|
|
t.Errorf("event_type not set: %q", payload.WebhookEvent.EventType)
|
|
}
|
|
for _, r := range payload.WebhookEvent.Recipients {
|
|
if r.DeliveryStatus != "delivered" {
|
|
t.Errorf("recipient %s status: %q", r.RecipientID, r.DeliveryStatus)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDispatcherRetryOnFailure(t *testing.T) {
|
|
var attempts atomic.Int32
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
n := attempts.Add(1)
|
|
if n < 3 {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
d := NewDispatcher()
|
|
payload := &TradeWebhookPayload{
|
|
WebhookEvent: WebhookEvent{EventID: "evt_retry"},
|
|
Transaction: Transaction{TransactionID: "txn_retry"},
|
|
Buyer: Party{InvestorID: "inv_retry_buyer"},
|
|
Seller: Party{InvestorID: "inv_retry_seller"},
|
|
TransferAgent: TransferAgent{FirmName: "Retry TA"},
|
|
}
|
|
|
|
targets := []WebhookTarget{
|
|
{RecipientID: "rec_retry", Name: "Retry BD", Role: "buyer_broker_dealer", URL: srv.URL, HMACSecret: "s"},
|
|
}
|
|
|
|
err := d.FireTradeExecuted(context.Background(), payload, targets)
|
|
if err != nil {
|
|
t.Fatalf("expected success after retries, got: %v", err)
|
|
}
|
|
|
|
if attempts.Load() < 3 {
|
|
t.Errorf("expected at least 3 attempts, got %d", attempts.Load())
|
|
}
|
|
}
|
|
|
|
func TestVerifySignature(t *testing.T) {
|
|
payload := []byte(`{"event":"trade.executed","data":{}}`)
|
|
secret := "my-secret-key"
|
|
|
|
sig := signPayload(payload, secret)
|
|
|
|
if !VerifySignature(payload, sig, secret) {
|
|
t.Error("valid signature rejected")
|
|
}
|
|
if VerifySignature(payload, "bad-sig", secret) {
|
|
t.Error("invalid signature accepted")
|
|
}
|
|
if VerifySignature(payload, sig, "wrong-secret") {
|
|
t.Error("wrong secret accepted")
|
|
}
|
|
}
|