mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
1037 lines
34 KiB
Go
1037 lines
34 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"math/big"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/observe/log"
|
|
"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/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func defaultBalances() []models.Balance {
|
|
defaultAccounts := defaultAccounts()
|
|
return []models.Balance{
|
|
{
|
|
AccountID: defaultAccounts[0].ID,
|
|
CreatedAt: now.Add(-60 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-60 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
},
|
|
{
|
|
AccountID: defaultAccounts[1].ID,
|
|
CreatedAt: now.Add(-30 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-30 * time.Minute).UTC().Time,
|
|
Asset: "EUR/2",
|
|
Balance: big.NewInt(1000),
|
|
},
|
|
{
|
|
AccountID: defaultAccounts[0].ID,
|
|
CreatedAt: now.Add(-55 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-55 * time.Minute).UTC().Time,
|
|
Asset: "EUR/2",
|
|
Balance: big.NewInt(150),
|
|
PsuID: &defaultPSU2.ID,
|
|
OpenBankingConnectionID: &defaultOpenBankingConnection.ConnectionID,
|
|
},
|
|
}
|
|
}
|
|
|
|
func defaultBalances2() []models.Balance {
|
|
defaultAccounts := defaultAccounts()
|
|
return []models.Balance{
|
|
{
|
|
AccountID: defaultAccounts[2].ID,
|
|
CreatedAt: now.Add(-59 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-59 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
},
|
|
{
|
|
AccountID: defaultAccounts[2].ID,
|
|
CreatedAt: now.Add(-31 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-31 * time.Minute).UTC().Time,
|
|
Asset: "DKK/2",
|
|
Balance: big.NewInt(1000),
|
|
},
|
|
}
|
|
}
|
|
|
|
func defaultBalancesDuplicates() []models.Balance {
|
|
defaultAccounts := defaultAccounts()
|
|
return []models.Balance{
|
|
{
|
|
AccountID: defaultAccounts[0].ID,
|
|
CreatedAt: now.Add(-60 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-60 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
},
|
|
{
|
|
AccountID: defaultAccounts[1].ID,
|
|
CreatedAt: now.Add(-30 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-30 * time.Minute).UTC().Time,
|
|
Asset: "EUR/2",
|
|
Balance: big.NewInt(1000),
|
|
},
|
|
{
|
|
AccountID: defaultAccounts[0].ID,
|
|
CreatedAt: now.Add(-55 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-55 * time.Minute).UTC().Time,
|
|
Asset: "EUR/2",
|
|
Balance: big.NewInt(150),
|
|
PsuID: &defaultPSU2.ID,
|
|
OpenBankingConnectionID: &defaultOpenBankingConnection.ConnectionID,
|
|
},
|
|
{
|
|
AccountID: defaultAccounts[0].ID,
|
|
CreatedAt: now.Add(-50 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-50 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(200),
|
|
},
|
|
}
|
|
}
|
|
|
|
func upsertBalances(t *testing.T, ctx context.Context, storage Storage, balances []models.Balance) {
|
|
require.NoError(t, storage.BalancesUpsert(ctx, balances))
|
|
}
|
|
|
|
func TestBalancesUpsert(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := logging.TestingContext()
|
|
store := newStore(t)
|
|
|
|
t.Cleanup(func() {
|
|
cleanupOutboxHelper(ctx, store)()
|
|
store.Close()
|
|
})
|
|
|
|
upsertConnector(t, ctx, store, defaultConnector)
|
|
createPSU(t, ctx, store, defaultPSU2)
|
|
createOpenBankingConnection(t, ctx, store, defaultPSU2.ID, defaultOpenBankingConnection)
|
|
upsertConnector(t, ctx, store, defaultConnector)
|
|
upsertAccounts(t, ctx, store, defaultAccounts())
|
|
upsertBalances(t, ctx, store, defaultBalances())
|
|
upsertBalances(t, ctx, store, defaultBalances2())
|
|
cleanupOutboxHelper(ctx, store)() // Clean up outbox events from default data creation
|
|
|
|
t.Run("upsert empty balances", func(t *testing.T) {
|
|
upsertBalances(t, ctx, store, []models.Balance{})
|
|
})
|
|
|
|
t.Run("upsert balances with unknown connector id", func(t *testing.T) {
|
|
b := models.Balance{
|
|
AccountID: models.AccountID{
|
|
Reference: "test",
|
|
ConnectorID: models.ConnectorID{
|
|
Reference: uuid.New(),
|
|
Provider: "unknown",
|
|
},
|
|
},
|
|
CreatedAt: now.Add(-70 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-70 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
}
|
|
|
|
require.Error(t, store.BalancesUpsert(ctx, []models.Balance{b}))
|
|
})
|
|
|
|
t.Run("upsert balance in the past should not insert anything", func(t *testing.T) {
|
|
accounts := defaultAccounts()
|
|
b := models.Balance{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-70 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-70 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
}
|
|
|
|
upsertBalances(t, ctx, store, []models.Balance{b})
|
|
|
|
q := NewListBalancesQuery(
|
|
paginate.NewPaginatedQueryOptions(BalanceQuery{
|
|
AccountID: pointer.For(accounts[0].ID),
|
|
Asset: "USD/2",
|
|
}).WithPageSize(15),
|
|
)
|
|
|
|
// We should have the same balances as before
|
|
expectedBalances := []models.Balance{
|
|
{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-60 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-60 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
},
|
|
}
|
|
|
|
balances, err := store.BalancesList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, balances.Data, 1)
|
|
require.Equal(t, expectedBalances, balances.Data)
|
|
})
|
|
|
|
t.Run("insert balances with same asset and same balance", func(t *testing.T) {
|
|
accounts := defaultAccounts()
|
|
b := models.Balance{
|
|
AccountID: accounts[2].ID,
|
|
CreatedAt: now.Add(-20 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-20 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
}
|
|
|
|
upsertBalances(t, ctx, store, []models.Balance{b})
|
|
|
|
q := NewListBalancesQuery(
|
|
paginate.NewPaginatedQueryOptions(BalanceQuery{
|
|
AccountID: pointer.For(accounts[2].ID),
|
|
Asset: "USD/2",
|
|
}).WithPageSize(15),
|
|
)
|
|
|
|
expectedBalances := []models.Balance{
|
|
{
|
|
AccountID: accounts[2].ID,
|
|
CreatedAt: now.Add(-59 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-20 * time.Minute).UTC().Time, // Last updated at should be updated to the new balance value
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
},
|
|
}
|
|
|
|
balances, err := store.BalancesList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, balances.Data, 1)
|
|
require.Equal(t, expectedBalances, balances.Data)
|
|
})
|
|
|
|
t.Run("insert balances same asset different balance", func(t *testing.T) {
|
|
accounts := defaultAccounts()
|
|
b := models.Balance{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-20 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-20 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(200),
|
|
}
|
|
|
|
upsertBalances(t, ctx, store, []models.Balance{b})
|
|
|
|
q := NewListBalancesQuery(
|
|
paginate.NewPaginatedQueryOptions(BalanceQuery{
|
|
AccountID: pointer.For(accounts[0].ID),
|
|
Asset: "USD/2",
|
|
}).WithPageSize(15),
|
|
)
|
|
|
|
expectedBalances := []models.Balance{
|
|
// We should have one more balance with the new balance value
|
|
{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-20 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-20 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(200),
|
|
},
|
|
// and the old balance should have its updated at to the new balance created at
|
|
{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-60 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-20 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
},
|
|
}
|
|
|
|
balances, err := store.BalancesList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, balances.Data, 2)
|
|
require.Equal(t, expectedBalances, balances.Data)
|
|
})
|
|
|
|
t.Run("insert balances with new asset", func(t *testing.T) {
|
|
accounts := defaultAccounts()
|
|
b := models.Balance{
|
|
AccountID: accounts[2].ID,
|
|
CreatedAt: now.Add(-10 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-10 * time.Minute).UTC().Time,
|
|
Asset: "EUR/2",
|
|
Balance: big.NewInt(200),
|
|
}
|
|
|
|
upsertBalances(t, ctx, store, []models.Balance{b})
|
|
|
|
q := NewListBalancesQuery(
|
|
paginate.NewPaginatedQueryOptions(BalanceQuery{
|
|
AccountID: pointer.For(accounts[2].ID),
|
|
}).WithPageSize(15),
|
|
)
|
|
|
|
expectedBalances := []models.Balance{
|
|
{
|
|
AccountID: accounts[2].ID,
|
|
CreatedAt: now.Add(-10 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-10 * time.Minute).UTC().Time,
|
|
Asset: "EUR/2",
|
|
Balance: big.NewInt(200),
|
|
},
|
|
{
|
|
AccountID: accounts[2].ID,
|
|
CreatedAt: now.Add(-31 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-31 * time.Minute).UTC().Time,
|
|
Asset: "DKK/2",
|
|
Balance: big.NewInt(1000),
|
|
},
|
|
{
|
|
AccountID: accounts[2].ID,
|
|
CreatedAt: now.Add(-59 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-20 * time.Minute).UTC().Time, // Because on the first function it was modified
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
},
|
|
}
|
|
|
|
cursor, err := store.BalancesList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, cursor.Data, 3)
|
|
require.Equal(t, expectedBalances, cursor.Data)
|
|
})
|
|
|
|
t.Run("no balances available", func(t *testing.T) {
|
|
accountID := models.AccountID{
|
|
Reference: "12324343abc",
|
|
ConnectorID: defaultConnector.ID,
|
|
}
|
|
|
|
q := NewListBalancesQuery(
|
|
paginate.NewPaginatedQueryOptions(BalanceQuery{
|
|
AccountID: pointer.For(accountID),
|
|
}).WithPageSize(15),
|
|
)
|
|
|
|
expectedBalances := []models.Balance{}
|
|
cursor, err := store.BalancesList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, cursor.Data, 0)
|
|
require.Equal(t, expectedBalances, cursor.Data)
|
|
})
|
|
|
|
t.Run("outbox events created for new balances", func(t *testing.T) {
|
|
accounts := defaultAccounts()
|
|
// Create new balances
|
|
newBalances := []models.Balance{
|
|
{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-5 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-5 * time.Minute).UTC().Time,
|
|
Asset: "GBP/2",
|
|
Balance: big.NewInt(500),
|
|
},
|
|
{
|
|
AccountID: accounts[1].ID,
|
|
CreatedAt: now.Add(-4 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-4 * time.Minute).UTC().Time,
|
|
Asset: "JPY/0",
|
|
Balance: big.NewInt(10000),
|
|
},
|
|
}
|
|
|
|
// Create a set of expected idempotency keys
|
|
expectedKeys := make(map[string]bool)
|
|
for _, balance := range newBalances {
|
|
expectedKeys[balance.IdempotencyKey()] = true
|
|
}
|
|
|
|
// Insert balances
|
|
require.NoError(t, store.BalancesUpsert(ctx, newBalances))
|
|
|
|
// Verify outbox events were created
|
|
pendingEvents, err := store.OutboxEventsPollPending(ctx, 100)
|
|
require.NoError(t, err)
|
|
|
|
// Filter events to only those we just created
|
|
ourEvents := make([]models.OutboxEvent, 0)
|
|
for _, event := range pendingEvents {
|
|
if event.EventType == events.EventTypeSavedBalances && expectedKeys[event.ID.EventIdempotencyKey] {
|
|
ourEvents = append(ourEvents, event)
|
|
}
|
|
}
|
|
require.Len(t, ourEvents, 2, "expected 2 outbox events for 2 new balances")
|
|
|
|
// Create a map of expected balances by idempotency key for easier lookup
|
|
expectedBalancesByKey := make(map[string]models.Balance)
|
|
for _, balance := range newBalances {
|
|
expectedBalancesByKey[balance.IdempotencyKey()] = balance
|
|
}
|
|
|
|
// Check event details
|
|
for _, event := range ourEvents {
|
|
assert.Equal(t, events.EventTypeSavedBalances, event.EventType)
|
|
assert.Equal(t, models.OUTBOX_STATUS_PENDING, event.Status)
|
|
assert.Equal(t, defaultConnector.ID, *event.ConnectorID)
|
|
assert.Equal(t, 0, event.RetryCount)
|
|
assert.Nil(t, event.Error)
|
|
assert.NotEmpty(t, event.ID.EventIdempotencyKey)
|
|
|
|
// Find the matching balance by idempotency key
|
|
expectedBalance, found := expectedBalancesByKey[event.ID.EventIdempotencyKey]
|
|
require.True(t, found, "event idempotency key should match one of the balances")
|
|
|
|
// Verify payload contains balance data
|
|
var payload map[string]interface{}
|
|
err := json.Unmarshal(event.Payload, &payload)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, expectedBalance.AccountID.String(), payload["accountID"])
|
|
assert.Equal(t, expectedBalance.AccountID.ConnectorID.String(), payload["connectorID"])
|
|
assert.Equal(t, expectedBalance.Asset, payload["asset"])
|
|
assert.Equal(t, expectedBalance.Balance.String(), payload["balance"])
|
|
assert.Contains(t, payload, "provider")
|
|
assert.Contains(t, payload, "createdAt")
|
|
assert.Contains(t, payload, "lastUpdatedAt")
|
|
|
|
// Verify EntityID matches account ID
|
|
assert.Equal(t, expectedBalance.AccountID.String(), event.EntityID)
|
|
}
|
|
})
|
|
|
|
t.Run("outbox events created for balance updates with different values", func(t *testing.T) {
|
|
accounts := defaultAccounts()
|
|
// Update a balance with a different value (should create a new entry)
|
|
updatedBalance := models.Balance{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-2 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-2 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(300), // Different from the existing balance
|
|
}
|
|
|
|
expectedKey := updatedBalance.IdempotencyKey()
|
|
|
|
// Insert balance update
|
|
require.NoError(t, store.BalancesUpsert(ctx, []models.Balance{updatedBalance}))
|
|
|
|
// Verify outbox event was created
|
|
pendingEvents, err := store.OutboxEventsPollPending(ctx, 100)
|
|
require.NoError(t, err)
|
|
|
|
// Filter events to only the one we just created
|
|
var ourEvent *models.OutboxEvent
|
|
for _, event := range pendingEvents {
|
|
if event.EventType == events.EventTypeSavedBalances && event.ID.EventIdempotencyKey == expectedKey {
|
|
ourEvent = &event
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, ourEvent, "expected 1 outbox event for balance update")
|
|
|
|
assert.Equal(t, events.EventTypeSavedBalances, ourEvent.EventType)
|
|
assert.Equal(t, models.OUTBOX_STATUS_PENDING, ourEvent.Status)
|
|
|
|
// Verify payload
|
|
var payload map[string]interface{}
|
|
err = json.Unmarshal(ourEvent.Payload, &payload)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, updatedBalance.Balance.String(), payload["balance"])
|
|
})
|
|
|
|
t.Run("outbox events created for multiple balances in single upsert", func(t *testing.T) {
|
|
accounts := defaultAccounts()
|
|
// Create multiple balances at once
|
|
multiBalances := []models.Balance{
|
|
{
|
|
AccountID: accounts[2].ID,
|
|
CreatedAt: now.Add(-1 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-1 * time.Minute).UTC().Time,
|
|
Asset: "CHF/2",
|
|
Balance: big.NewInt(150),
|
|
},
|
|
{
|
|
AccountID: accounts[2].ID,
|
|
CreatedAt: now.Add(-1 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-1 * time.Minute).UTC().Time,
|
|
Asset: "AUD/2",
|
|
Balance: big.NewInt(200),
|
|
},
|
|
}
|
|
|
|
// Create a set of expected idempotency keys
|
|
expectedKeys := make(map[string]bool)
|
|
for _, balance := range multiBalances {
|
|
expectedKeys[balance.IdempotencyKey()] = true
|
|
}
|
|
|
|
// Insert multiple balances
|
|
require.NoError(t, store.BalancesUpsert(ctx, multiBalances))
|
|
|
|
// Verify outbox events were created for all balances
|
|
pendingEvents, err := store.OutboxEventsPollPending(ctx, 100)
|
|
require.NoError(t, err)
|
|
|
|
// Filter events to only those we just created
|
|
ourEvents := make([]models.OutboxEvent, 0)
|
|
for _, event := range pendingEvents {
|
|
if event.EventType == events.EventTypeSavedBalances && expectedKeys[event.ID.EventIdempotencyKey] {
|
|
ourEvents = append(ourEvents, event)
|
|
}
|
|
}
|
|
require.Len(t, ourEvents, 2, "expected 2 outbox events for 2 balances")
|
|
|
|
// Verify all events have correct structure
|
|
for _, event := range ourEvents {
|
|
assert.Equal(t, events.EventTypeSavedBalances, event.EventType)
|
|
assert.Equal(t, models.OUTBOX_STATUS_PENDING, event.Status)
|
|
assert.NotEmpty(t, event.ID.EventIdempotencyKey)
|
|
}
|
|
})
|
|
|
|
t.Run("no outbox events for stale balances (no-op)", func(t *testing.T) {
|
|
accounts := defaultAccounts()
|
|
// Try to insert a balance with a timestamp in the past (older than existing)
|
|
// This should be a no-op and not create an outbox event
|
|
staleBalance := models.Balance{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-70 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-70 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
}
|
|
|
|
expectedKey := staleBalance.IdempotencyKey()
|
|
|
|
// Insert stale balance (should be no-op)
|
|
require.NoError(t, store.BalancesUpsert(ctx, []models.Balance{staleBalance}))
|
|
|
|
// Verify no new outbox event was created by polling for recent events only
|
|
// Use a reasonable limit to avoid holding connections unnecessarily
|
|
pendingEvents, err := store.OutboxEventsPollPending(ctx, 100)
|
|
require.NoError(t, err)
|
|
|
|
// Check that no event with this idempotency key exists
|
|
for _, event := range pendingEvents {
|
|
if event.EventType == events.EventTypeSavedBalances && event.ID.EventIdempotencyKey == expectedKey {
|
|
t.Fatalf("unexpected outbox event created for stale balance (no-op)")
|
|
}
|
|
}
|
|
})
|
|
t.Run("mixed scenario: some balances create events, some don't", func(t *testing.T) {
|
|
accounts := defaultAccounts()
|
|
// Create a mix of balances:
|
|
// 1. Stale balance (no-op, no event)
|
|
// 2. New balance (insert, event)
|
|
// 3. Update with same value (update, event)
|
|
mixedBalances := []models.Balance{
|
|
// Stale balance - should be no-op
|
|
{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-65 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-65 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
},
|
|
// New balance - should create event
|
|
{
|
|
AccountID: accounts[1].ID,
|
|
CreatedAt: now.Add(-3 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-3 * time.Minute).UTC().Time,
|
|
Asset: "CAD/2",
|
|
Balance: big.NewInt(250),
|
|
},
|
|
// Update with same value - should create event
|
|
{
|
|
AccountID: accounts[2].ID,
|
|
CreatedAt: now.Add(-8 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-8 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100), // Same value, but newer timestamp
|
|
},
|
|
}
|
|
|
|
// Create expected keys for balances that should create events
|
|
expectedKeys := make(map[string]bool)
|
|
expectedKeys[mixedBalances[1].IdempotencyKey()] = true // New balance
|
|
expectedKeys[mixedBalances[2].IdempotencyKey()] = true // Update
|
|
staleKey := mixedBalances[0].IdempotencyKey() // Should NOT create event
|
|
|
|
// Insert mixed balances
|
|
require.NoError(t, store.BalancesUpsert(ctx, mixedBalances))
|
|
|
|
// Verify outbox events were created only for the ones that modified the DB
|
|
pendingEvents, err := store.OutboxEventsPollPending(ctx, 100)
|
|
require.NoError(t, err)
|
|
|
|
// Filter events to only those we just created
|
|
ourEvents := make([]models.OutboxEvent, 0)
|
|
for _, event := range pendingEvents {
|
|
if event.EventType == events.EventTypeSavedBalances && expectedKeys[event.ID.EventIdempotencyKey] {
|
|
ourEvents = append(ourEvents, event)
|
|
}
|
|
}
|
|
require.Len(t, ourEvents, 2, "expected 2 outbox events (one for new balance, one for update)")
|
|
|
|
// Verify no event was created for the stale balance
|
|
for _, event := range pendingEvents {
|
|
if event.EventType == events.EventTypeSavedBalances && event.ID.EventIdempotencyKey == staleKey {
|
|
t.Fatalf("unexpected outbox event created for stale balance (no-op)")
|
|
}
|
|
}
|
|
|
|
// Verify all events have correct structure
|
|
for _, event := range ourEvents {
|
|
assert.Equal(t, events.EventTypeSavedBalances, event.EventType)
|
|
assert.Equal(t, models.OUTBOX_STATUS_PENDING, event.Status)
|
|
assert.NotEmpty(t, event.ID.EventIdempotencyKey)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBalancesList(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := logging.TestingContext()
|
|
store := newStore(t)
|
|
defer store.Close()
|
|
|
|
upsertConnector(t, ctx, store, defaultConnector)
|
|
createPSU(t, ctx, store, defaultPSU2)
|
|
createOpenBankingConnection(t, ctx, store, defaultPSU2.ID, defaultOpenBankingConnection)
|
|
upsertAccounts(t, ctx, store, defaultAccounts())
|
|
upsertBalances(t, ctx, store, defaultBalances())
|
|
upsertBalances(t, ctx, store, defaultBalances2())
|
|
|
|
t.Run("list balances with account id", func(t *testing.T) {
|
|
accounts := defaultAccounts()
|
|
q := NewListBalancesQuery(
|
|
paginate.NewPaginatedQueryOptions(BalanceQuery{
|
|
AccountID: pointer.For(accounts[0].ID),
|
|
}).WithPageSize(15),
|
|
)
|
|
|
|
expectedBalances := []models.Balance{
|
|
{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-55 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-55 * time.Minute).UTC().Time,
|
|
Asset: "EUR/2",
|
|
Balance: big.NewInt(150),
|
|
PsuID: &defaultPSU2.ID,
|
|
OpenBankingConnectionID: &defaultOpenBankingConnection.ConnectionID,
|
|
},
|
|
{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-60 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-60 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
PsuID: nil,
|
|
OpenBankingConnectionID: nil,
|
|
},
|
|
}
|
|
|
|
cursor, err := store.BalancesList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, cursor.Data, 2)
|
|
require.False(t, cursor.HasMore)
|
|
require.Equal(t, expectedBalances, cursor.Data)
|
|
})
|
|
|
|
t.Run("list balances with asset 1", func(t *testing.T) {
|
|
q := NewListBalancesQuery(
|
|
paginate.NewPaginatedQueryOptions(BalanceQuery{
|
|
Asset: "USD/2",
|
|
}).WithPageSize(15),
|
|
)
|
|
|
|
accounts := defaultAccounts()
|
|
expectedBalances := []models.Balance{
|
|
{
|
|
AccountID: accounts[2].ID,
|
|
CreatedAt: now.Add(-59 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-59 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
},
|
|
{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-60 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-60 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
},
|
|
}
|
|
|
|
cursor, err := store.BalancesList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, cursor.Data, 2)
|
|
require.False(t, cursor.HasMore)
|
|
require.Equal(t, expectedBalances, cursor.Data)
|
|
})
|
|
|
|
t.Run("list balances with asset 2", func(t *testing.T) {
|
|
q := NewListBalancesQuery(
|
|
paginate.NewPaginatedQueryOptions(BalanceQuery{
|
|
Asset: "DKK/2",
|
|
}).WithPageSize(15),
|
|
)
|
|
|
|
accounts := defaultAccounts()
|
|
expectedBalances := []models.Balance{
|
|
{
|
|
AccountID: accounts[2].ID,
|
|
CreatedAt: now.Add(-31 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-31 * time.Minute).UTC().Time,
|
|
Asset: "DKK/2",
|
|
Balance: big.NewInt(1000),
|
|
},
|
|
}
|
|
|
|
cursor, err := store.BalancesList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, cursor.Data, 1)
|
|
require.False(t, cursor.HasMore)
|
|
require.Equal(t, expectedBalances, cursor.Data)
|
|
})
|
|
|
|
t.Run("list balances with from", func(t *testing.T) {
|
|
q := NewListBalancesQuery(
|
|
paginate.NewPaginatedQueryOptions(NewBalanceQuery().WithFrom(now.Add(-40 * time.Minute).UTC().Time)).WithPageSize(15),
|
|
)
|
|
|
|
accounts := defaultAccounts()
|
|
expectedBalances := []models.Balance{
|
|
{
|
|
AccountID: accounts[1].ID,
|
|
CreatedAt: now.Add(-30 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-30 * time.Minute).UTC().Time,
|
|
Asset: "EUR/2",
|
|
Balance: big.NewInt(1000),
|
|
},
|
|
{
|
|
AccountID: accounts[2].ID,
|
|
CreatedAt: now.Add(-31 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-31 * time.Minute).UTC().Time,
|
|
Asset: "DKK/2",
|
|
Balance: big.NewInt(1000),
|
|
},
|
|
}
|
|
|
|
cursor, err := store.BalancesList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, cursor.Data, 2)
|
|
require.False(t, cursor.HasMore)
|
|
require.Equal(t, expectedBalances, cursor.Data)
|
|
})
|
|
|
|
t.Run("list balances with from 2", func(t *testing.T) {
|
|
q := NewListBalancesQuery(
|
|
paginate.NewPaginatedQueryOptions(BalanceQuery{
|
|
From: now.Add(-20 * time.Minute).UTC().Time,
|
|
}).WithPageSize(15),
|
|
)
|
|
|
|
cursor, err := store.BalancesList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, cursor.Data, 0)
|
|
require.False(t, cursor.HasMore)
|
|
})
|
|
|
|
t.Run("list balances with to", func(t *testing.T) {
|
|
q := NewListBalancesQuery(
|
|
paginate.NewPaginatedQueryOptions(BalanceQuery{
|
|
To: now.Add(-40 * time.Minute).UTC().Time,
|
|
}).WithPageSize(15),
|
|
)
|
|
|
|
accounts := defaultAccounts()
|
|
expectedBalances := []models.Balance{
|
|
{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-55 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-55 * time.Minute).UTC().Time,
|
|
Asset: "EUR/2",
|
|
Balance: big.NewInt(150),
|
|
PsuID: &defaultPSU2.ID,
|
|
OpenBankingConnectionID: &defaultOpenBankingConnection.ConnectionID,
|
|
},
|
|
{
|
|
AccountID: accounts[2].ID,
|
|
CreatedAt: now.Add(-59 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-59 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
},
|
|
{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-60 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-60 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
},
|
|
}
|
|
|
|
cursor, err := store.BalancesList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, cursor.Data, 3)
|
|
require.False(t, cursor.HasMore)
|
|
require.Equal(t, expectedBalances, cursor.Data)
|
|
})
|
|
|
|
t.Run("list balances with to 2", func(t *testing.T) {
|
|
q := NewListBalancesQuery(
|
|
paginate.NewPaginatedQueryOptions(BalanceQuery{
|
|
To: now.Add(-70 * time.Minute).UTC().Time,
|
|
}).WithPageSize(15),
|
|
)
|
|
|
|
cursor, err := store.BalancesList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, cursor.Data, 0)
|
|
require.False(t, cursor.HasMore)
|
|
})
|
|
|
|
t.Run("list balances test cursor", func(t *testing.T) {
|
|
accounts := defaultAccounts()
|
|
q := NewListBalancesQuery(
|
|
paginate.NewPaginatedQueryOptions(BalanceQuery{
|
|
AccountID: pointer.For(accounts[0].ID),
|
|
}).WithPageSize(1),
|
|
)
|
|
expectedBalances1 := []models.Balance{
|
|
{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-55 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-55 * time.Minute).UTC().Time,
|
|
Asset: "EUR/2",
|
|
Balance: big.NewInt(150),
|
|
PsuID: &defaultPSU2.ID,
|
|
OpenBankingConnectionID: &defaultOpenBankingConnection.ConnectionID,
|
|
},
|
|
}
|
|
|
|
cursor, err := store.BalancesList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, cursor.Data, 1)
|
|
require.True(t, cursor.HasMore)
|
|
require.Empty(t, cursor.Previous)
|
|
require.NotEmpty(t, cursor.Next)
|
|
require.Equal(t, expectedBalances1, cursor.Data)
|
|
|
|
expectedBalances2 := []models.Balance{
|
|
{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-60 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-60 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
},
|
|
}
|
|
|
|
err = paginate.UnmarshalCursor(cursor.Next, &q)
|
|
require.NoError(t, err)
|
|
cursor, err = store.BalancesList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, cursor.Data, 1)
|
|
require.False(t, cursor.HasMore)
|
|
require.Empty(t, cursor.Next)
|
|
require.NotEmpty(t, cursor.Previous)
|
|
require.Equal(t, expectedBalances2, cursor.Data)
|
|
|
|
err = paginate.UnmarshalCursor(cursor.Previous, &q)
|
|
require.NoError(t, err)
|
|
cursor, err = store.BalancesList(ctx, q)
|
|
require.NoError(t, err)
|
|
require.Len(t, cursor.Data, 1)
|
|
require.True(t, cursor.HasMore)
|
|
require.Empty(t, cursor.Previous)
|
|
require.NotEmpty(t, cursor.Next)
|
|
require.Equal(t, expectedBalances1, cursor.Data)
|
|
})
|
|
}
|
|
|
|
func TestBalancesGetAt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := logging.TestingContext()
|
|
store := newStore(t)
|
|
defer store.Close()
|
|
|
|
upsertConnector(t, ctx, store, defaultConnector)
|
|
createPSU(t, ctx, store, defaultPSU2)
|
|
createOpenBankingConnection(t, ctx, store, defaultPSU2.ID, defaultOpenBankingConnection)
|
|
upsertAccounts(t, ctx, store, defaultAccounts())
|
|
upsertBalances(t, ctx, store, defaultBalances())
|
|
|
|
t.Run("get balances at before first balance should return empty", func(t *testing.T) {
|
|
accounts := defaultAccounts()
|
|
balances, err := store.BalancesGetFromAccountIDs(ctx, []models.AccountID{accounts[0].ID}, pointer.For(now.Add(-61*time.Minute).UTC().Time))
|
|
require.NoError(t, err)
|
|
require.Empty(t, balances)
|
|
})
|
|
|
|
t.Run("get balances at after last balance updated at should return empty", func(t *testing.T) {
|
|
accounts := defaultAccounts()
|
|
balances, err := store.BalancesGetFromAccountIDs(ctx, []models.AccountID{accounts[0].ID}, pointer.For(now.Add(-50*time.Minute).UTC().Time))
|
|
require.NoError(t, err)
|
|
require.Empty(t, balances)
|
|
})
|
|
|
|
t.Run("get balances at", func(t *testing.T) {
|
|
accounts := defaultAccounts()
|
|
balances, err := store.BalancesGetFromAccountIDs(ctx, []models.AccountID{accounts[0].ID}, pointer.For(now.Add(-60*time.Minute).UTC().Time))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, balances)
|
|
require.Len(t, balances, 1)
|
|
})
|
|
|
|
t.Run("get balances after inserting a new balance", func(t *testing.T) {
|
|
accounts := defaultAccounts()
|
|
b := models.Balance{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-20 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-20 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
}
|
|
|
|
upsertBalances(t, ctx, store, []models.Balance{b})
|
|
|
|
balances, err := store.BalancesGetFromAccountIDs(ctx, []models.AccountID{accounts[0].ID}, pointer.For(now.Add(-50*time.Minute).UTC().Time))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, balances)
|
|
require.Len(t, balances, 1)
|
|
})
|
|
|
|
t.Run("get balances at after inserting two new balances with different asset", func(t *testing.T) {
|
|
accounts := defaultAccounts()
|
|
|
|
b := models.Balance{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-20 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-20 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(100),
|
|
}
|
|
|
|
b1 := models.Balance{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-20 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-20 * time.Minute).UTC().Time,
|
|
Asset: "EUR/2",
|
|
Balance: big.NewInt(100),
|
|
}
|
|
|
|
upsertBalances(t, ctx, store, []models.Balance{b, b1})
|
|
|
|
balances, err := store.BalancesGetFromAccountIDs(ctx, []models.AccountID{accounts[0].ID}, pointer.For(now.Add(-50*time.Minute).UTC().Time))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, balances)
|
|
require.Len(t, balances, 2)
|
|
})
|
|
}
|
|
|
|
func TestBalancesGetLatestFromAccountIDs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := logging.TestingContext()
|
|
store := newStore(t)
|
|
defer store.Close()
|
|
|
|
upsertConnector(t, ctx, store, defaultConnector)
|
|
createPSU(t, ctx, store, defaultPSU2)
|
|
createOpenBankingConnection(t, ctx, store, defaultPSU2.ID, defaultOpenBankingConnection)
|
|
upsertAccounts(t, ctx, store, defaultAccounts())
|
|
upsertBalances(t, ctx, store, defaultBalancesDuplicates())
|
|
|
|
t.Run("get latest balances returns 1 balance per currency", func(t *testing.T) {
|
|
accounts := defaultAccounts()
|
|
accountIDs := make([]models.AccountID, len(accounts))
|
|
for i, account := range accounts {
|
|
accountIDs[i] = account.ID
|
|
}
|
|
balances, err := store.BalancesGetFromAccountIDs(ctx, accountIDs, nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, balances)
|
|
require.Len(t, balances, 2)
|
|
assert.Equal(t, balances[0].Asset, "EUR/2")
|
|
assert.Equal(t, balances[1].Asset, "USD/2")
|
|
})
|
|
|
|
t.Run("get latest balances after inserting a new balance", func(t *testing.T) {
|
|
accounts := defaultAccounts()
|
|
accountIDs := make([]models.AccountID, len(accounts))
|
|
for i, account := range accounts {
|
|
accountIDs[i] = account.ID
|
|
}
|
|
b := models.Balance{
|
|
AccountID: accounts[0].ID,
|
|
CreatedAt: now.Add(-20 * time.Minute).UTC().Time,
|
|
LastUpdatedAt: now.Add(-20 * time.Minute).UTC().Time,
|
|
Asset: "USD/2",
|
|
Balance: big.NewInt(999),
|
|
}
|
|
|
|
upsertBalances(t, ctx, store, []models.Balance{b})
|
|
|
|
balances, err := store.BalancesGetFromAccountIDs(ctx, accountIDs, nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, balances)
|
|
require.Len(t, balances, 2)
|
|
assert.Equal(t, balances[1].Asset, "USD/2")
|
|
assert.Equal(t, balances[1].Amount, b.Balance)
|
|
assert.Equal(t, balances[1].RelatedAccounts, []models.AccountID{accounts[0].ID})
|
|
})
|
|
|
|
t.Run("rollback on foreign key violation with latest balances", func(t *testing.T) {
|
|
// Create a valid balance first
|
|
upsertConnector(t, ctx, store, defaultConnector)
|
|
createPSU(t, ctx, store, defaultPSU2)
|
|
createOpenBankingConnection(t, ctx, store, defaultPSU2.ID, defaultOpenBankingConnection)
|
|
upsertAccounts(t, ctx, store, defaultAccounts())
|
|
accounts := defaultAccounts()
|
|
|
|
// Count existing balances
|
|
balancesBefore, err := store.BalancesGetFromAccountIDs(ctx, []models.AccountID{accounts[0].ID}, nil)
|
|
require.NoError(t, err)
|
|
countBefore := len(balancesBefore)
|
|
|
|
// Create a balance with an invalid account ID that doesn't exist
|
|
invalidConnectorID := models.ConnectorID{Reference: uuid.New(), Provider: "invalid-provider"}
|
|
invalidBalance := models.Balance{
|
|
AccountID: models.AccountID{Reference: "invalid-account", ConnectorID: invalidConnectorID},
|
|
CreatedAt: time.Now().UTC(),
|
|
LastUpdatedAt: time.Now().UTC(),
|
|
Asset: "USD",
|
|
Balance: big.NewInt(100),
|
|
}
|
|
|
|
// Attempt to upsert - should fail due to foreign key violation
|
|
err = store.BalancesUpsert(ctx, []models.Balance{invalidBalance})
|
|
require.Error(t, err)
|
|
|
|
// Verify no balance was inserted
|
|
balancesAfter, err := store.BalancesGetFromAccountIDs(ctx, []models.AccountID{accounts[0].ID}, nil)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, countBefore, len(balancesAfter), "no balances should be inserted on error")
|
|
|
|
// Verify no outbox events were created
|
|
pendingEvents, err := store.OutboxEventsPollPending(ctx, 100)
|
|
require.NoError(t, err)
|
|
for _, event := range pendingEvents {
|
|
assert.NotEqual(t, invalidBalance.AccountID.String(), event.EntityID, "no outbox event should be created for failed insert")
|
|
}
|
|
})
|
|
}
|