mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
515 lines
16 KiB
Go
515 lines
16 KiB
Go
package models_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"math/big"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/types/pointer"
|
|
"github.com/hanzo-fi/payments/pkg/domain/models"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func newConnectorID(t *testing.T) models.ConnectorID {
|
|
t.Helper()
|
|
return models.ConnectorID{
|
|
Reference: uuid.New(),
|
|
Provider: "coinbaseprime",
|
|
}
|
|
}
|
|
|
|
func validPSPOrder() models.PSPOrder {
|
|
return models.PSPOrder{
|
|
Reference: "order-ref-1",
|
|
ClientOrderID: "client-order-1",
|
|
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
Direction: models.ORDER_DIRECTION_BUY,
|
|
SourceAsset: "USD/2",
|
|
DestinationAsset: "BTC/8",
|
|
Type: models.ORDER_TYPE_LIMIT,
|
|
Status: models.ORDER_STATUS_FILLED,
|
|
BaseQuantityOrdered: big.NewInt(1000),
|
|
BaseQuantityFilled: big.NewInt(1000),
|
|
LimitPrice: big.NewInt(5000000),
|
|
TimeInForce: models.TIME_IN_FORCE_GOOD_UNTIL_CANCELLED,
|
|
QuoteAmount: big.NewInt(5000000000),
|
|
QuoteAsset: "USD/2",
|
|
Fee: big.NewInt(100),
|
|
FeeAsset: pointer.For("USD/2"),
|
|
AverageFillPrice: big.NewInt(5000000),
|
|
PriceAsset: pointer.For("USD/2"),
|
|
SourceAccountReference: pointer.For("src-wallet"),
|
|
DestinationAccountReference: pointer.For("dst-wallet"),
|
|
Metadata: map[string]string{
|
|
"k": "v",
|
|
},
|
|
Raw: json.RawMessage(`{"raw":"ok"}`),
|
|
}
|
|
}
|
|
|
|
func TestPSPOrderValidate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("valid", func(t *testing.T) {
|
|
t.Parallel()
|
|
o := validPSPOrder()
|
|
assert.NoError(t, o.Validate())
|
|
})
|
|
|
|
cases := []struct {
|
|
name string
|
|
mutate func(*models.PSPOrder)
|
|
errMsg string
|
|
}{
|
|
{"missing reference", func(o *models.PSPOrder) { o.Reference = "" }, "missing order reference"},
|
|
{"missing createdAt", func(o *models.PSPOrder) { o.CreatedAt = time.Time{} }, "missing order createdAt"},
|
|
{"missing direction", func(o *models.PSPOrder) { o.Direction = models.ORDER_DIRECTION_UNKNOWN }, "missing order direction"},
|
|
{"invalid source asset", func(o *models.PSPOrder) { o.SourceAsset = "nope" }, "invalid order source asset"},
|
|
{"invalid dest asset", func(o *models.PSPOrder) { o.DestinationAsset = "nope" }, "invalid order target asset"},
|
|
{"missing type", func(o *models.PSPOrder) { o.Type = models.ORDER_TYPE_UNKNOWN }, "missing order type"},
|
|
{"missing status", func(o *models.PSPOrder) { o.Status = models.ORDER_STATUS_UNKNOWN }, "missing order status"},
|
|
{"missing base quantity ordered", func(o *models.PSPOrder) { o.BaseQuantityOrdered = nil }, "missing order base quantity ordered"},
|
|
{"missing raw", func(o *models.PSPOrder) { o.Raw = nil }, "missing order raw"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
o := validPSPOrder()
|
|
tc.mutate(&o)
|
|
err := o.Validate()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tc.errMsg)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFromPSPOrderToOrder(t *testing.T) {
|
|
t.Parallel()
|
|
connectorID := newConnectorID(t)
|
|
observedAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
t.Run("valid", func(t *testing.T) {
|
|
t.Parallel()
|
|
psp := validPSPOrder()
|
|
|
|
order, err := models.FromPSPOrderToOrder(psp, connectorID, observedAt)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, psp.Reference, order.Reference)
|
|
assert.Equal(t, psp.ClientOrderID, order.ClientOrderID)
|
|
assert.Equal(t, psp.CreatedAt, order.CreatedAt)
|
|
assert.Equal(t, observedAt, order.UpdatedAt)
|
|
assert.Equal(t, connectorID, order.ConnectorID)
|
|
assert.Equal(t, psp.SourceAsset, order.SourceAsset)
|
|
assert.Equal(t, psp.DestinationAsset, order.DestinationAsset)
|
|
assert.Equal(t, psp.Status, order.Status)
|
|
assert.Equal(t, psp.BaseQuantityOrdered, order.BaseQuantityOrdered)
|
|
require.NotNil(t, order.SourceAccountID)
|
|
require.NotNil(t, order.DestinationAccountID)
|
|
assert.Equal(t, "src-wallet", order.SourceAccountID.Reference)
|
|
assert.Equal(t, "dst-wallet", order.DestinationAccountID.Reference)
|
|
|
|
require.Len(t, order.Adjustments, 1)
|
|
adj := order.Adjustments[0]
|
|
assert.Equal(t, psp.Reference, adj.Reference)
|
|
assert.Equal(t, psp.Status, adj.Status)
|
|
assert.Equal(t, observedAt, adj.CreatedAt)
|
|
assert.Equal(t, json.RawMessage(`{"raw":"ok"}`), adj.Raw)
|
|
})
|
|
|
|
t.Run("nil account references produce nil account ids", func(t *testing.T) {
|
|
t.Parallel()
|
|
psp := validPSPOrder()
|
|
psp.SourceAccountReference = nil
|
|
psp.DestinationAccountReference = nil
|
|
|
|
order, err := models.FromPSPOrderToOrder(psp, connectorID, observedAt)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, order.SourceAccountID)
|
|
assert.Nil(t, order.DestinationAccountID)
|
|
})
|
|
|
|
t.Run("invalid order returns error", func(t *testing.T) {
|
|
t.Parallel()
|
|
psp := validPSPOrder()
|
|
psp.Reference = ""
|
|
|
|
_, err := models.FromPSPOrderToOrder(psp, connectorID, observedAt)
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestFromPSPOrders(t *testing.T) {
|
|
t.Parallel()
|
|
connectorID := newConnectorID(t)
|
|
observedAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
t.Run("all valid", func(t *testing.T) {
|
|
t.Parallel()
|
|
p1 := validPSPOrder()
|
|
p1.Reference = "a"
|
|
p2 := validPSPOrder()
|
|
p2.Reference = "b"
|
|
|
|
orders, err := models.FromPSPOrders([]models.PSPOrder{p1, p2}, connectorID, observedAt)
|
|
require.NoError(t, err)
|
|
require.Len(t, orders, 2)
|
|
assert.Equal(t, "a", orders[0].Reference)
|
|
assert.Equal(t, "b", orders[1].Reference)
|
|
})
|
|
|
|
t.Run("empty slice", func(t *testing.T) {
|
|
t.Parallel()
|
|
orders, err := models.FromPSPOrders(nil, connectorID, observedAt)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, orders)
|
|
})
|
|
|
|
t.Run("deduplicates same ID, newer CreatedAt wins regardless of list position", func(t *testing.T) {
|
|
t.Parallel()
|
|
older := validPSPOrder()
|
|
older.ClientOrderID = "older"
|
|
older.CreatedAt = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
newer := validPSPOrder()
|
|
newer.ClientOrderID = "newer"
|
|
newer.CreatedAt = time.Date(2024, 1, 1, 0, 0, 0, 1000, time.UTC) // 1 µs later
|
|
|
|
// newer comes first in the list — CreatedAt must win over position.
|
|
orders, err := models.FromPSPOrders([]models.PSPOrder{newer, older}, connectorID, observedAt)
|
|
require.NoError(t, err)
|
|
require.Len(t, orders, 1)
|
|
assert.Equal(t, "newer", orders[0].ClientOrderID)
|
|
assert.Len(t, orders[0].Adjustments, 2)
|
|
|
|
// older comes first in the list — CreatedAt must win over position.
|
|
orders, err = models.FromPSPOrders([]models.PSPOrder{older, newer}, connectorID, observedAt)
|
|
require.NoError(t, err)
|
|
require.Len(t, orders, 1)
|
|
assert.Equal(t, "newer", orders[0].ClientOrderID)
|
|
assert.Len(t, orders[0].Adjustments, 2)
|
|
})
|
|
|
|
t.Run("deduplicates same ID equal CreatedAt, later in list wins", func(t *testing.T) {
|
|
t.Parallel()
|
|
first := validPSPOrder()
|
|
first.ClientOrderID = "first"
|
|
second := validPSPOrder()
|
|
second.ClientOrderID = "second"
|
|
|
|
orders, err := models.FromPSPOrders([]models.PSPOrder{first, second}, connectorID, observedAt)
|
|
require.NoError(t, err)
|
|
require.Len(t, orders, 1)
|
|
assert.Equal(t, "second", orders[0].ClientOrderID)
|
|
})
|
|
|
|
t.Run("one invalid aborts", func(t *testing.T) {
|
|
t.Parallel()
|
|
good := validPSPOrder()
|
|
bad := validPSPOrder()
|
|
bad.Reference = ""
|
|
|
|
_, err := models.FromPSPOrders([]models.PSPOrder{good, bad}, connectorID, observedAt)
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestToPSPOrder(t *testing.T) {
|
|
t.Parallel()
|
|
connectorID := newConnectorID(t)
|
|
observedAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
t.Run("round trip preserves fields", func(t *testing.T) {
|
|
t.Parallel()
|
|
original := validPSPOrder()
|
|
order, err := models.FromPSPOrderToOrder(original, connectorID, observedAt)
|
|
require.NoError(t, err)
|
|
|
|
psp := models.ToPSPOrder(&order)
|
|
assert.Equal(t, original.Reference, psp.Reference)
|
|
assert.Equal(t, original.SourceAsset, psp.SourceAsset)
|
|
assert.Equal(t, original.DestinationAsset, psp.DestinationAsset)
|
|
assert.Equal(t, original.Status, psp.Status)
|
|
assert.Equal(t, original.BaseQuantityOrdered, psp.BaseQuantityOrdered)
|
|
require.NotNil(t, psp.SourceAccountReference)
|
|
assert.Equal(t, "src-wallet", *psp.SourceAccountReference)
|
|
require.NotNil(t, psp.DestinationAccountReference)
|
|
assert.Equal(t, "dst-wallet", *psp.DestinationAccountReference)
|
|
|
|
assert.NoError(t, psp.Validate())
|
|
})
|
|
|
|
t.Run("picks latest adjustment Raw", func(t *testing.T) {
|
|
t.Parallel()
|
|
oldAdj := models.OrderAdjustment{
|
|
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
Raw: json.RawMessage(`{"v":"old"}`),
|
|
}
|
|
newAdj := models.OrderAdjustment{
|
|
CreatedAt: time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC),
|
|
Raw: json.RawMessage(`{"v":"new"}`),
|
|
}
|
|
order := models.Order{
|
|
Reference: "x",
|
|
ConnectorID: connectorID,
|
|
Adjustments: []models.OrderAdjustment{oldAdj, newAdj},
|
|
}
|
|
psp := models.ToPSPOrder(&order)
|
|
assert.Equal(t, json.RawMessage(`{"v":"new"}`), psp.Raw)
|
|
})
|
|
|
|
t.Run("empty adjustments -> nil raw", func(t *testing.T) {
|
|
t.Parallel()
|
|
order := models.Order{
|
|
Reference: "x",
|
|
ConnectorID: connectorID,
|
|
}
|
|
psp := models.ToPSPOrder(&order)
|
|
assert.Nil(t, psp.Raw)
|
|
})
|
|
}
|
|
|
|
func TestOrderMarshalUnmarshal(t *testing.T) {
|
|
t.Parallel()
|
|
connectorID := newConnectorID(t)
|
|
observedAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
order, err := models.FromPSPOrderToOrder(validPSPOrder(), connectorID, observedAt)
|
|
require.NoError(t, err)
|
|
|
|
data, err := json.Marshal(order)
|
|
require.NoError(t, err)
|
|
|
|
// Sanity: provider field rendered
|
|
assert.Contains(t, string(data), `"provider":`)
|
|
|
|
var decoded models.Order
|
|
require.NoError(t, json.Unmarshal(data, &decoded))
|
|
assert.Equal(t, order.Reference, decoded.Reference)
|
|
assert.Equal(t, order.ConnectorID, decoded.ConnectorID)
|
|
assert.Equal(t, order.Direction, decoded.Direction)
|
|
assert.Equal(t, order.Status, decoded.Status)
|
|
assert.Equal(t, order.BaseQuantityOrdered, decoded.BaseQuantityOrdered)
|
|
require.NotNil(t, decoded.SourceAccountID)
|
|
assert.Equal(t, order.SourceAccountID.Reference, decoded.SourceAccountID.Reference)
|
|
require.Len(t, decoded.Adjustments, len(order.Adjustments))
|
|
}
|
|
|
|
func TestOrderUnmarshalInvalid(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("invalid JSON", func(t *testing.T) {
|
|
t.Parallel()
|
|
var o models.Order
|
|
err := json.Unmarshal([]byte("not json"), &o)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("invalid id", func(t *testing.T) {
|
|
t.Parallel()
|
|
var o models.Order
|
|
err := json.Unmarshal([]byte(`{"id":"!!!","connectorID":""}`), &o)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("invalid connector id", func(t *testing.T) {
|
|
t.Parallel()
|
|
connectorID := newConnectorID(t)
|
|
observedAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)
|
|
order, err := models.FromPSPOrderToOrder(validPSPOrder(), connectorID, observedAt)
|
|
require.NoError(t, err)
|
|
data, err := json.Marshal(order)
|
|
require.NoError(t, err)
|
|
|
|
// Poison the connectorID field.
|
|
bad := []byte(
|
|
string(data)[:0] + "{" +
|
|
`"id":"` + order.ID.String() + `",` +
|
|
`"connectorID":"not-a-connector-id"}`,
|
|
)
|
|
var decoded models.Order
|
|
err = json.Unmarshal(bad, &decoded)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("invalid source account id", func(t *testing.T) {
|
|
t.Parallel()
|
|
connectorID := newConnectorID(t)
|
|
observedAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)
|
|
order, err := models.FromPSPOrderToOrder(validPSPOrder(), connectorID, observedAt)
|
|
require.NoError(t, err)
|
|
|
|
payload := map[string]any{
|
|
"id": order.ID.String(),
|
|
"connectorID": connectorID.String(),
|
|
"sourceAccountID": "invalid-account-id",
|
|
}
|
|
raw, err := json.Marshal(payload)
|
|
require.NoError(t, err)
|
|
|
|
var decoded models.Order
|
|
err = json.Unmarshal(raw, &decoded)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("invalid destination account id", func(t *testing.T) {
|
|
t.Parallel()
|
|
connectorID := newConnectorID(t)
|
|
observedAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)
|
|
order, err := models.FromPSPOrderToOrder(validPSPOrder(), connectorID, observedAt)
|
|
require.NoError(t, err)
|
|
|
|
payload := map[string]any{
|
|
"id": order.ID.String(),
|
|
"connectorID": connectorID.String(),
|
|
"destinationAccountID": "invalid-account-id",
|
|
}
|
|
raw, err := json.Marshal(payload)
|
|
require.NoError(t, err)
|
|
|
|
var decoded models.Order
|
|
err = json.Unmarshal(raw, &decoded)
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestOrderAdjustmentID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
orderID := models.OrderID{
|
|
Reference: "order-ref-1",
|
|
ConnectorID: models.ConnectorID{Provider: "psp", Reference: uuid.New()},
|
|
}
|
|
original := models.OrderAdjustmentID{
|
|
OrderID: orderID,
|
|
Reference: "order-ref-1",
|
|
Status: models.ORDER_STATUS_FILLED,
|
|
BaseQuantityFilled: big.NewInt(1000),
|
|
Fee: big.NewInt(10),
|
|
FeeAsset: pointer.For("USD/2"),
|
|
}
|
|
|
|
t.Run("String round trip", func(t *testing.T) {
|
|
t.Parallel()
|
|
s := original.String()
|
|
assert.NotEmpty(t, s)
|
|
|
|
decoded, err := models.OrderAdjustmentIDFromString(s)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, original.Reference, decoded.Reference)
|
|
assert.Equal(t, original.Status, decoded.Status)
|
|
})
|
|
|
|
t.Run("FromString invalid", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := models.OrderAdjustmentIDFromString("invalid-base64!!")
|
|
assert.Error(t, err)
|
|
})
|
|
|
|
t.Run("Value", func(t *testing.T) {
|
|
t.Parallel()
|
|
val, err := original.Value()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, original.String(), val)
|
|
})
|
|
|
|
t.Run("Scan", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("valid", func(t *testing.T) {
|
|
t.Parallel()
|
|
var id models.OrderAdjustmentID
|
|
require.NoError(t, id.Scan(original.String()))
|
|
assert.Equal(t, original.Reference, id.Reference)
|
|
})
|
|
|
|
t.Run("nil", func(t *testing.T) {
|
|
t.Parallel()
|
|
var id models.OrderAdjustmentID
|
|
err := id.Scan(nil)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "order adjustment id is nil")
|
|
})
|
|
|
|
t.Run("invalid type", func(t *testing.T) {
|
|
t.Parallel()
|
|
var id models.OrderAdjustmentID
|
|
err := id.Scan(123)
|
|
assert.Error(t, err)
|
|
})
|
|
|
|
t.Run("illegal base64", func(t *testing.T) {
|
|
t.Parallel()
|
|
var id models.OrderAdjustmentID
|
|
err := id.Scan("not-base64!!")
|
|
assert.Error(t, err)
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestOrderAdjustmentMarshalUnmarshal(t *testing.T) {
|
|
t.Parallel()
|
|
connectorID := newConnectorID(t)
|
|
observedAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
adj := models.FromPSPOrderToOrderAdjustment(validPSPOrder(), connectorID, observedAt)
|
|
|
|
data, err := json.Marshal(adj)
|
|
require.NoError(t, err)
|
|
|
|
var decoded models.OrderAdjustment
|
|
require.NoError(t, json.Unmarshal(data, &decoded))
|
|
assert.Equal(t, adj.Reference, decoded.Reference)
|
|
assert.Equal(t, adj.Status, decoded.Status)
|
|
assert.Equal(t, adj.BaseQuantityFilled, decoded.BaseQuantityFilled)
|
|
assert.Equal(t, adj.Fee, decoded.Fee)
|
|
assert.NotEmpty(t, decoded.IdempotencyKey())
|
|
|
|
t.Run("invalid adjustment ID fails unmarshal", func(t *testing.T) {
|
|
t.Parallel()
|
|
var o models.OrderAdjustment
|
|
err := json.Unmarshal([]byte(`{"id":"!!!bad-id"}`), &o)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("malformed JSON fails", func(t *testing.T) {
|
|
t.Parallel()
|
|
var o models.OrderAdjustment
|
|
err := json.Unmarshal([]byte("not json"), &o)
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestOrderExpandedMarshal(t *testing.T) {
|
|
t.Parallel()
|
|
connectorID := newConnectorID(t)
|
|
observedAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)
|
|
order, err := models.FromPSPOrderToOrder(validPSPOrder(), connectorID, observedAt)
|
|
require.NoError(t, err)
|
|
|
|
t.Run("without error", func(t *testing.T) {
|
|
t.Parallel()
|
|
oe := models.OrderExpanded{Order: order, Status: models.ORDER_STATUS_FILLED}
|
|
data, err := json.Marshal(oe)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(data), `"status":"FILLED"`)
|
|
assert.NotContains(t, string(data), `"error"`)
|
|
})
|
|
|
|
t.Run("with error", func(t *testing.T) {
|
|
t.Parallel()
|
|
oe := models.OrderExpanded{
|
|
Order: order,
|
|
Status: models.ORDER_STATUS_FAILED,
|
|
Error: errors.New("boom"),
|
|
}
|
|
data, err := json.Marshal(oe)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(data), `"error":"boom"`)
|
|
})
|
|
}
|