mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
350 lines
11 KiB
Go
350 lines
11 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/big"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/observe/log"
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/query"
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/storage/bun/paginate"
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/types/pointer"
|
|
"github.com/hanzo-fi/payments/pkg/domain/models"
|
|
"github.com/hanzo-fi/payments/pkg/events"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestConversionsUpsert(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := logging.TestingContext()
|
|
store := newStore(t)
|
|
|
|
upsertConnector(t, ctx, store, defaultConnector)
|
|
|
|
t.Run("outbox event created atomically with conversion", func(t *testing.T) {
|
|
defer cleanupOutboxHelper(ctx, store)()
|
|
|
|
convID := models.ConversionID{
|
|
Reference: "conv-test-1",
|
|
ConnectorID: defaultConnector.ID,
|
|
}
|
|
|
|
conv := models.Conversion{
|
|
ID: convID,
|
|
ConnectorID: defaultConnector.ID,
|
|
Reference: "conv-test-1",
|
|
CreatedAt: now.Add(-60 * time.Minute).UTC().Time,
|
|
UpdatedAt: now.Add(-5 * time.Minute).UTC().Time,
|
|
SourceAsset: "USD/2",
|
|
DestinationAsset: "USDC/6",
|
|
SourceAmount: big.NewInt(10000),
|
|
DestinationAmount: big.NewInt(10000000),
|
|
Status: models.CONVERSION_STATUS_COMPLETED,
|
|
Fee: big.NewInt(50),
|
|
FeeAsset: pointer.For("USD/2"),
|
|
Metadata: map[string]string{},
|
|
Raw: []byte(`{"test": "data"}`),
|
|
}
|
|
|
|
require.NoError(t, store.ConversionsUpsert(ctx, []models.Conversion{conv}))
|
|
|
|
// Verify conversion exists
|
|
storedConv, err := store.ConversionsGet(ctx, convID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, convID, storedConv.ID)
|
|
assert.Equal(t, models.CONVERSION_STATUS_COMPLETED, storedConv.Status)
|
|
|
|
// Verify outbox event exists
|
|
pendingEvents, err := store.OutboxEventsPollPending(ctx, 100)
|
|
require.NoError(t, err)
|
|
|
|
expectedKey := conv.IdempotencyKey()
|
|
var found bool
|
|
for _, event := range pendingEvents {
|
|
if event.ID.EventIdempotencyKey == expectedKey {
|
|
found = true
|
|
assert.Equal(t, events.EventTypeSavedConversion, event.EventType)
|
|
assert.Equal(t, convID.String(), event.EntityID)
|
|
assert.Equal(t, models.OUTBOX_STATUS_PENDING, event.Status)
|
|
assert.Equal(t, defaultConnector.ID, *event.ConnectorID)
|
|
|
|
// Verify payload
|
|
var payload map[string]interface{}
|
|
err = json.Unmarshal(event.Payload, &payload)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, conv.ID.String(), payload["id"])
|
|
assert.Equal(t, conv.Status.String(), payload["status"])
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, found, "outbox event must exist for new conversion")
|
|
})
|
|
|
|
t.Run("duplicate conversion does not create duplicate event", func(t *testing.T) {
|
|
defer cleanupOutboxHelper(ctx, store)()
|
|
|
|
convID := models.ConversionID{
|
|
Reference: "conv-test-2",
|
|
ConnectorID: defaultConnector.ID,
|
|
}
|
|
|
|
conv := models.Conversion{
|
|
ID: convID,
|
|
ConnectorID: defaultConnector.ID,
|
|
Reference: "conv-test-2",
|
|
CreatedAt: now.Add(-60 * time.Minute).UTC().Time,
|
|
UpdatedAt: now.Add(-5 * time.Minute).UTC().Time,
|
|
SourceAsset: "USD/2",
|
|
DestinationAsset: "EUR/2",
|
|
SourceAmount: big.NewInt(5000),
|
|
Status: models.CONVERSION_STATUS_PENDING,
|
|
Metadata: map[string]string{},
|
|
Raw: []byte(`{}`),
|
|
}
|
|
|
|
// Insert first time
|
|
require.NoError(t, store.ConversionsUpsert(ctx, []models.Conversion{conv}))
|
|
|
|
// Insert second time with same data
|
|
conv.UpdatedAt = now.Add(-2 * time.Minute).UTC().Time
|
|
require.NoError(t, store.ConversionsUpsert(ctx, []models.Conversion{conv}))
|
|
|
|
// Verify only 1 outbox event (second was deduplicated via events_sent/outbox conflict)
|
|
pendingEvents, err := store.OutboxEventsPollPending(ctx, 100)
|
|
require.NoError(t, err)
|
|
|
|
convEvents := make([]models.OutboxEvent, 0)
|
|
for _, event := range pendingEvents {
|
|
if event.EventType == events.EventTypeSavedConversion && event.EntityID == convID.String() {
|
|
convEvents = append(convEvents, event)
|
|
}
|
|
}
|
|
require.Len(t, convEvents, 1, "expected 1 outbox event (duplicate was deduplicated)")
|
|
})
|
|
}
|
|
|
|
func defaultConversions() []models.Conversion {
|
|
convID1 := models.ConversionID{Reference: "conv-1", ConnectorID: defaultConnector.ID}
|
|
convID2 := models.ConversionID{Reference: "conv-2", ConnectorID: defaultConnector.ID}
|
|
|
|
return []models.Conversion{
|
|
{
|
|
ID: convID1,
|
|
ConnectorID: defaultConnector.ID,
|
|
Reference: "conv-1",
|
|
CreatedAt: now.Add(-60 * time.Minute).UTC().Time,
|
|
UpdatedAt: now.Add(-50 * time.Minute).UTC().Time,
|
|
SourceAsset: "USD/2",
|
|
DestinationAsset: "USDC/6",
|
|
SourceAmount: big.NewInt(10000),
|
|
DestinationAmount: big.NewInt(10000000),
|
|
Status: models.CONVERSION_STATUS_COMPLETED,
|
|
Fee: big.NewInt(50),
|
|
FeeAsset: pointer.For("USD/2"),
|
|
Metadata: map[string]string{"key1": "value1"},
|
|
Raw: []byte(`{}`),
|
|
},
|
|
{
|
|
ID: convID2,
|
|
ConnectorID: defaultConnector.ID,
|
|
Reference: "conv-2",
|
|
CreatedAt: now.Add(-30 * time.Minute).UTC().Time,
|
|
UpdatedAt: now.Add(-20 * time.Minute).UTC().Time,
|
|
SourceAsset: "EUR/2",
|
|
DestinationAsset: "USD/2",
|
|
SourceAmount: big.NewInt(5000),
|
|
Status: models.CONVERSION_STATUS_PENDING,
|
|
Metadata: map[string]string{"key2": "value2"},
|
|
Raw: []byte(`{}`),
|
|
},
|
|
}
|
|
}
|
|
|
|
func upsertConversions(t *testing.T, ctx context.Context, store Storage, conversions []models.Conversion) {
|
|
t.Helper()
|
|
require.NoError(t, store.ConversionsUpsert(ctx, conversions))
|
|
}
|
|
|
|
func TestConversionsGet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := logging.TestingContext()
|
|
store := newStore(t)
|
|
|
|
upsertConnector(t, ctx, store, defaultConnector)
|
|
conversions := defaultConversions()
|
|
upsertConversions(t, ctx, store, conversions)
|
|
|
|
t.Run("get unknown conversion", func(t *testing.T) {
|
|
unknownID := models.ConversionID{Reference: "unknown", ConnectorID: defaultConnector.ID}
|
|
_, err := store.ConversionsGet(ctx, unknownID)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("get existing conversion", func(t *testing.T) {
|
|
for _, expected := range conversions {
|
|
stored, err := store.ConversionsGet(ctx, expected.ID)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, expected.ID, stored.ID)
|
|
assert.Equal(t, expected.ConnectorID, stored.ConnectorID)
|
|
assert.Equal(t, expected.Reference, stored.Reference)
|
|
assert.Equal(t, expected.SourceAsset, stored.SourceAsset)
|
|
assert.Equal(t, expected.DestinationAsset, stored.DestinationAsset)
|
|
assert.Equal(t, expected.Status, stored.Status)
|
|
assert.Equal(t, 0, expected.SourceAmount.Cmp(stored.SourceAmount))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestConversionsList(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := logging.TestingContext()
|
|
store := newStore(t)
|
|
|
|
upsertConnector(t, ctx, store, defaultConnector)
|
|
conversions := defaultConversions()
|
|
upsertConversions(t, ctx, store, conversions)
|
|
|
|
t.Run("list all conversions", func(t *testing.T) {
|
|
q := NewListConversionsQuery(paginate.NewPaginatedQueryOptions(ConversionQuery{}).WithPageSize(10))
|
|
cursor, err := store.ConversionsList(ctx, q)
|
|
require.NoError(t, err)
|
|
assert.Len(t, cursor.Data, 2)
|
|
})
|
|
|
|
t.Run("filter by reference", func(t *testing.T) {
|
|
q := NewListConversionsQuery(
|
|
paginate.NewPaginatedQueryOptions(ConversionQuery{}).
|
|
WithPageSize(10).
|
|
WithQueryBuilder(query.Match("reference", "conv-1")),
|
|
)
|
|
cursor, err := store.ConversionsList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, cursor.Data, 1)
|
|
assert.Equal(t, "conv-1", cursor.Data[0].Reference)
|
|
})
|
|
|
|
t.Run("filter by reference with invalid operator", func(t *testing.T) {
|
|
q := NewListConversionsQuery(
|
|
paginate.NewPaginatedQueryOptions(ConversionQuery{}).
|
|
WithPageSize(10).
|
|
WithQueryBuilder(query.Lt("reference", "conv-1")),
|
|
)
|
|
_, err := store.ConversionsList(ctx, q)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("filter by status", func(t *testing.T) {
|
|
q := NewListConversionsQuery(
|
|
paginate.NewPaginatedQueryOptions(ConversionQuery{}).
|
|
WithPageSize(10).
|
|
WithQueryBuilder(query.Match("status", "COMPLETED")),
|
|
)
|
|
cursor, err := store.ConversionsList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, cursor.Data, 1)
|
|
assert.Equal(t, "conv-1", cursor.Data[0].Reference)
|
|
})
|
|
|
|
t.Run("filter by source_asset", func(t *testing.T) {
|
|
q := NewListConversionsQuery(
|
|
paginate.NewPaginatedQueryOptions(ConversionQuery{}).
|
|
WithPageSize(10).
|
|
WithQueryBuilder(query.Match("source_asset", "EUR/2")),
|
|
)
|
|
cursor, err := store.ConversionsList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, cursor.Data, 1)
|
|
assert.Equal(t, "conv-2", cursor.Data[0].Reference)
|
|
})
|
|
|
|
t.Run("filter by metadata", func(t *testing.T) {
|
|
q := NewListConversionsQuery(
|
|
paginate.NewPaginatedQueryOptions(ConversionQuery{}).
|
|
WithPageSize(10).
|
|
WithQueryBuilder(query.Match("metadata[key1]", "value1")),
|
|
)
|
|
cursor, err := store.ConversionsList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, cursor.Data, 1)
|
|
assert.Equal(t, "conv-1", cursor.Data[0].Reference)
|
|
})
|
|
|
|
t.Run("filter by metadata with invalid operator", func(t *testing.T) {
|
|
q := NewListConversionsQuery(
|
|
paginate.NewPaginatedQueryOptions(ConversionQuery{}).
|
|
WithPageSize(10).
|
|
WithQueryBuilder(query.Lt("metadata[key1]", "value1")),
|
|
)
|
|
_, err := store.ConversionsList(ctx, q)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("filter by unknown key", func(t *testing.T) {
|
|
q := NewListConversionsQuery(
|
|
paginate.NewPaginatedQueryOptions(ConversionQuery{}).
|
|
WithPageSize(10).
|
|
WithQueryBuilder(query.Match("unknown_field", "value")),
|
|
)
|
|
_, err := store.ConversionsList(ctx, q)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("pagination", func(t *testing.T) {
|
|
q := NewListConversionsQuery(paginate.NewPaginatedQueryOptions(ConversionQuery{}).WithPageSize(1))
|
|
cursor, err := store.ConversionsList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, cursor.Data, 1)
|
|
assert.True(t, cursor.HasMore)
|
|
|
|
// Next page
|
|
var next ListConversionsQuery
|
|
err = paginate.UnmarshalCursor(cursor.Next, &next)
|
|
require.NoError(t, err)
|
|
cursor, err = store.ConversionsList(ctx, next)
|
|
require.NoError(t, err)
|
|
require.Len(t, cursor.Data, 1)
|
|
assert.False(t, cursor.HasMore)
|
|
})
|
|
}
|
|
|
|
func TestConversionsDeleteFromConnectorID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := logging.TestingContext()
|
|
store := newStore(t)
|
|
|
|
upsertConnector(t, ctx, store, defaultConnector)
|
|
upsertConnector(t, ctx, store, defaultConnector2)
|
|
conversions := defaultConversions()
|
|
upsertConversions(t, ctx, store, conversions)
|
|
|
|
t.Run("delete from unknown connector", func(t *testing.T) {
|
|
unknownConnID := models.ConnectorID{Reference: defaultConnector.ID.Reference, Provider: "unknown"}
|
|
require.NoError(t, store.ConversionsDeleteFromConnectorID(ctx, unknownConnID))
|
|
|
|
// Conversions should still exist
|
|
for _, c := range conversions {
|
|
_, err := store.ConversionsGet(ctx, c.ID)
|
|
require.NoError(t, err)
|
|
}
|
|
})
|
|
|
|
t.Run("delete from existing connector", func(t *testing.T) {
|
|
require.NoError(t, store.ConversionsDeleteFromConnectorID(ctx, defaultConnector.ID))
|
|
|
|
// Conversions should be gone
|
|
for _, c := range conversions {
|
|
_, err := store.ConversionsGet(ctx, c.ID)
|
|
require.Error(t, err, fmt.Sprintf("conversion %s should have been deleted", c.ID.String()))
|
|
}
|
|
})
|
|
}
|