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 := ×tamppb.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) }) }