Files
sendico/api/ledger/internal/service/ledger/helpers_test.go
Stephan D 2ee17b0c46
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
fx build fix
2025-11-07 23:50:48 +01:00

418 lines
10 KiB
Go

package ledger
import (
"testing"
"time"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/ledger/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestParseObjectID(t *testing.T) {
t.Run("ValidObjectID", func(t *testing.T) {
validID := primitive.NewObjectID()
result, err := parseObjectID(validID.Hex())
require.NoError(t, err)
assert.Equal(t, validID, result)
})
t.Run("EmptyString", func(t *testing.T) {
result, err := parseObjectID("")
require.Error(t, err)
assert.Equal(t, primitive.NilObjectID, result)
assert.Contains(t, err.Error(), "empty object ID")
})
t.Run("InvalidHexString", func(t *testing.T) {
result, err := parseObjectID("invalid-hex-string")
require.Error(t, err)
assert.Equal(t, primitive.NilObjectID, result)
assert.Contains(t, err.Error(), "invalid object ID")
})
t.Run("IncorrectLength", func(t *testing.T) {
result, err := parseObjectID("abc123")
require.Error(t, err)
assert.Equal(t, primitive.NilObjectID, result)
})
}
func TestParseDecimal(t *testing.T) {
t.Run("ValidDecimal", func(t *testing.T) {
result, err := parseDecimal("123.45")
require.NoError(t, err)
assert.True(t, result.Equal(decimal.NewFromFloat(123.45)))
})
t.Run("EmptyString", func(t *testing.T) {
result, err := parseDecimal("")
require.Error(t, err)
assert.True(t, result.IsZero())
assert.Contains(t, err.Error(), "empty amount")
})
t.Run("InvalidDecimal", func(t *testing.T) {
result, err := parseDecimal("not-a-number")
require.Error(t, err)
assert.True(t, result.IsZero())
assert.Contains(t, err.Error(), "invalid decimal amount")
})
t.Run("NegativeDecimal", func(t *testing.T) {
result, err := parseDecimal("-100.50")
require.NoError(t, err)
assert.True(t, result.Equal(decimal.NewFromFloat(-100.50)))
})
t.Run("ZeroDecimal", func(t *testing.T) {
result, err := parseDecimal("0")
require.NoError(t, err)
assert.True(t, result.IsZero())
})
}
func TestValidateMoney(t *testing.T) {
t.Run("ValidMoney", func(t *testing.T) {
money := &moneyv1.Money{
Amount: "100.50",
Currency: "USD",
}
err := validateMoney(money, "test_field")
assert.NoError(t, err)
})
t.Run("NilMoney", func(t *testing.T) {
err := validateMoney(nil, "test_field")
require.Error(t, err)
assert.Contains(t, err.Error(), "test_field: money is required")
})
t.Run("EmptyAmount", func(t *testing.T) {
money := &moneyv1.Money{
Amount: "",
Currency: "USD",
}
err := validateMoney(money, "test_field")
require.Error(t, err)
assert.Contains(t, err.Error(), "test_field: amount is required")
})
t.Run("EmptyCurrency", func(t *testing.T) {
money := &moneyv1.Money{
Amount: "100.50",
Currency: "",
}
err := validateMoney(money, "test_field")
require.Error(t, err)
assert.Contains(t, err.Error(), "test_field: currency is required")
})
t.Run("InvalidAmount", func(t *testing.T) {
money := &moneyv1.Money{
Amount: "invalid",
Currency: "USD",
}
err := validateMoney(money, "test_field")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid decimal amount")
})
}
func TestValidatePostingLines(t *testing.T) {
t.Run("ValidPostingLines", func(t *testing.T) {
lines := []*ledgerv1.PostingLine{
{
LedgerAccountRef: primitive.NewObjectID().Hex(),
Money: &moneyv1.Money{
Amount: "10.00",
Currency: "USD",
},
LineType: ledgerv1.LineType_LINE_FEE,
},
}
err := validatePostingLines(lines)
assert.NoError(t, err)
})
t.Run("EmptyLines", func(t *testing.T) {
err := validatePostingLines([]*ledgerv1.PostingLine{})
assert.NoError(t, err)
})
t.Run("NilLine", func(t *testing.T) {
lines := []*ledgerv1.PostingLine{nil}
err := validatePostingLines(lines)
require.Error(t, err)
assert.Contains(t, err.Error(), "nil posting line")
})
t.Run("EmptyAccountRef", func(t *testing.T) {
lines := []*ledgerv1.PostingLine{
{
LedgerAccountRef: "",
Money: &moneyv1.Money{
Amount: "10.00",
Currency: "USD",
},
},
}
err := validatePostingLines(lines)
require.Error(t, err)
assert.Contains(t, err.Error(), "ledger_account_ref is required")
})
t.Run("NilMoney", func(t *testing.T) {
lines := []*ledgerv1.PostingLine{
{
LedgerAccountRef: primitive.NewObjectID().Hex(),
Money: nil,
},
}
err := validatePostingLines(lines)
require.Error(t, err)
assert.Contains(t, err.Error(), "money is required")
})
t.Run("MainLineType", func(t *testing.T) {
lines := []*ledgerv1.PostingLine{
{
LedgerAccountRef: primitive.NewObjectID().Hex(),
Money: &moneyv1.Money{
Amount: "10.00",
Currency: "USD",
},
LineType: ledgerv1.LineType_LINE_MAIN,
},
}
err := validatePostingLines(lines)
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot have LINE_MAIN type")
})
}
func TestGetEventTime(t *testing.T) {
t.Run("ValidTimestamp", func(t *testing.T) {
now := time.Now()
ts := timestamppb.New(now)
result := getEventTime(ts)
assert.True(t, result.Sub(now) < time.Second)
})
t.Run("NilTimestamp", func(t *testing.T) {
before := time.Now()
result := getEventTime(nil)
after := time.Now()
assert.True(t, result.After(before) || result.Equal(before))
assert.True(t, result.Before(after) || result.Equal(after))
})
t.Run("InvalidTimestamp", func(t *testing.T) {
// Create an invalid timestamp with negative seconds
ts := &timestamppb.Timestamp{Seconds: -1, Nanos: -1}
// Invalid timestamp should return current time
before := time.Now()
result := getEventTime(ts)
after := time.Now()
// Result should be close to now since timestamp is invalid
assert.True(t, result.After(before.Add(-time.Second)) || result.Equal(before))
assert.True(t, result.Before(after.Add(time.Second)) || result.Equal(after))
})
}
func TestProtoLineTypeToModel(t *testing.T) {
tests := []struct {
name string
input ledgerv1.LineType
expected model.LineType
}{
{"Main", ledgerv1.LineType_LINE_MAIN, model.LineTypeMain},
{"Fee", ledgerv1.LineType_LINE_FEE, model.LineTypeFee},
{"Spread", ledgerv1.LineType_LINE_SPREAD, model.LineTypeSpread},
{"Reversal", ledgerv1.LineType_LINE_REVERSAL, model.LineTypeReversal},
{"Unspecified", ledgerv1.LineType_LINE_TYPE_UNSPECIFIED, model.LineTypeMain},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := protoLineTypeToModel(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestModelLineTypeToProto(t *testing.T) {
tests := []struct {
name string
input model.LineType
expected ledgerv1.LineType
}{
{"Main", model.LineTypeMain, ledgerv1.LineType_LINE_MAIN},
{"Fee", model.LineTypeFee, ledgerv1.LineType_LINE_FEE},
{"Spread", model.LineTypeSpread, ledgerv1.LineType_LINE_SPREAD},
{"Reversal", model.LineTypeReversal, ledgerv1.LineType_LINE_REVERSAL},
{"Unknown", model.LineType("unknown"), ledgerv1.LineType_LINE_TYPE_UNSPECIFIED},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := modelLineTypeToProto(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestModelEntryTypeToProto(t *testing.T) {
tests := []struct {
name string
input model.EntryType
expected ledgerv1.EntryType
}{
{"Credit", model.EntryTypeCredit, ledgerv1.EntryType_ENTRY_CREDIT},
{"Debit", model.EntryTypeDebit, ledgerv1.EntryType_ENTRY_DEBIT},
{"Transfer", model.EntryTypeTransfer, ledgerv1.EntryType_ENTRY_TRANSFER},
{"FX", model.EntryTypeFX, ledgerv1.EntryType_ENTRY_FX},
{"Fee", model.EntryTypeFee, ledgerv1.EntryType_ENTRY_FEE},
{"Adjust", model.EntryTypeAdjust, ledgerv1.EntryType_ENTRY_ADJUST},
{"Reverse", model.EntryTypeReverse, ledgerv1.EntryType_ENTRY_REVERSE},
{"Unknown", model.EntryType("unknown"), ledgerv1.EntryType_ENTRY_TYPE_UNSPECIFIED},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := modelEntryTypeToProto(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestCalculateBalance(t *testing.T) {
t.Run("PositiveBalance", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "100.00"},
{Amount: "50.00"},
}
result, err := calculateBalance(lines)
require.NoError(t, err)
assert.True(t, result.Equal(decimal.NewFromFloat(150.00)))
})
t.Run("NegativeBalance", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "-100.00"},
{Amount: "-50.00"},
}
result, err := calculateBalance(lines)
require.NoError(t, err)
assert.True(t, result.Equal(decimal.NewFromFloat(-150.00)))
})
t.Run("ZeroBalance", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "100.00"},
{Amount: "-100.00"},
}
result, err := calculateBalance(lines)
require.NoError(t, err)
assert.True(t, result.IsZero())
})
t.Run("EmptyLines", func(t *testing.T) {
result, err := calculateBalance([]*model.PostingLine{})
require.NoError(t, err)
assert.True(t, result.IsZero())
})
t.Run("InvalidAmount", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "invalid"},
}
_, err := calculateBalance(lines)
require.Error(t, err)
})
}
func TestValidateBalanced(t *testing.T) {
t.Run("BalancedEntry", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "100.00"}, // credit
{Amount: "-100.00"}, // debit
}
err := validateBalanced(lines)
assert.NoError(t, err)
})
t.Run("BalancedWithMultipleLines", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "100.00"}, // credit
{Amount: "-50.00"}, // debit
{Amount: "-50.00"}, // debit
}
err := validateBalanced(lines)
assert.NoError(t, err)
})
t.Run("UnbalancedEntry", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "100.00"},
{Amount: "-50.00"},
}
err := validateBalanced(lines)
require.Error(t, err)
assert.Contains(t, err.Error(), "must balance")
})
t.Run("EmptyLines", func(t *testing.T) {
err := validateBalanced([]*model.PostingLine{})
assert.NoError(t, err)
})
}