service backend
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful

This commit is contained in:
Stephan D
2025-11-07 18:35:26 +01:00
parent 20e8f9acc4
commit 62a6631b9a
537 changed files with 48453 additions and 0 deletions

View File

@@ -0,0 +1,220 @@
package store
import (
"context"
"errors"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type accountsStore struct {
logger mlogger.Logger
repo repository.Repository
}
func NewAccounts(logger mlogger.Logger, db *mongo.Database) (storage.AccountsStore, error) {
repo := repository.CreateMongoRepository(db, model.AccountsCollection)
// Create compound index on organizationRef + accountCode + currency (unique)
uniqueIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "organizationRef", Sort: ri.Asc},
{Field: "accountCode", Sort: ri.Asc},
{Field: "currency", Sort: ri.Asc},
},
Unique: true,
}
if err := repo.CreateIndex(uniqueIndex); err != nil {
logger.Error("failed to ensure accounts unique index", zap.Error(err))
return nil, err
}
// Create index on organizationRef for listing
orgIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "organizationRef", Sort: ri.Asc},
},
}
if err := repo.CreateIndex(orgIndex); err != nil {
logger.Error("failed to ensure accounts organization index", zap.Error(err))
return nil, err
}
childLogger := logger.Named(model.AccountsCollection)
childLogger.Debug("accounts store initialised", zap.String("collection", model.AccountsCollection))
return &accountsStore{
logger: childLogger,
repo: repo,
}, nil
}
func (a *accountsStore) Create(ctx context.Context, account *model.Account) error {
if account == nil {
a.logger.Warn("attempt to create nil account")
return merrors.InvalidArgument("accountsStore: nil account")
}
if err := a.repo.Insert(ctx, account, nil); err != nil {
if mongo.IsDuplicateKeyError(err) {
a.logger.Warn("duplicate account code", zap.String("accountCode", account.AccountCode),
zap.String("currency", account.Currency))
return merrors.DataConflict("account with this code and currency already exists")
}
a.logger.Warn("failed to create account", zap.Error(err))
return err
}
a.logger.Debug("account created", zap.String("accountCode", account.AccountCode),
zap.String("currency", account.Currency))
return nil
}
func (a *accountsStore) Get(ctx context.Context, accountRef primitive.ObjectID) (*model.Account, error) {
if accountRef.IsZero() {
a.logger.Warn("attempt to get account with zero ID")
return nil, merrors.InvalidArgument("accountsStore: zero account ID")
}
result := &model.Account{}
if err := a.repo.Get(ctx, accountRef, result); err != nil {
if errors.Is(err, merrors.ErrNoData) {
a.logger.Debug("account not found", zap.String("accountRef", accountRef.Hex()))
return nil, storage.ErrAccountNotFound
}
a.logger.Warn("failed to get account", zap.Error(err), zap.String("accountRef", accountRef.Hex()))
return nil, err
}
a.logger.Debug("account loaded", zap.String("accountRef", accountRef.Hex()),
zap.String("accountCode", result.AccountCode))
return result, nil
}
func (a *accountsStore) GetByAccountCode(ctx context.Context, orgRef primitive.ObjectID, accountCode, currency string) (*model.Account, error) {
if orgRef.IsZero() {
a.logger.Warn("attempt to get account with zero organization ID")
return nil, merrors.InvalidArgument("accountsStore: zero organization ID")
}
if accountCode == "" {
a.logger.Warn("attempt to get account with empty code")
return nil, merrors.InvalidArgument("accountsStore: empty account code")
}
if currency == "" {
a.logger.Warn("attempt to get account with empty currency")
return nil, merrors.InvalidArgument("accountsStore: empty currency")
}
query := repository.Query().
Filter(repository.Field("organizationRef"), orgRef).
Filter(repository.Field("accountCode"), accountCode).
Filter(repository.Field("currency"), currency)
result := &model.Account{}
if err := a.repo.FindOneByFilter(ctx, query, result); err != nil {
if errors.Is(err, merrors.ErrNoData) {
a.logger.Debug("account not found by code", zap.String("accountCode", accountCode),
zap.String("currency", currency))
return nil, storage.ErrAccountNotFound
}
a.logger.Warn("failed to get account by code", zap.Error(err), zap.String("accountCode", accountCode))
return nil, err
}
a.logger.Debug("account loaded by code", zap.String("accountCode", accountCode),
zap.String("currency", currency))
return result, nil
}
func (a *accountsStore) GetDefaultSettlement(ctx context.Context, orgRef primitive.ObjectID, currency string) (*model.Account, error) {
if orgRef.IsZero() {
a.logger.Warn("attempt to get default settlement with zero organization ID")
return nil, merrors.InvalidArgument("accountsStore: zero organization ID")
}
if currency == "" {
a.logger.Warn("attempt to get default settlement with empty currency")
return nil, merrors.InvalidArgument("accountsStore: empty currency")
}
limit := int64(1)
query := repository.Query().
Filter(repository.Field("organizationRef"), orgRef).
Filter(repository.Field("currency"), currency).
Filter(repository.Field("isSettlement"), true).
Limit(&limit)
result := &model.Account{}
if err := a.repo.FindOneByFilter(ctx, query, result); err != nil {
if errors.Is(err, merrors.ErrNoData) {
a.logger.Debug("default settlement account not found",
zap.String("currency", currency),
zap.String("organizationRef", orgRef.Hex()))
return nil, storage.ErrAccountNotFound
}
a.logger.Warn("failed to get default settlement account", zap.Error(err),
zap.String("organizationRef", orgRef.Hex()),
zap.String("currency", currency))
return nil, err
}
a.logger.Debug("default settlement account loaded",
zap.String("accountRef", result.GetID().Hex()),
zap.String("currency", currency))
return result, nil
}
func (a *accountsStore) ListByOrganization(ctx context.Context, orgRef primitive.ObjectID, limit int, offset int) ([]*model.Account, error) {
if orgRef.IsZero() {
a.logger.Warn("attempt to list accounts with zero organization ID")
return nil, merrors.InvalidArgument("accountsStore: zero organization ID")
}
limit64 := int64(limit)
offset64 := int64(offset)
query := repository.Query().
Filter(repository.Field("organizationRef"), orgRef).
Limit(&limit64).
Offset(&offset64)
accounts := make([]*model.Account, 0)
err := a.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
doc := &model.Account{}
if err := cur.Decode(doc); err != nil {
return err
}
accounts = append(accounts, doc)
return nil
})
if err != nil {
a.logger.Warn("failed to list accounts", zap.Error(err))
return nil, err
}
a.logger.Debug("listed accounts", zap.Int("count", len(accounts)))
return accounts, nil
}
func (a *accountsStore) UpdateStatus(ctx context.Context, accountRef primitive.ObjectID, status model.AccountStatus) error {
if accountRef.IsZero() {
a.logger.Warn("attempt to update account status with zero ID")
return merrors.InvalidArgument("accountsStore: zero account ID")
}
patch := repository.Patch().Set(repository.Field("status"), status)
if err := a.repo.Patch(ctx, accountRef, patch); err != nil {
a.logger.Warn("failed to update account status", zap.Error(err), zap.String("accountRef", accountRef.Hex()))
return err
}
a.logger.Debug("account status updated", zap.String("accountRef", accountRef.Hex()),
zap.String("status", string(status)))
return nil
}

View File

@@ -0,0 +1,436 @@
package store
import (
"context"
"errors"
"testing"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
rd "github.com/tech/sendico/pkg/db/repository/decoder"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
func TestAccountsStore_Create(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("Success", func(t *testing.T) {
var insertedAccount *model.Account
stub := &repositoryStub{
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
insertedAccount = object.(*model.Account)
return nil
},
}
store := &accountsStore{logger: logger, repo: stub}
account := &model.Account{
AccountCode: "1000",
Currency: "USD",
AccountType: model.AccountTypeAsset,
Status: model.AccountStatusActive,
AllowNegative: false,
}
err := store.Create(ctx, account)
require.NoError(t, err)
assert.NotNil(t, insertedAccount)
assert.Equal(t, "1000", insertedAccount.AccountCode)
assert.Equal(t, "USD", insertedAccount.Currency)
})
t.Run("NilAccount", func(t *testing.T) {
stub := &repositoryStub{}
store := &accountsStore{logger: logger, repo: stub}
err := store.Create(ctx, nil)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("DuplicateAccountCode", func(t *testing.T) {
stub := &repositoryStub{
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
return mongo.WriteException{
WriteErrors: []mongo.WriteError{
{Code: 11000}, // Duplicate key error
},
}
},
}
store := &accountsStore{logger: logger, repo: stub}
account := &model.Account{
AccountCode: "1000",
Currency: "USD",
}
err := store.Create(ctx, account)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrDataConflict))
})
t.Run("InsertError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
return expectedErr
},
}
store := &accountsStore{logger: logger, repo: stub}
account := &model.Account{AccountCode: "1000", Currency: "USD"}
err := store.Create(ctx, account)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
}
func TestAccountsStore_Get(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("Success", func(t *testing.T) {
accountRef := primitive.NewObjectID()
stub := &repositoryStub{
GetFunc: func(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
account := result.(*model.Account)
account.SetID(accountRef)
account.AccountCode = "1000"
account.Currency = "USD"
return nil
},
}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.Get(ctx, accountRef)
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, "1000", result.AccountCode)
assert.Equal(t, "USD", result.Currency)
})
t.Run("ZeroID", func(t *testing.T) {
stub := &repositoryStub{}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.Get(ctx, primitive.NilObjectID)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("NotFound", func(t *testing.T) {
accountRef := primitive.NewObjectID()
stub := &repositoryStub{
GetFunc: func(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
return merrors.ErrNoData
},
}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.Get(ctx, accountRef)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, storage.ErrAccountNotFound))
})
t.Run("GetError", func(t *testing.T) {
accountRef := primitive.NewObjectID()
expectedErr := errors.New("database error")
stub := &repositoryStub{
GetFunc: func(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
return expectedErr
},
}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.Get(ctx, accountRef)
require.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, expectedErr, err)
})
}
func TestAccountsStore_GetByAccountCode(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
orgRef := primitive.NewObjectID()
t.Run("Success", func(t *testing.T) {
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
account := result.(*model.Account)
account.AccountCode = "1000"
account.Currency = "USD"
return nil
},
}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.GetByAccountCode(ctx, orgRef, "1000", "USD")
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, "1000", result.AccountCode)
assert.Equal(t, "USD", result.Currency)
})
t.Run("ZeroOrganizationID", func(t *testing.T) {
stub := &repositoryStub{}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.GetByAccountCode(ctx, primitive.NilObjectID, "1000", "USD")
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("EmptyAccountCode", func(t *testing.T) {
stub := &repositoryStub{}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.GetByAccountCode(ctx, orgRef, "", "USD")
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("EmptyCurrency", func(t *testing.T) {
stub := &repositoryStub{}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.GetByAccountCode(ctx, orgRef, "1000", "")
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("NotFound", func(t *testing.T) {
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
return merrors.ErrNoData
},
}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.GetByAccountCode(ctx, orgRef, "9999", "USD")
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, storage.ErrAccountNotFound))
})
}
func TestAccountsStore_GetDefaultSettlement(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
orgRef := primitive.NewObjectID()
t.Run("Success", func(t *testing.T) {
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
account := result.(*model.Account)
account.SetID(primitive.NewObjectID())
account.Currency = "USD"
account.IsSettlement = true
return nil
},
}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.GetDefaultSettlement(ctx, orgRef, "USD")
require.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsSettlement)
assert.Equal(t, "USD", result.Currency)
})
t.Run("ZeroOrganizationID", func(t *testing.T) {
store := &accountsStore{logger: logger, repo: &repositoryStub{}}
result, err := store.GetDefaultSettlement(ctx, primitive.NilObjectID, "USD")
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("EmptyCurrency", func(t *testing.T) {
store := &accountsStore{logger: logger, repo: &repositoryStub{}}
result, err := store.GetDefaultSettlement(ctx, orgRef, "")
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("NotFound", func(t *testing.T) {
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
return merrors.ErrNoData
},
}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.GetDefaultSettlement(ctx, orgRef, "USD")
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, storage.ErrAccountNotFound))
})
t.Run("FindError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
return expectedErr
},
}
store := &accountsStore{logger: logger, repo: stub}
result, err := store.GetDefaultSettlement(ctx, orgRef, "USD")
require.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, expectedErr, err)
})
}
func TestAccountsStore_ListByOrganization(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
orgRef := primitive.NewObjectID()
t.Run("Success", func(t *testing.T) {
var calledWithQuery bool
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
calledWithQuery = true
// In unit tests, we just verify the method is called correctly
// Integration tests would test the actual iteration logic
return nil
},
}
store := &accountsStore{logger: logger, repo: stub}
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
require.NoError(t, err)
assert.True(t, calledWithQuery, "FindManyByFilter should have been called")
assert.NotNil(t, results)
})
t.Run("ZeroOrganizationID", func(t *testing.T) {
stub := &repositoryStub{}
store := &accountsStore{logger: logger, repo: stub}
results, err := store.ListByOrganization(ctx, primitive.NilObjectID, 10, 0)
require.Error(t, err)
assert.Nil(t, results)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("EmptyResult", func(t *testing.T) {
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return nil
},
}
store := &accountsStore{logger: logger, repo: stub}
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
require.NoError(t, err)
assert.Len(t, results, 0)
})
t.Run("FindError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return expectedErr
},
}
store := &accountsStore{logger: logger, repo: stub}
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
require.Error(t, err)
assert.Nil(t, results)
assert.Equal(t, expectedErr, err)
})
}
func TestAccountsStore_UpdateStatus(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
accountRef := primitive.NewObjectID()
t.Run("Success", func(t *testing.T) {
var patchedID primitive.ObjectID
var patchedStatus model.AccountStatus
stub := &repositoryStub{
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
patchedID = id
// In real test, we'd inspect patch builder but this is sufficient for stub
patchedStatus = model.AccountStatusFrozen
return nil
},
}
store := &accountsStore{logger: logger, repo: stub}
err := store.UpdateStatus(ctx, accountRef, model.AccountStatusFrozen)
require.NoError(t, err)
assert.Equal(t, accountRef, patchedID)
assert.Equal(t, model.AccountStatusFrozen, patchedStatus)
})
t.Run("ZeroID", func(t *testing.T) {
stub := &repositoryStub{}
store := &accountsStore{logger: logger, repo: stub}
err := store.UpdateStatus(ctx, primitive.NilObjectID, model.AccountStatusFrozen)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("PatchError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
return expectedErr
},
}
store := &accountsStore{logger: logger, repo: stub}
err := store.UpdateStatus(ctx, accountRef, model.AccountStatusFrozen)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
}

View File

@@ -0,0 +1,115 @@
package store
import (
"context"
"errors"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type balancesStore struct {
logger mlogger.Logger
repo repository.Repository
}
func NewBalances(logger mlogger.Logger, db *mongo.Database) (storage.BalancesStore, error) {
repo := repository.CreateMongoRepository(db, model.AccountBalancesCollection)
// Create unique index on accountRef (one balance per account)
uniqueIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "accountRef", Sort: ri.Asc},
},
Unique: true,
}
if err := repo.CreateIndex(uniqueIndex); err != nil {
logger.Error("failed to ensure balances unique index", zap.Error(err))
return nil, err
}
childLogger := logger.Named(model.AccountBalancesCollection)
childLogger.Debug("balances store initialised", zap.String("collection", model.AccountBalancesCollection))
return &balancesStore{
logger: childLogger,
repo: repo,
}, nil
}
func (b *balancesStore) Get(ctx context.Context, accountRef primitive.ObjectID) (*model.AccountBalance, error) {
if accountRef.IsZero() {
b.logger.Warn("attempt to get balance with zero account ID")
return nil, merrors.InvalidArgument("balancesStore: zero account ID")
}
query := repository.Filter("accountRef", accountRef)
result := &model.AccountBalance{}
if err := b.repo.FindOneByFilter(ctx, query, result); err != nil {
if errors.Is(err, merrors.ErrNoData) {
b.logger.Debug("balance not found", zap.String("accountRef", accountRef.Hex()))
return nil, storage.ErrBalanceNotFound
}
b.logger.Warn("failed to get balance", zap.Error(err), zap.String("accountRef", accountRef.Hex()))
return nil, err
}
b.logger.Debug("balance loaded", zap.String("accountRef", accountRef.Hex()),
zap.String("balance", result.Balance))
return result, nil
}
func (b *balancesStore) Upsert(ctx context.Context, balance *model.AccountBalance) error {
if balance == nil {
b.logger.Warn("attempt to upsert nil balance")
return merrors.InvalidArgument("balancesStore: nil balance")
}
if balance.AccountRef.IsZero() {
b.logger.Warn("attempt to upsert balance with zero account ID")
return merrors.InvalidArgument("balancesStore: zero account ID")
}
existing := &model.AccountBalance{}
filter := repository.Filter("accountRef", balance.AccountRef)
if err := b.repo.FindOneByFilter(ctx, filter, existing); err != nil {
if errors.Is(err, merrors.ErrNoData) {
b.logger.Debug("inserting new balance", zap.String("accountRef", balance.AccountRef.Hex()))
return b.repo.Insert(ctx, balance, filter)
}
b.logger.Warn("failed to fetch balance", zap.Error(err), zap.String("accountRef", balance.AccountRef.Hex()))
return err
}
if existing.GetID() != nil {
balance.SetID(*existing.GetID())
}
b.logger.Debug("updating balance", zap.String("accountRef", balance.AccountRef.Hex()),
zap.String("balance", balance.Balance))
return b.repo.Update(ctx, balance)
}
func (b *balancesStore) IncrementBalance(ctx context.Context, accountRef primitive.ObjectID, amount string) error {
if accountRef.IsZero() {
b.logger.Warn("attempt to increment balance with zero account ID")
return merrors.InvalidArgument("balancesStore: zero account ID")
}
// Note: This implementation uses $inc on a string field, which won't work.
// In a real implementation, you'd need to:
// 1. Fetch the balance
// 2. Parse amount strings to decimal
// 3. Add them
// 4. Update with optimistic locking via version field
// For now, return not implemented to indicate this needs proper decimal handling
b.logger.Warn("IncrementBalance not fully implemented - requires decimal arithmetic")
return merrors.NotImplemented("IncrementBalance requires proper decimal handling")
}

View File

@@ -0,0 +1,285 @@
package store
import (
"context"
"errors"
"testing"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func TestBalancesStore_Get(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("Success", func(t *testing.T) {
accountRef := primitive.NewObjectID()
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
balance := result.(*model.AccountBalance)
balance.AccountRef = accountRef
balance.Balance = "1500.50"
balance.Version = 10
return nil
},
}
store := &balancesStore{logger: logger, repo: stub}
result, err := store.Get(ctx, accountRef)
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, accountRef, result.AccountRef)
assert.Equal(t, "1500.50", result.Balance)
assert.Equal(t, int64(10), result.Version)
})
t.Run("ZeroAccountID", func(t *testing.T) {
stub := &repositoryStub{}
store := &balancesStore{logger: logger, repo: stub}
result, err := store.Get(ctx, primitive.NilObjectID)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("NotFound", func(t *testing.T) {
accountRef := primitive.NewObjectID()
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
return merrors.ErrNoData
},
}
store := &balancesStore{logger: logger, repo: stub}
result, err := store.Get(ctx, accountRef)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, storage.ErrBalanceNotFound))
})
t.Run("FindError", func(t *testing.T) {
accountRef := primitive.NewObjectID()
expectedErr := errors.New("database error")
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
return expectedErr
},
}
store := &balancesStore{logger: logger, repo: stub}
result, err := store.Get(ctx, accountRef)
require.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, expectedErr, err)
})
}
func TestBalancesStore_Upsert(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("Insert_NewBalance", func(t *testing.T) {
accountRef := primitive.NewObjectID()
var insertedBalance *model.AccountBalance
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
return merrors.ErrNoData // Balance doesn't exist
},
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
insertedBalance = object.(*model.AccountBalance)
return nil
},
}
store := &balancesStore{logger: logger, repo: stub}
balance := &model.AccountBalance{
AccountRef: accountRef,
Balance: "1000.00",
Version: 1,
}
err := store.Upsert(ctx, balance)
require.NoError(t, err)
assert.NotNil(t, insertedBalance)
assert.Equal(t, "1000.00", insertedBalance.Balance)
})
t.Run("Update_ExistingBalance", func(t *testing.T) {
accountRef := primitive.NewObjectID()
existingID := primitive.NewObjectID()
var updatedBalance *model.AccountBalance
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
existing := result.(*model.AccountBalance)
existing.SetID(existingID)
existing.AccountRef = accountRef
existing.Balance = "500.00"
existing.Version = 5
return nil
},
UpdateFunc: func(ctx context.Context, object storable.Storable) error {
updatedBalance = object.(*model.AccountBalance)
return nil
},
}
store := &balancesStore{logger: logger, repo: stub}
balance := &model.AccountBalance{
AccountRef: accountRef,
Balance: "1500.00",
Version: 6,
}
err := store.Upsert(ctx, balance)
require.NoError(t, err)
assert.NotNil(t, updatedBalance)
assert.Equal(t, existingID, *updatedBalance.GetID())
assert.Equal(t, "1500.00", updatedBalance.Balance)
assert.Equal(t, int64(6), updatedBalance.Version)
})
t.Run("NilBalance", func(t *testing.T) {
stub := &repositoryStub{}
store := &balancesStore{logger: logger, repo: stub}
err := store.Upsert(ctx, nil)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("ZeroAccountID", func(t *testing.T) {
stub := &repositoryStub{}
store := &balancesStore{logger: logger, repo: stub}
balance := &model.AccountBalance{
AccountRef: primitive.NilObjectID,
Balance: "100.00",
}
err := store.Upsert(ctx, balance)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("FindError", func(t *testing.T) {
accountRef := primitive.NewObjectID()
expectedErr := errors.New("database error")
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
return expectedErr
},
}
store := &balancesStore{logger: logger, repo: stub}
balance := &model.AccountBalance{
AccountRef: accountRef,
Balance: "100.00",
}
err := store.Upsert(ctx, balance)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
t.Run("InsertError", func(t *testing.T) {
accountRef := primitive.NewObjectID()
expectedErr := errors.New("insert error")
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
return merrors.ErrNoData // Balance doesn't exist
},
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
return expectedErr
},
}
store := &balancesStore{logger: logger, repo: stub}
balance := &model.AccountBalance{
AccountRef: accountRef,
Balance: "100.00",
}
err := store.Upsert(ctx, balance)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
t.Run("UpdateError", func(t *testing.T) {
accountRef := primitive.NewObjectID()
existingID := primitive.NewObjectID()
expectedErr := errors.New("update error")
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
existing := result.(*model.AccountBalance)
existing.SetID(existingID)
existing.AccountRef = accountRef
existing.Balance = "500.00"
return nil
},
UpdateFunc: func(ctx context.Context, object storable.Storable) error {
return expectedErr
},
}
store := &balancesStore{logger: logger, repo: stub}
balance := &model.AccountBalance{
AccountRef: accountRef,
Balance: "1500.00",
}
err := store.Upsert(ctx, balance)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
}
func TestBalancesStore_IncrementBalance(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("NotImplemented", func(t *testing.T) {
accountRef := primitive.NewObjectID()
stub := &repositoryStub{}
store := &balancesStore{logger: logger, repo: stub}
err := store.IncrementBalance(ctx, accountRef, "100.00")
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrNotImplemented))
})
t.Run("ZeroAccountID", func(t *testing.T) {
stub := &repositoryStub{}
store := &balancesStore{logger: logger, repo: stub}
err := store.IncrementBalance(ctx, primitive.NilObjectID, "100.00")
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
}

View File

@@ -0,0 +1,160 @@
package store
import (
"context"
"errors"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type journalEntriesStore struct {
logger mlogger.Logger
repo repository.Repository
}
func NewJournalEntries(logger mlogger.Logger, db *mongo.Database) (storage.JournalEntriesStore, error) {
repo := repository.CreateMongoRepository(db, model.JournalEntriesCollection)
// Create unique index on organizationRef + idempotencyKey
uniqueIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "organizationRef", Sort: ri.Asc},
{Field: "idempotencyKey", Sort: ri.Asc},
},
Unique: true,
}
if err := repo.CreateIndex(uniqueIndex); err != nil {
logger.Error("failed to ensure journal entries idempotency index", zap.Error(err))
return nil, err
}
// Create index on organizationRef for listing
orgIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "organizationRef", Sort: ri.Asc},
{Field: "createdAt", Sort: ri.Desc},
},
}
if err := repo.CreateIndex(orgIndex); err != nil {
logger.Error("failed to ensure journal entries organization index", zap.Error(err))
return nil, err
}
childLogger := logger.Named(model.JournalEntriesCollection)
childLogger.Debug("journal entries store initialised", zap.String("collection", model.JournalEntriesCollection))
return &journalEntriesStore{
logger: childLogger,
repo: repo,
}, nil
}
func (j *journalEntriesStore) Create(ctx context.Context, entry *model.JournalEntry) error {
if entry == nil {
j.logger.Warn("attempt to create nil journal entry")
return merrors.InvalidArgument("journalEntriesStore: nil journal entry")
}
if err := j.repo.Insert(ctx, entry, nil); err != nil {
if mongo.IsDuplicateKeyError(err) {
j.logger.Warn("duplicate idempotency key", zap.String("idempotencyKey", entry.IdempotencyKey))
return storage.ErrDuplicateIdempotency
}
j.logger.Warn("failed to create journal entry", zap.Error(err))
return err
}
j.logger.Debug("journal entry created", zap.String("idempotencyKey", entry.IdempotencyKey),
zap.String("entryType", string(entry.EntryType)))
return nil
}
func (j *journalEntriesStore) Get(ctx context.Context, entryRef primitive.ObjectID) (*model.JournalEntry, error) {
if entryRef.IsZero() {
j.logger.Warn("attempt to get journal entry with zero ID")
return nil, merrors.InvalidArgument("journalEntriesStore: zero entry ID")
}
result := &model.JournalEntry{}
if err := j.repo.Get(ctx, entryRef, result); err != nil {
if errors.Is(err, merrors.ErrNoData) {
j.logger.Debug("journal entry not found", zap.String("entryRef", entryRef.Hex()))
return nil, storage.ErrJournalEntryNotFound
}
j.logger.Warn("failed to get journal entry", zap.Error(err), zap.String("entryRef", entryRef.Hex()))
return nil, err
}
j.logger.Debug("journal entry loaded", zap.String("entryRef", entryRef.Hex()),
zap.String("idempotencyKey", result.IdempotencyKey))
return result, nil
}
func (j *journalEntriesStore) GetByIdempotencyKey(ctx context.Context, orgRef primitive.ObjectID, idempotencyKey string) (*model.JournalEntry, error) {
if orgRef.IsZero() {
j.logger.Warn("attempt to get journal entry with zero organization ID")
return nil, merrors.InvalidArgument("journalEntriesStore: zero organization ID")
}
if idempotencyKey == "" {
j.logger.Warn("attempt to get journal entry with empty idempotency key")
return nil, merrors.InvalidArgument("journalEntriesStore: empty idempotency key")
}
query := repository.Query().
Filter(repository.Field("organizationRef"), orgRef).
Filter(repository.Field("idempotencyKey"), idempotencyKey)
result := &model.JournalEntry{}
if err := j.repo.FindOneByFilter(ctx, query, result); err != nil {
if errors.Is(err, merrors.ErrNoData) {
j.logger.Debug("journal entry not found by idempotency key", zap.String("idempotencyKey", idempotencyKey))
return nil, storage.ErrJournalEntryNotFound
}
j.logger.Warn("failed to get journal entry by idempotency key", zap.Error(err),
zap.String("idempotencyKey", idempotencyKey))
return nil, err
}
j.logger.Debug("journal entry loaded by idempotency key", zap.String("idempotencyKey", idempotencyKey))
return result, nil
}
func (j *journalEntriesStore) ListByOrganization(ctx context.Context, orgRef primitive.ObjectID, limit int, offset int) ([]*model.JournalEntry, error) {
if orgRef.IsZero() {
j.logger.Warn("attempt to list journal entries with zero organization ID")
return nil, merrors.InvalidArgument("journalEntriesStore: zero organization ID")
}
limit64 := int64(limit)
offset64 := int64(offset)
query := repository.Query().
Filter(repository.Field("organizationRef"), orgRef).
Limit(&limit64).
Offset(&offset64).
Sort(repository.Field("createdAt"), false) // false = descending
entries := make([]*model.JournalEntry, 0)
err := j.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
doc := &model.JournalEntry{}
if err := cur.Decode(doc); err != nil {
return err
}
entries = append(entries, doc)
return nil
})
if err != nil {
j.logger.Warn("failed to list journal entries", zap.Error(err))
return nil, err
}
j.logger.Debug("listed journal entries", zap.Int("count", len(entries)))
return entries, nil
}

View File

@@ -0,0 +1,299 @@
package store
import (
"context"
"errors"
"testing"
"time"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/db/repository/builder"
rd "github.com/tech/sendico/pkg/db/repository/decoder"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
func TestJournalEntriesStore_Create(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("Success", func(t *testing.T) {
var insertedEntry *model.JournalEntry
stub := &repositoryStub{
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
insertedEntry = object.(*model.JournalEntry)
return nil
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
entry := &model.JournalEntry{
IdempotencyKey: "test-key-123",
EventTime: time.Now(),
EntryType: model.EntryTypeCredit,
Description: "Test invoice entry",
}
err := store.Create(ctx, entry)
require.NoError(t, err)
assert.NotNil(t, insertedEntry)
assert.Equal(t, "test-key-123", insertedEntry.IdempotencyKey)
assert.Equal(t, model.EntryTypeCredit, insertedEntry.EntryType)
})
t.Run("NilEntry", func(t *testing.T) {
stub := &repositoryStub{}
store := &journalEntriesStore{logger: logger, repo: stub}
err := store.Create(ctx, nil)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("DuplicateIdempotencyKey", func(t *testing.T) {
stub := &repositoryStub{
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
return mongo.WriteException{
WriteErrors: []mongo.WriteError{
{Code: 11000}, // Duplicate key error
},
}
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
entry := &model.JournalEntry{
IdempotencyKey: "duplicate-key",
EventTime: time.Now(),
}
err := store.Create(ctx, entry)
require.Error(t, err)
assert.True(t, errors.Is(err, storage.ErrDuplicateIdempotency))
})
t.Run("InsertError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
return expectedErr
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
entry := &model.JournalEntry{
IdempotencyKey: "test-key",
EventTime: time.Now(),
}
err := store.Create(ctx, entry)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
}
func TestJournalEntriesStore_Get(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("Success", func(t *testing.T) {
entryRef := primitive.NewObjectID()
stub := &repositoryStub{
GetFunc: func(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
entry := result.(*model.JournalEntry)
entry.SetID(entryRef)
entry.IdempotencyKey = "test-key-123"
entry.EntryType = model.EntryTypeDebit
return nil
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
result, err := store.Get(ctx, entryRef)
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, "test-key-123", result.IdempotencyKey)
assert.Equal(t, model.EntryTypeDebit, result.EntryType)
})
t.Run("ZeroID", func(t *testing.T) {
stub := &repositoryStub{}
store := &journalEntriesStore{logger: logger, repo: stub}
result, err := store.Get(ctx, primitive.NilObjectID)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("NotFound", func(t *testing.T) {
entryRef := primitive.NewObjectID()
stub := &repositoryStub{
GetFunc: func(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
return merrors.ErrNoData
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
result, err := store.Get(ctx, entryRef)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, storage.ErrJournalEntryNotFound))
})
}
func TestJournalEntriesStore_GetByIdempotencyKey(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
orgRef := primitive.NewObjectID()
t.Run("Success", func(t *testing.T) {
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
entry := result.(*model.JournalEntry)
entry.IdempotencyKey = "unique-key-123"
entry.EntryType = model.EntryTypeReverse
return nil
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
result, err := store.GetByIdempotencyKey(ctx, orgRef, "unique-key-123")
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, "unique-key-123", result.IdempotencyKey)
assert.Equal(t, model.EntryTypeReverse, result.EntryType)
})
t.Run("ZeroOrganizationID", func(t *testing.T) {
stub := &repositoryStub{}
store := &journalEntriesStore{logger: logger, repo: stub}
result, err := store.GetByIdempotencyKey(ctx, primitive.NilObjectID, "test-key")
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("EmptyIdempotencyKey", func(t *testing.T) {
stub := &repositoryStub{}
store := &journalEntriesStore{logger: logger, repo: stub}
result, err := store.GetByIdempotencyKey(ctx, orgRef, "")
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("NotFound", func(t *testing.T) {
stub := &repositoryStub{
FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error {
return merrors.ErrNoData
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
result, err := store.GetByIdempotencyKey(ctx, orgRef, "nonexistent-key")
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, storage.ErrJournalEntryNotFound))
})
}
func TestJournalEntriesStore_ListByOrganization(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
orgRef := primitive.NewObjectID()
t.Run("Success", func(t *testing.T) {
called := false
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
called = true
return nil
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
require.NoError(t, err)
assert.True(t, called)
assert.NotNil(t, results)
})
t.Run("ZeroOrganizationID", func(t *testing.T) {
stub := &repositoryStub{}
store := &journalEntriesStore{logger: logger, repo: stub}
results, err := store.ListByOrganization(ctx, primitive.NilObjectID, 10, 0)
require.Error(t, err)
assert.Nil(t, results)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("EmptyResult", func(t *testing.T) {
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return nil
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
require.NoError(t, err)
assert.Len(t, results, 0)
})
t.Run("WithPagination", func(t *testing.T) {
called := false
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
called = true
return nil
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
results, err := store.ListByOrganization(ctx, orgRef, 2, 1)
require.NoError(t, err)
assert.True(t, called)
assert.NotNil(t, results)
})
t.Run("FindError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return expectedErr
},
}
store := &journalEntriesStore{logger: logger, repo: stub}
results, err := store.ListByOrganization(ctx, orgRef, 10, 0)
require.Error(t, err)
assert.Nil(t, results)
assert.Equal(t, expectedErr, err)
})
}

View File

@@ -0,0 +1,155 @@
package store
import (
"context"
"time"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type outboxStore struct {
logger mlogger.Logger
repo repository.Repository
}
func NewOutbox(logger mlogger.Logger, db *mongo.Database) (storage.OutboxStore, error) {
repo := repository.CreateMongoRepository(db, model.OutboxCollection)
// Create index on status + createdAt for efficient pending query
statusIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "status", Sort: ri.Asc},
{Field: "createdAt", Sort: ri.Asc},
},
}
if err := repo.CreateIndex(statusIndex); err != nil {
logger.Error("failed to ensure outbox status index", zap.Error(err))
return nil, err
}
// Create unique index on eventId for deduplication
eventIdIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "eventId", Sort: ri.Asc},
},
Unique: true,
}
if err := repo.CreateIndex(eventIdIndex); err != nil {
logger.Error("failed to ensure outbox eventId index", zap.Error(err))
return nil, err
}
childLogger := logger.Named(model.OutboxCollection)
childLogger.Debug("outbox store initialised", zap.String("collection", model.OutboxCollection))
return &outboxStore{
logger: childLogger,
repo: repo,
}, nil
}
func (o *outboxStore) Create(ctx context.Context, event *model.OutboxEvent) error {
if event == nil {
o.logger.Warn("attempt to create nil outbox event")
return merrors.InvalidArgument("outboxStore: nil outbox event")
}
if err := o.repo.Insert(ctx, event, nil); err != nil {
if mongo.IsDuplicateKeyError(err) {
o.logger.Warn("duplicate event ID", zap.String("eventId", event.EventID))
return merrors.DataConflict("outbox event with this ID already exists")
}
o.logger.Warn("failed to create outbox event", zap.Error(err))
return err
}
o.logger.Debug("outbox event created", zap.String("eventId", event.EventID),
zap.String("subject", event.Subject))
return nil
}
func (o *outboxStore) ListPending(ctx context.Context, limit int) ([]*model.OutboxEvent, error) {
limit64 := int64(limit)
query := repository.Query().
Filter(repository.Field("status"), model.OutboxStatusPending).
Limit(&limit64).
Sort(repository.Field("createdAt"), true) // true = ascending (oldest first)
events := make([]*model.OutboxEvent, 0)
err := o.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
doc := &model.OutboxEvent{}
if err := cur.Decode(doc); err != nil {
return err
}
events = append(events, doc)
return nil
})
if err != nil {
o.logger.Warn("failed to list pending outbox events", zap.Error(err))
return nil, err
}
o.logger.Debug("listed pending outbox events", zap.Int("count", len(events)))
return events, nil
}
func (o *outboxStore) MarkSent(ctx context.Context, eventRef primitive.ObjectID, sentAt time.Time) error {
if eventRef.IsZero() {
o.logger.Warn("attempt to mark sent with zero event ID")
return merrors.InvalidArgument("outboxStore: zero event ID")
}
patch := repository.Patch().
Set(repository.Field("status"), model.OutboxStatusSent).
Set(repository.Field("sentAt"), sentAt)
if err := o.repo.Patch(ctx, eventRef, patch); err != nil {
o.logger.Warn("failed to mark outbox event as sent", zap.Error(err), zap.String("eventRef", eventRef.Hex()))
return err
}
o.logger.Debug("outbox event marked as sent", zap.String("eventRef", eventRef.Hex()))
return nil
}
func (o *outboxStore) MarkFailed(ctx context.Context, eventRef primitive.ObjectID) error {
if eventRef.IsZero() {
o.logger.Warn("attempt to mark failed with zero event ID")
return merrors.InvalidArgument("outboxStore: zero event ID")
}
patch := repository.Patch().Set(repository.Field("status"), model.OutboxStatusFailed)
if err := o.repo.Patch(ctx, eventRef, patch); err != nil {
o.logger.Warn("failed to mark outbox event as failed", zap.Error(err), zap.String("eventRef", eventRef.Hex()))
return err
}
o.logger.Debug("outbox event marked as failed", zap.String("eventRef", eventRef.Hex()))
return nil
}
func (o *outboxStore) IncrementAttempts(ctx context.Context, eventRef primitive.ObjectID) error {
if eventRef.IsZero() {
o.logger.Warn("attempt to increment attempts with zero event ID")
return merrors.InvalidArgument("outboxStore: zero event ID")
}
patch := repository.Patch().Inc(repository.Field("attempts"), 1)
if err := o.repo.Patch(ctx, eventRef, patch); err != nil {
o.logger.Warn("failed to increment outbox attempts", zap.Error(err), zap.String("eventRef", eventRef.Hex()))
return err
}
o.logger.Debug("outbox attempts incremented", zap.String("eventRef", eventRef.Hex()))
return nil
}

View File

@@ -0,0 +1,336 @@
package store
import (
"context"
"errors"
"testing"
"time"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
rd "github.com/tech/sendico/pkg/db/repository/decoder"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
func TestOutboxStore_Create(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("Success", func(t *testing.T) {
var insertedEvent *model.OutboxEvent
stub := &repositoryStub{
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
insertedEvent = object.(*model.OutboxEvent)
return nil
},
}
store := &outboxStore{logger: logger, repo: stub}
event := &model.OutboxEvent{
EventID: "evt_12345",
Subject: "ledger.entry.created",
Payload: []byte(`{"entryId":"123"}`),
Status: model.OutboxStatusPending,
}
err := store.Create(ctx, event)
require.NoError(t, err)
assert.NotNil(t, insertedEvent)
assert.Equal(t, "evt_12345", insertedEvent.EventID)
assert.Equal(t, "ledger.entry.created", insertedEvent.Subject)
assert.Equal(t, model.OutboxStatusPending, insertedEvent.Status)
})
t.Run("NilEvent", func(t *testing.T) {
stub := &repositoryStub{}
store := &outboxStore{logger: logger, repo: stub}
err := store.Create(ctx, nil)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("DuplicateEventID", func(t *testing.T) {
stub := &repositoryStub{
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
return mongo.WriteException{
WriteErrors: []mongo.WriteError{
{Code: 11000}, // Duplicate key error
},
}
},
}
store := &outboxStore{logger: logger, repo: stub}
event := &model.OutboxEvent{
EventID: "duplicate_event",
Subject: "test.subject",
Status: model.OutboxStatusPending,
}
err := store.Create(ctx, event)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrDataConflict))
})
t.Run("InsertError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
InsertFunc: func(ctx context.Context, object storable.Storable, _ builder.Query) error {
return expectedErr
},
}
store := &outboxStore{logger: logger, repo: stub}
event := &model.OutboxEvent{
EventID: "evt_123",
Subject: "test.subject",
}
err := store.Create(ctx, event)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
}
func TestOutboxStore_ListPending(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("Success", func(t *testing.T) {
called := false
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
called = true
return nil
},
}
store := &outboxStore{logger: logger, repo: stub}
results, err := store.ListPending(ctx, 10)
require.NoError(t, err)
assert.True(t, called)
assert.NotNil(t, results)
})
t.Run("EmptyResult", func(t *testing.T) {
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return nil
},
}
store := &outboxStore{logger: logger, repo: stub}
results, err := store.ListPending(ctx, 10)
require.NoError(t, err)
assert.Len(t, results, 0)
})
t.Run("WithLimit", func(t *testing.T) {
called := false
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
called = true
return nil
},
}
store := &outboxStore{logger: logger, repo: stub}
results, err := store.ListPending(ctx, 3)
require.NoError(t, err)
assert.True(t, called)
assert.NotNil(t, results)
})
t.Run("FindError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return expectedErr
},
}
store := &outboxStore{logger: logger, repo: stub}
results, err := store.ListPending(ctx, 10)
require.Error(t, err)
assert.Nil(t, results)
assert.Equal(t, expectedErr, err)
})
}
func TestOutboxStore_MarkSent(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
eventRef := primitive.NewObjectID()
sentTime := time.Now()
t.Run("Success", func(t *testing.T) {
var patchedID primitive.ObjectID
stub := &repositoryStub{
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
patchedID = id
return nil
},
}
store := &outboxStore{logger: logger, repo: stub}
err := store.MarkSent(ctx, eventRef, sentTime)
require.NoError(t, err)
assert.Equal(t, eventRef, patchedID)
})
t.Run("ZeroEventID", func(t *testing.T) {
stub := &repositoryStub{}
store := &outboxStore{logger: logger, repo: stub}
err := store.MarkSent(ctx, primitive.NilObjectID, sentTime)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("PatchError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
return expectedErr
},
}
store := &outboxStore{logger: logger, repo: stub}
err := store.MarkSent(ctx, eventRef, sentTime)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
}
func TestOutboxStore_MarkFailed(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
eventRef := primitive.NewObjectID()
t.Run("Success", func(t *testing.T) {
var patchedID primitive.ObjectID
stub := &repositoryStub{
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
patchedID = id
return nil
},
}
store := &outboxStore{logger: logger, repo: stub}
err := store.MarkFailed(ctx, eventRef)
require.NoError(t, err)
assert.Equal(t, eventRef, patchedID)
})
t.Run("ZeroEventID", func(t *testing.T) {
stub := &repositoryStub{}
store := &outboxStore{logger: logger, repo: stub}
err := store.MarkFailed(ctx, primitive.NilObjectID)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("PatchError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
return expectedErr
},
}
store := &outboxStore{logger: logger, repo: stub}
err := store.MarkFailed(ctx, eventRef)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
}
func TestOutboxStore_IncrementAttempts(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
eventRef := primitive.NewObjectID()
t.Run("Success", func(t *testing.T) {
var patchedID primitive.ObjectID
stub := &repositoryStub{
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
patchedID = id
return nil
},
}
store := &outboxStore{logger: logger, repo: stub}
err := store.IncrementAttempts(ctx, eventRef)
require.NoError(t, err)
assert.Equal(t, eventRef, patchedID)
})
t.Run("ZeroEventID", func(t *testing.T) {
stub := &repositoryStub{}
store := &outboxStore{logger: logger, repo: stub}
err := store.IncrementAttempts(ctx, primitive.NilObjectID)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("PatchError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
return expectedErr
},
}
store := &outboxStore{logger: logger, repo: stub}
err := store.IncrementAttempts(ctx, eventRef)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
t.Run("MultipleIncrements", func(t *testing.T) {
var callCount int
stub := &repositoryStub{
PatchFunc: func(ctx context.Context, id primitive.ObjectID, _ repository.PatchDoc) error {
callCount++
return nil
},
}
store := &outboxStore{logger: logger, repo: stub}
// Simulate multiple retry attempts
for i := 0; i < 3; i++ {
err := store.IncrementAttempts(ctx, eventRef)
require.NoError(t, err)
}
assert.Equal(t, 3, callCount)
})
}

View File

@@ -0,0 +1,138 @@
package store
import (
"context"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type postingLinesStore struct {
logger mlogger.Logger
repo repository.Repository
}
func NewPostingLines(logger mlogger.Logger, db *mongo.Database) (storage.PostingLinesStore, error) {
repo := repository.CreateMongoRepository(db, model.PostingLinesCollection)
// Create index on journalEntryRef for fast lookup by entry
entryIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "journalEntryRef", Sort: ri.Asc},
},
}
if err := repo.CreateIndex(entryIndex); err != nil {
logger.Error("failed to ensure posting lines entry index", zap.Error(err))
return nil, err
}
// Create index on accountRef for account statement queries
accountIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "accountRef", Sort: ri.Asc},
{Field: "createdAt", Sort: ri.Desc},
},
}
if err := repo.CreateIndex(accountIndex); err != nil {
logger.Error("failed to ensure posting lines account index", zap.Error(err))
return nil, err
}
childLogger := logger.Named(model.PostingLinesCollection)
childLogger.Debug("posting lines store initialised", zap.String("collection", model.PostingLinesCollection))
return &postingLinesStore{
logger: childLogger,
repo: repo,
}, nil
}
func (p *postingLinesStore) CreateMany(ctx context.Context, lines []*model.PostingLine) error {
if len(lines) == 0 {
p.logger.Warn("attempt to create empty posting lines array")
return nil
}
storables := make([]storable.Storable, len(lines))
for i, line := range lines {
if line == nil {
p.logger.Warn("attempt to create nil posting line")
return merrors.InvalidArgument("postingLinesStore: nil posting line")
}
storables[i] = line
}
if err := p.repo.InsertMany(ctx, storables); err != nil {
p.logger.Warn("failed to create posting lines", zap.Error(err), zap.Int("count", len(lines)))
return err
}
p.logger.Debug("posting lines created", zap.Int("count", len(lines)))
return nil
}
func (p *postingLinesStore) ListByJournalEntry(ctx context.Context, entryRef primitive.ObjectID) ([]*model.PostingLine, error) {
if entryRef.IsZero() {
p.logger.Warn("attempt to list posting lines with zero entry ID")
return nil, merrors.InvalidArgument("postingLinesStore: zero entry ID")
}
query := repository.Filter("journalEntryRef", entryRef)
lines := make([]*model.PostingLine, 0)
err := p.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
doc := &model.PostingLine{}
if err := cur.Decode(doc); err != nil {
return err
}
lines = append(lines, doc)
return nil
})
if err != nil {
p.logger.Warn("failed to list posting lines by entry", zap.Error(err), zap.String("entryRef", entryRef.Hex()))
return nil, err
}
p.logger.Debug("listed posting lines by entry", zap.Int("count", len(lines)), zap.String("entryRef", entryRef.Hex()))
return lines, nil
}
func (p *postingLinesStore) ListByAccount(ctx context.Context, accountRef primitive.ObjectID, limit int, offset int) ([]*model.PostingLine, error) {
if accountRef.IsZero() {
p.logger.Warn("attempt to list posting lines with zero account ID")
return nil, merrors.InvalidArgument("postingLinesStore: zero account ID")
}
limit64 := int64(limit)
offset64 := int64(offset)
query := repository.Query().
Filter(repository.Field("accountRef"), accountRef).
Limit(&limit64).
Offset(&offset64).
Sort(repository.Field("createdAt"), false) // false = descending
lines := make([]*model.PostingLine, 0)
err := p.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
doc := &model.PostingLine{}
if err := cur.Decode(doc); err != nil {
return err
}
lines = append(lines, doc)
return nil
})
if err != nil {
p.logger.Warn("failed to list posting lines by account", zap.Error(err), zap.String("accountRef", accountRef.Hex()))
return nil, err
}
p.logger.Debug("listed posting lines by account", zap.Int("count", len(lines)), zap.String("accountRef", accountRef.Hex()))
return lines, nil
}

View File

@@ -0,0 +1,276 @@
package store
import (
"context"
"errors"
"testing"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/db/repository/builder"
rd "github.com/tech/sendico/pkg/db/repository/decoder"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func TestPostingLinesStore_CreateMany(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("Success", func(t *testing.T) {
var insertedLines []storable.Storable
stub := &repositoryStub{
InsertManyFunc: func(ctx context.Context, objects []storable.Storable) error {
insertedLines = objects
return nil
},
}
store := &postingLinesStore{logger: logger, repo: stub}
lines := []*model.PostingLine{
{
JournalEntryRef: primitive.NewObjectID(),
AccountRef: primitive.NewObjectID(),
LineType: model.LineTypeMain,
Amount: "100.00",
},
{
JournalEntryRef: primitive.NewObjectID(),
AccountRef: primitive.NewObjectID(),
LineType: model.LineTypeMain,
Amount: "100.00",
},
}
err := store.CreateMany(ctx, lines)
require.NoError(t, err)
assert.Len(t, insertedLines, 2)
})
t.Run("EmptyArray", func(t *testing.T) {
stub := &repositoryStub{}
store := &postingLinesStore{logger: logger, repo: stub}
err := store.CreateMany(ctx, []*model.PostingLine{})
require.NoError(t, err) // Should not error on empty array
})
t.Run("NilLine", func(t *testing.T) {
stub := &repositoryStub{}
store := &postingLinesStore{logger: logger, repo: stub}
lines := []*model.PostingLine{
{Amount: "100.00"},
nil,
}
err := store.CreateMany(ctx, lines)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("InsertManyError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
InsertManyFunc: func(ctx context.Context, objects []storable.Storable) error {
return expectedErr
},
}
store := &postingLinesStore{logger: logger, repo: stub}
lines := []*model.PostingLine{
{Amount: "100.00"},
}
err := store.CreateMany(ctx, lines)
require.Error(t, err)
assert.Equal(t, expectedErr, err)
})
t.Run("BalancedEntry", func(t *testing.T) {
var insertedLines []storable.Storable
stub := &repositoryStub{
InsertManyFunc: func(ctx context.Context, objects []storable.Storable) error {
insertedLines = objects
return nil
},
}
store := &postingLinesStore{logger: logger, repo: stub}
entryRef := primitive.NewObjectID()
cashAccount := primitive.NewObjectID()
revenueAccount := primitive.NewObjectID()
lines := []*model.PostingLine{
{
JournalEntryRef: entryRef,
AccountRef: cashAccount,
LineType: model.LineTypeMain,
Amount: "500.00",
},
{
JournalEntryRef: entryRef,
AccountRef: revenueAccount,
LineType: model.LineTypeMain,
Amount: "500.00",
},
}
err := store.CreateMany(ctx, lines)
require.NoError(t, err)
assert.Len(t, insertedLines, 2)
})
}
func TestPostingLinesStore_ListByJournalEntry(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
entryRef := primitive.NewObjectID()
t.Run("Success", func(t *testing.T) {
called := false
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
called = true
return nil
},
}
store := &postingLinesStore{logger: logger, repo: stub}
results, err := store.ListByJournalEntry(ctx, entryRef)
require.NoError(t, err)
assert.True(t, called)
assert.NotNil(t, results)
})
t.Run("ZeroEntryID", func(t *testing.T) {
stub := &repositoryStub{}
store := &postingLinesStore{logger: logger, repo: stub}
results, err := store.ListByJournalEntry(ctx, primitive.NilObjectID)
require.Error(t, err)
assert.Nil(t, results)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("EmptyResult", func(t *testing.T) {
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return nil
},
}
store := &postingLinesStore{logger: logger, repo: stub}
results, err := store.ListByJournalEntry(ctx, entryRef)
require.NoError(t, err)
assert.Len(t, results, 0)
})
t.Run("FindError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return expectedErr
},
}
store := &postingLinesStore{logger: logger, repo: stub}
results, err := store.ListByJournalEntry(ctx, entryRef)
require.Error(t, err)
assert.Nil(t, results)
assert.Equal(t, expectedErr, err)
})
}
func TestPostingLinesStore_ListByAccount(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
accountRef := primitive.NewObjectID()
t.Run("Success", func(t *testing.T) {
called := false
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
called = true
return nil
},
}
store := &postingLinesStore{logger: logger, repo: stub}
results, err := store.ListByAccount(ctx, accountRef, 10, 0)
require.NoError(t, err)
assert.True(t, called)
assert.NotNil(t, results)
})
t.Run("ZeroAccountID", func(t *testing.T) {
stub := &repositoryStub{}
store := &postingLinesStore{logger: logger, repo: stub}
results, err := store.ListByAccount(ctx, primitive.NilObjectID, 10, 0)
require.Error(t, err)
assert.Nil(t, results)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("WithPagination", func(t *testing.T) {
called := false
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
called = true
return nil
},
}
store := &postingLinesStore{logger: logger, repo: stub}
results, err := store.ListByAccount(ctx, accountRef, 2, 2)
require.NoError(t, err)
assert.True(t, called)
assert.NotNil(t, results)
})
t.Run("EmptyResult", func(t *testing.T) {
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return nil
},
}
store := &postingLinesStore{logger: logger, repo: stub}
results, err := store.ListByAccount(ctx, accountRef, 10, 0)
require.NoError(t, err)
assert.Len(t, results, 0)
})
t.Run("FindError", func(t *testing.T) {
expectedErr := errors.New("database error")
stub := &repositoryStub{
FindManyByFilterFunc: func(ctx context.Context, _ builder.Query, decoder rd.DecodingFunc) error {
return expectedErr
},
}
store := &postingLinesStore{logger: logger, repo: stub}
results, err := store.ListByAccount(ctx, accountRef, 10, 0)
require.Error(t, err)
assert.Nil(t, results)
assert.Equal(t, expectedErr, err)
})
}

View File

@@ -0,0 +1,137 @@
package store
import (
"context"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
rd "github.com/tech/sendico/pkg/db/repository/decoder"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// repositoryStub provides a stub implementation of repository.Repository for testing
type repositoryStub struct {
AggregateFunc func(ctx context.Context, pipeline builder.Pipeline, decoder rd.DecodingFunc) error
GetFunc func(ctx context.Context, id primitive.ObjectID, result storable.Storable) error
InsertFunc func(ctx context.Context, object storable.Storable, filter builder.Query) error
InsertManyFunc func(ctx context.Context, objects []storable.Storable) error
UpdateFunc func(ctx context.Context, object storable.Storable) error
DeleteFunc func(ctx context.Context, id primitive.ObjectID) error
FindOneByFilterFunc func(ctx context.Context, filter builder.Query, result storable.Storable) error
FindManyByFilterFunc func(ctx context.Context, filter builder.Query, decoder rd.DecodingFunc) error
PatchFunc func(ctx context.Context, id primitive.ObjectID, patch repository.PatchDoc) error
PatchManyFunc func(ctx context.Context, filter repository.FilterQuery, patch repository.PatchDoc) (int, error)
DeleteManyFunc func(ctx context.Context, query builder.Query) error
ListIDsFunc func(ctx context.Context, query builder.Query) ([]primitive.ObjectID, error)
CreateIndexFunc func(def *ri.Definition) error
}
func (r *repositoryStub) Aggregate(ctx context.Context, pipeline builder.Pipeline, decoder rd.DecodingFunc) error {
if r.AggregateFunc != nil {
return r.AggregateFunc(ctx, pipeline, decoder)
}
return nil
}
func (r *repositoryStub) Get(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
if r.GetFunc != nil {
return r.GetFunc(ctx, id, result)
}
return nil
}
func (r *repositoryStub) Insert(ctx context.Context, object storable.Storable, filter builder.Query) error {
if r.InsertFunc != nil {
return r.InsertFunc(ctx, object, filter)
}
return nil
}
func (r *repositoryStub) InsertMany(ctx context.Context, objects []storable.Storable) error {
if r.InsertManyFunc != nil {
return r.InsertManyFunc(ctx, objects)
}
return nil
}
func (r *repositoryStub) Update(ctx context.Context, object storable.Storable) error {
if r.UpdateFunc != nil {
return r.UpdateFunc(ctx, object)
}
return nil
}
func (r *repositoryStub) Delete(ctx context.Context, id primitive.ObjectID) error {
if r.DeleteFunc != nil {
return r.DeleteFunc(ctx, id)
}
return nil
}
func (r *repositoryStub) FindOneByFilter(ctx context.Context, filter builder.Query, result storable.Storable) error {
if r.FindOneByFilterFunc != nil {
return r.FindOneByFilterFunc(ctx, filter, result)
}
return nil
}
func (r *repositoryStub) FindManyByFilter(ctx context.Context, filter builder.Query, decoder rd.DecodingFunc) error {
if r.FindManyByFilterFunc != nil {
return r.FindManyByFilterFunc(ctx, filter, decoder)
}
return nil
}
func (r *repositoryStub) Patch(ctx context.Context, id primitive.ObjectID, patch repository.PatchDoc) error {
if r.PatchFunc != nil {
return r.PatchFunc(ctx, id, patch)
}
return nil
}
func (r *repositoryStub) PatchMany(ctx context.Context, filter repository.FilterQuery, patch repository.PatchDoc) (int, error) {
if r.PatchManyFunc != nil {
return r.PatchManyFunc(ctx, filter, patch)
}
return 0, nil
}
func (r *repositoryStub) DeleteMany(ctx context.Context, query builder.Query) error {
if r.DeleteManyFunc != nil {
return r.DeleteManyFunc(ctx, query)
}
return nil
}
func (r *repositoryStub) ListIDs(ctx context.Context, query builder.Query) ([]primitive.ObjectID, error) {
if r.ListIDsFunc != nil {
return r.ListIDsFunc(ctx, query)
}
return nil, nil
}
func (r *repositoryStub) ListPermissionBound(ctx context.Context, query builder.Query) ([]model.PermissionBoundStorable, error) {
return nil, nil
}
func (r *repositoryStub) ListAccountBound(ctx context.Context, query builder.Query) ([]model.AccountBoundStorable, error) {
return nil, nil
}
func (r *repositoryStub) Collection() string {
return "test_collection"
}
func (r *repositoryStub) CreateIndex(def *ri.Definition) error {
if r.CreateIndexFunc != nil {
return r.CreateIndexFunc(def)
}
return nil
}
// Note: For unit tests with FindManyByFilter, we don't simulate the full cursor iteration
// since we can't easily mock *mongo.Cursor. These tests verify that the store calls the
// repository correctly. Integration tests with real MongoDB test the actual iteration logic.