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,113 @@
package store
import (
"context"
"errors"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/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/mongo"
"go.uber.org/zap"
)
type currencyStore struct {
logger mlogger.Logger
repo repository.Repository
}
func NewCurrency(logger mlogger.Logger, db *mongo.Database) (storage.CurrencyStore, error) {
repo := repository.CreateMongoRepository(db, model.CurrenciesCollection)
index := &ri.Definition{
Keys: []ri.Key{
{Field: "code", Sort: ri.Asc},
},
Unique: true,
}
if err := repo.CreateIndex(index); err != nil {
logger.Error("failed to ensure currencies index", zap.Error(err))
return nil, err
}
childLogger := logger.Named(model.CurrenciesCollection)
childLogger.Debug("currency store initialised", zap.String("collection", model.CurrenciesCollection))
return &currencyStore{
logger: childLogger,
repo: repo,
}, nil
}
func (c *currencyStore) Get(ctx context.Context, code string) (*model.Currency, error) {
if code == "" {
c.logger.Warn("attempt to fetch currency with empty code")
return nil, merrors.InvalidArgument("currencyStore: empty code")
}
result := &model.Currency{}
if err := c.repo.FindOneByFilter(ctx, repository.Filter("code", code), result); err != nil {
if errors.Is(err, merrors.ErrNoData) {
c.logger.Debug("currency not found", zap.String("code", code))
}
return nil, err
}
c.logger.Debug("currency loaded", zap.String("code", code))
return result, nil
}
func (c *currencyStore) List(ctx context.Context, codes ...string) ([]*model.Currency, error) {
query := repository.Query()
if len(codes) > 0 {
values := make([]any, len(codes))
for i, code := range codes {
values[i] = code
}
query = query.In(repository.Field("code"), values...)
}
currencies := make([]*model.Currency, 0)
err := c.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
doc := &model.Currency{}
if err := cur.Decode(doc); err != nil {
return err
}
currencies = append(currencies, doc)
return nil
})
if err != nil {
c.logger.Error("failed to list currencies", zap.Error(err))
return nil, err
}
c.logger.Debug("listed currencies", zap.Int("count", len(currencies)))
return currencies, nil
}
func (c *currencyStore) Upsert(ctx context.Context, currency *model.Currency) error {
if currency == nil {
c.logger.Warn("attempt to upsert nil currency")
return merrors.InvalidArgument("currencyStore: nil currency")
}
if currency.Code == "" {
c.logger.Warn("attempt to upsert currency with empty code")
return merrors.InvalidArgument("currencyStore: empty code")
}
existing := &model.Currency{}
filter := repository.Filter("code", currency.Code)
if err := c.repo.FindOneByFilter(ctx, filter, existing); err != nil {
if errors.Is(err, merrors.ErrNoData) {
c.logger.Debug("inserting new currency", zap.String("code", currency.Code))
return c.repo.Insert(ctx, currency, filter)
}
c.logger.Error("failed to fetch currency", zap.Error(err), zap.String("code", currency.Code))
return err
}
if existing.GetID() != nil {
currency.SetID(*existing.GetID())
}
c.logger.Debug("updating currency", zap.String("code", currency.Code))
return c.repo.Update(ctx, currency)
}

View File

@@ -0,0 +1,104 @@
package store
import (
"context"
"errors"
"testing"
"github.com/tech/sendico/fx/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"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func TestCurrencyStoreGet(t *testing.T) {
repo := &repoStub{
findOneFn: func(_ context.Context, _ builder.Query, result storable.Storable) error {
currency := result.(*model.Currency)
currency.Code = "USD"
return nil
},
}
store := &currencyStore{logger: zap.NewNop(), repo: repo}
res, err := store.Get(context.Background(), "USD")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.Code != "USD" {
t.Fatalf("unexpected code: %s", res.Code)
}
}
func TestCurrencyStoreList(t *testing.T) {
repo := &repoStub{
findManyFn: func(_ context.Context, _ builder.Query, decode rd.DecodingFunc) error {
return runDecoderWithDocs(t, decode, &model.Currency{Code: "USD"})
},
}
store := &currencyStore{logger: zap.NewNop(), repo: repo}
currencies, err := store.List(context.Background(), "USD")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(currencies) != 1 || currencies[0].Code != "USD" {
t.Fatalf("unexpected list result: %+v", currencies)
}
}
func TestCurrencyStoreUpsertInsert(t *testing.T) {
inserted := false
repo := &repoStub{
findOneFn: func(context.Context, builder.Query, storable.Storable) error {
return merrors.ErrNoData
},
insertFn: func(_ context.Context, obj storable.Storable, _ builder.Query) error {
_ = cloneCurrency(t, obj)
inserted = true
return nil
},
}
store := &currencyStore{logger: zap.NewNop(), repo: repo}
if err := store.Upsert(context.Background(), &model.Currency{Code: "USD"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !inserted {
t.Fatalf("expected insert to be called")
}
}
func TestCurrencyStoreGetInvalid(t *testing.T) {
store := &currencyStore{logger: zap.NewNop(), repo: &repoStub{}}
if _, err := store.Get(context.Background(), ""); !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument error")
}
}
func TestCurrencyStoreUpsertUpdate(t *testing.T) {
var updated *model.Currency
repo := &repoStub{
findOneFn: func(_ context.Context, _ builder.Query, result storable.Storable) error {
currency := result.(*model.Currency)
currency.SetID(primitive.NewObjectID())
currency.Code = "USD"
return nil
},
updateFn: func(_ context.Context, obj storable.Storable) error {
updated = cloneCurrency(t, obj)
return nil
},
}
store := &currencyStore{logger: zap.NewNop(), repo: repo}
if err := store.Upsert(context.Background(), &model.Currency{Code: "USD"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated == nil || updated.GetID() == nil {
t.Fatalf("expected update to preserve ID")
}
}

View File

@@ -0,0 +1,111 @@
package store
import (
"context"
"errors"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/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/mongo"
"go.uber.org/zap"
)
type pairStore struct {
logger mlogger.Logger
repo repository.Repository
}
func NewPair(logger mlogger.Logger, db *mongo.Database) (storage.PairStore, error) {
repo := repository.CreateMongoRepository(db, model.PairsCollection)
index := &ri.Definition{
Keys: []ri.Key{
{Field: "pair.base", Sort: ri.Asc},
{Field: "pair.quote", Sort: ri.Asc},
},
Unique: true,
}
if err := repo.CreateIndex(index); err != nil {
logger.Error("failed to ensure pairs index", zap.Error(err))
return nil, err
}
logger.Debug("pair store initialised", zap.String("collection", model.PairsCollection))
return &pairStore{
logger: logger.Named(model.PairsCollection),
repo: repo,
}, nil
}
func (p *pairStore) ListEnabled(ctx context.Context) ([]*model.Pair, error) {
filter := repository.Query().Filter(repository.Field("isEnabled"), true)
pairs := make([]*model.Pair, 0)
err := p.repo.FindManyByFilter(ctx, filter, func(cur *mongo.Cursor) error {
doc := &model.Pair{}
if err := cur.Decode(doc); err != nil {
return err
}
pairs = append(pairs, doc)
return nil
})
if err != nil {
p.logger.Error("failed to list enabled pairs", zap.Error(err))
return nil, err
}
p.logger.Debug("listed enabled pairs", zap.Int("count", len(pairs)))
return pairs, nil
}
func (p *pairStore) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
if pair.Base == "" || pair.Quote == "" {
p.logger.Warn("attempt to fetch pair with empty currency", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
return nil, merrors.InvalidArgument("pairStore: incomplete pair")
}
result := &model.Pair{}
query := repository.Query().
Filter(repository.Field("pair").Dot("base"), pair.Base).
Filter(repository.Field("pair").Dot("quote"), pair.Quote)
if err := p.repo.FindOneByFilter(ctx, query, result); err != nil {
if errors.Is(err, merrors.ErrNoData) {
p.logger.Debug("pair not found", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
}
return nil, err
}
p.logger.Debug("pair loaded", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
return result, nil
}
func (p *pairStore) Upsert(ctx context.Context, pair *model.Pair) error {
if pair == nil {
p.logger.Warn("attempt to upsert nil pair")
return merrors.InvalidArgument("pairStore: nil pair")
}
if pair.Pair.Base == "" || pair.Pair.Quote == "" {
p.logger.Warn("attempt to upsert pair with empty currency", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
return merrors.InvalidArgument("pairStore: incomplete pair")
}
existing := &model.Pair{}
query := repository.Query().
Filter(repository.Field("pair").Dot("base"), pair.Pair.Base).
Filter(repository.Field("pair").Dot("quote"), pair.Pair.Quote)
err := p.repo.FindOneByFilter(ctx, query, existing)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
p.logger.Debug("inserting new pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
return p.repo.Insert(ctx, pair, query)
}
p.logger.Error("failed to fetch pair", zap.Error(err), zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
return err
}
if existing.GetID() != nil {
pair.SetID(*existing.GetID())
}
p.logger.Debug("updating pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
return p.repo.Update(ctx, pair)
}

View File

@@ -0,0 +1,101 @@
package store
import (
"context"
"errors"
"testing"
"github.com/tech/sendico/fx/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"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func TestPairStoreListEnabled(t *testing.T) {
repo := &repoStub{
findManyFn: func(_ context.Context, _ builder.Query, decode rd.DecodingFunc) error {
docs := []interface{}{
&model.Pair{Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}},
}
return runDecoderWithDocs(t, decode, docs...)
},
}
store := &pairStore{logger: zap.NewNop(), repo: repo}
pairs, err := store.ListEnabled(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(pairs) != 1 || pairs[0].Pair.Base != "USD" {
t.Fatalf("unexpected pairs result: %+v", pairs)
}
}
func TestPairStoreGetInvalid(t *testing.T) {
store := &pairStore{logger: zap.NewNop(), repo: &repoStub{}}
if _, err := store.Get(context.Background(), model.CurrencyPair{}); !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument error")
}
}
func TestPairStoreGetNotFound(t *testing.T) {
repo := &repoStub{
findOneFn: func(context.Context, builder.Query, storable.Storable) error {
return merrors.ErrNoData
},
}
store := &pairStore{logger: zap.NewNop(), repo: repo}
if _, err := store.Get(context.Background(), model.CurrencyPair{Base: "USD", Quote: "EUR"}); !errors.Is(err, merrors.ErrNoData) {
t.Fatalf("expected ErrNoData, got %v", err)
}
}
func TestPairStoreUpsertInsert(t *testing.T) {
ctx := context.Background()
var inserted *model.Pair
repo := &repoStub{
findOneFn: func(context.Context, builder.Query, storable.Storable) error {
return merrors.ErrNoData
},
insertFn: func(_ context.Context, obj storable.Storable, _ builder.Query) error {
inserted = clonePair(t, obj)
return nil
},
}
store := &pairStore{logger: zap.NewNop(), repo: repo}
pair := &model.Pair{Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}}
if err := store.Upsert(ctx, pair); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if inserted == nil {
t.Fatalf("expected insert to be called")
}
}
func TestPairStoreUpsertUpdate(t *testing.T) {
ctx := context.Background()
var updated *model.Pair
repo := &repoStub{
findOneFn: func(_ context.Context, _ builder.Query, result storable.Storable) error {
pair := result.(*model.Pair)
pair.SetID(primitive.NewObjectID())
return nil
},
updateFn: func(_ context.Context, obj storable.Storable) error {
updated = clonePair(t, obj)
return nil
},
}
store := &pairStore{logger: zap.NewNop(), repo: repo}
if err := store.Upsert(ctx, &model.Pair{Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated == nil || updated.GetID() == nil {
t.Fatalf("expected update to preserve existing ID")
}
}

View File

@@ -0,0 +1,198 @@
package store
import (
"context"
"errors"
"time"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/db/transaction"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type quotesStore struct {
logger mlogger.Logger
repo repository.Repository
txFactory transaction.Factory
}
func NewQuotes(logger mlogger.Logger, db *mongo.Database, txFactory transaction.Factory) (storage.QuotesStore, error) {
repo := repository.CreateMongoRepository(db, model.QuotesCollection)
indexes := []*ri.Definition{
{
Keys: []ri.Key{
{Field: "quoteRef", Sort: ri.Asc},
},
Unique: true,
},
{
Keys: []ri.Key{
{Field: "status", Sort: ri.Asc},
{Field: "expiresAtUnixMs", Sort: ri.Asc},
},
},
{
Keys: []ri.Key{
{Field: "consumedByLedgerTxnRef", Sort: ri.Asc},
},
},
}
ttlSeconds := int32(0)
indexes = append(indexes, &ri.Definition{
Keys: []ri.Key{
{Field: "expiresAt", Sort: ri.Asc},
},
TTL: &ttlSeconds,
Name: "quotes_expires_at_ttl",
})
for _, def := range indexes {
if err := repo.CreateIndex(def); err != nil {
logger.Error("failed to ensure quotes index", zap.Error(err))
return nil, err
}
}
childLogger := logger.Named(model.QuotesCollection)
childLogger.Debug("quotes store initialised", zap.String("collection", model.QuotesCollection))
return &quotesStore{
logger: childLogger,
repo: repo,
txFactory: txFactory,
}, nil
}
func (q *quotesStore) Issue(ctx context.Context, quote *model.Quote) error {
if quote == nil {
q.logger.Warn("attempt to issue nil quote")
return merrors.InvalidArgument("quotesStore: nil quote")
}
if quote.QuoteRef == "" {
q.logger.Warn("attempt to issue quote with empty ref")
return merrors.InvalidArgument("quotesStore: empty quoteRef")
}
if quote.ExpiresAtUnixMs > 0 && quote.ExpiresAt == nil {
expiry := time.UnixMilli(quote.ExpiresAtUnixMs)
quote.ExpiresAt = &expiry
}
quote.Status = model.QuoteStatusIssued
quote.ConsumedByLedgerTxnRef = ""
quote.ConsumedAtUnixMs = nil
if err := q.repo.Insert(ctx, quote, repository.Filter("quoteRef", quote.QuoteRef)); err != nil {
q.logger.Error("failed to insert quote", zap.Error(err), zap.String("quote_ref", quote.QuoteRef))
return err
}
q.logger.Debug("quote issued", zap.String("quote_ref", quote.QuoteRef), zap.Bool("firm", quote.Firm))
return nil
}
func (q *quotesStore) GetByRef(ctx context.Context, quoteRef string) (*model.Quote, error) {
if quoteRef == "" {
q.logger.Warn("attempt to fetch quote with empty ref")
return nil, merrors.InvalidArgument("quotesStore: empty quoteRef")
}
quote := &model.Quote{}
if err := q.repo.FindOneByFilter(ctx, repository.Filter("quoteRef", quoteRef), quote); err != nil {
if errors.Is(err, merrors.ErrNoData) {
q.logger.Debug("quote not found", zap.String("quote_ref", quoteRef))
}
return nil, err
}
q.logger.Debug("quote loaded", zap.String("quote_ref", quoteRef), zap.String("status", string(quote.Status)))
return quote, nil
}
func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string, when time.Time) (*model.Quote, error) {
if quoteRef == "" || ledgerTxnRef == "" {
q.logger.Warn("attempt to consume quote with missing identifiers", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
return nil, merrors.InvalidArgument("quotesStore: missing identifiers")
}
if when.IsZero() {
when = time.Now()
}
q.logger.Debug("consuming quote", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
txn := q.txFactory.CreateTransaction()
result, err := txn.Execute(ctx, func(txCtx context.Context) (any, error) {
quote := &model.Quote{}
if err := q.repo.FindOneByFilter(txCtx, repository.Filter("quoteRef", quoteRef), quote); err != nil {
return nil, err
}
if !quote.Firm {
q.logger.Warn("quote not firm", zap.String("quote_ref", quoteRef))
return nil, storage.ErrQuoteNotFirm
}
if quote.Status == model.QuoteStatusExpired || quote.IsExpired(when) {
quote.MarkExpired()
if err := q.repo.Update(txCtx, quote); err != nil {
return nil, err
}
q.logger.Info("quote expired during consume", zap.String("quote_ref", quoteRef))
return nil, storage.ErrQuoteExpired
}
if quote.Status == model.QuoteStatusConsumed {
if quote.ConsumedByLedgerTxnRef == ledgerTxnRef {
q.logger.Debug("quote already consumed by ledger", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
return quote, nil
}
q.logger.Warn("quote consumed by different ledger", zap.String("quote_ref", quoteRef), zap.String("existing_ledger_ref", quote.ConsumedByLedgerTxnRef))
return nil, storage.ErrQuoteConsumed
}
quote.MarkConsumed(ledgerTxnRef, when)
if err := q.repo.Update(txCtx, quote); err != nil {
return nil, err
}
q.logger.Info("quote consumed", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
return quote, nil
})
if err != nil {
q.logger.Error("quote consumption failed", zap.Error(err), zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
return nil, err
}
quote, _ := result.(*model.Quote)
if quote == nil {
return nil, merrors.Internal("quotesStore: transaction returned nil quote")
}
return quote, nil
}
func (q *quotesStore) ExpireIssuedBefore(ctx context.Context, cutoff time.Time) (int, error) {
if cutoff.IsZero() {
q.logger.Warn("attempt to expire quotes with zero cutoff")
return 0, merrors.InvalidArgument("quotesStore: cutoff time is zero")
}
filter := repository.Query().
Filter(repository.Field("status"), model.QuoteStatusIssued).
Comparison(repository.Field("expiresAtUnixMs"), builder.Lt, cutoff.UnixMilli())
patch := repository.Patch().
Set(repository.Field("status"), model.QuoteStatusExpired).
Unset(repository.Field("consumedByLedgerTxnRef")).
Unset(repository.Field("consumedAtUnixMs"))
updated, err := q.repo.PatchMany(ctx, filter, patch)
if err != nil {
q.logger.Error("failed to expire quotes", zap.Error(err))
return 0, err
}
if updated > 0 {
q.logger.Info("quotes expired", zap.Int("count", updated))
}
return updated, nil
}

View File

@@ -0,0 +1,184 @@
package store
import (
"context"
"errors"
"testing"
"time"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"go.uber.org/zap"
)
func TestQuotesStoreIssue(t *testing.T) {
ctx := context.Background()
var inserted *model.Quote
repo := &repoStub{
insertFn: func(_ context.Context, obj storable.Storable, _ builder.Query) error {
inserted = cloneQuote(t, obj)
return nil
},
}
store := &quotesStore{logger: zap.NewNop(), repo: repo, txFactory: &txFactoryStub{}}
quote := &model.Quote{QuoteRef: "q1"}
if err := store.Issue(ctx, quote); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if inserted == nil || inserted.Status != model.QuoteStatusIssued {
t.Fatalf("expected issued quote to be inserted")
}
}
func TestQuotesStoreIssueSetsExpiryDate(t *testing.T) {
ctx := context.Background()
var inserted *model.Quote
repo := &repoStub{
insertFn: func(_ context.Context, obj storable.Storable, _ builder.Query) error {
inserted = cloneQuote(t, obj)
return nil
},
}
store := &quotesStore{logger: zap.NewNop(), repo: repo, txFactory: &txFactoryStub{}}
expiry := time.Now().Add(2 * time.Minute).UnixMilli()
quote := &model.Quote{
QuoteRef: "q1",
ExpiresAtUnixMs: expiry,
}
if err := store.Issue(ctx, quote); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if inserted == nil || inserted.ExpiresAt == nil {
t.Fatalf("expected expiry timestamp to be populated")
}
if inserted.ExpiresAt.UnixMilli() != expiry {
t.Fatalf("expected expiry to equal %d, got %d", expiry, inserted.ExpiresAt.UnixMilli())
}
}
func TestQuotesStoreIssueInvalidInput(t *testing.T) {
store := &quotesStore{logger: zap.NewNop(), repo: &repoStub{}, txFactory: &txFactoryStub{}}
if err := store.Issue(context.Background(), nil); !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument error, got %v", err)
}
}
func TestQuotesStoreConsumeSuccess(t *testing.T) {
ctx := context.Background()
now := time.Now()
ledgerRef := "ledger-1"
stored := &model.Quote{
QuoteRef: "q1",
Firm: true,
Status: model.QuoteStatusIssued,
ExpiresAtUnixMs: now.Add(5 * time.Minute).UnixMilli(),
}
var updated *model.Quote
repo := &repoStub{
findOneFn: func(_ context.Context, _ builder.Query, result storable.Storable) error {
quote := result.(*model.Quote)
*quote = *stored
return nil
},
updateFn: func(_ context.Context, obj storable.Storable) error {
updated = cloneQuote(t, obj)
return nil
},
}
factory := &txFactoryStub{}
store := &quotesStore{logger: zap.NewNop(), repo: repo, txFactory: factory}
res, err := store.Consume(ctx, "q1", ledgerRef, now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res == nil || res.Status != model.QuoteStatusConsumed {
t.Fatalf("expected consumed quote")
}
if updated == nil || updated.ConsumedByLedgerTxnRef != ledgerRef {
t.Fatalf("expected update with ledger ref")
}
}
func TestQuotesStoreConsumeExpired(t *testing.T) {
ctx := context.Background()
stored := &model.Quote{
QuoteRef: "q1",
Firm: true,
Status: model.QuoteStatusIssued,
ExpiresAtUnixMs: time.Now().Add(-time.Minute).UnixMilli(),
}
var updated *model.Quote
repo := &repoStub{
findOneFn: func(_ context.Context, _ builder.Query, result storable.Storable) error {
quote := result.(*model.Quote)
*quote = *stored
return nil
},
updateFn: func(_ context.Context, obj storable.Storable) error {
updated = cloneQuote(t, obj)
return nil
},
}
factory := &txFactoryStub{}
store := &quotesStore{logger: zap.NewNop(), repo: repo, txFactory: factory}
_, err := store.Consume(ctx, "q1", "ledger", time.Now())
if !errors.Is(err, storage.ErrQuoteExpired) {
t.Fatalf("expected ErrQuoteExpired, got %v", err)
}
if updated == nil || updated.Status != model.QuoteStatusExpired {
t.Fatalf("expected quote marked expired")
}
}
func TestQuotesStoreExpireIssuedBefore(t *testing.T) {
repo := &repoStub{
patchManyFn: func(context.Context, builder.Query, builder.Patch) (int, error) {
return 3, nil
},
}
store := &quotesStore{logger: zap.NewNop(), repo: repo, txFactory: &txFactoryStub{}}
count, err := store.ExpireIssuedBefore(context.Background(), time.Now())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 3 {
t.Fatalf("expected 3 expired quotes, got %d", count)
}
}
func TestQuotesStoreExpireZeroCutoff(t *testing.T) {
store := &quotesStore{logger: zap.NewNop(), repo: &repoStub{}, txFactory: &txFactoryStub{}}
if _, err := store.ExpireIssuedBefore(context.Background(), time.Time{}); !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument error")
}
}
func TestQuotesStoreGetByRefNotFound(t *testing.T) {
repo := &repoStub{
findOneFn: func(context.Context, builder.Query, storable.Storable) error {
return merrors.ErrNoData
},
}
store := &quotesStore{logger: zap.NewNop(), repo: repo, txFactory: &txFactoryStub{}}
if _, err := store.GetByRef(context.Background(), "missing"); !errors.Is(err, merrors.ErrNoData) {
t.Fatalf("expected ErrNoData, got %v", err)
}
}
func TestQuotesStoreGetByRefInvalid(t *testing.T) {
store := &quotesStore{logger: zap.NewNop(), repo: &repoStub{}, txFactory: &txFactoryStub{}}
if _, err := store.GetByRef(context.Background(), ""); !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument error")
}
}

View File

@@ -0,0 +1,127 @@
package store
import (
"context"
"errors"
"time"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/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/mongo"
"go.uber.org/zap"
)
type ratesStore struct {
logger mlogger.Logger
repo repository.Repository
}
func NewRates(logger mlogger.Logger, db *mongo.Database) (storage.RatesStore, error) {
repo := repository.CreateMongoRepository(db, model.RatesCollection)
indexes := []*ri.Definition{
{
Keys: []ri.Key{
{Field: "pair.base", Sort: ri.Asc},
{Field: "pair.quote", Sort: ri.Asc},
{Field: "provider", Sort: ri.Asc},
{Field: "asOfUnixMs", Sort: ri.Desc},
},
},
{
Keys: []ri.Key{
{Field: "rateRef", Sort: ri.Asc},
},
Unique: true,
},
}
ttlSeconds := int32(24 * 60 * 60)
indexes = append(indexes, &ri.Definition{
Keys: []ri.Key{
{Field: "asOf", Sort: ri.Asc},
},
TTL: &ttlSeconds,
Name: "rates_as_of_ttl",
})
for _, def := range indexes {
if err := repo.CreateIndex(def); err != nil {
logger.Error("failed to ensure rates index", zap.Error(err))
return nil, err
}
}
logger.Debug("rates store initialised", zap.String("collection", model.RatesCollection))
return &ratesStore{
logger: logger.Named(model.RatesCollection),
repo: repo,
}, nil
}
func (r *ratesStore) UpsertSnapshot(ctx context.Context, snapshot *model.RateSnapshot) error {
if snapshot == nil {
r.logger.Warn("attempt to upsert nil snapshot")
return merrors.InvalidArgument("ratesStore: nil snapshot")
}
if snapshot.RateRef == "" {
r.logger.Warn("attempt to upsert snapshot with empty rate_ref")
return merrors.InvalidArgument("ratesStore: empty rateRef")
}
if snapshot.AsOfUnixMs > 0 && snapshot.AsOf == nil {
asOf := time.UnixMilli(snapshot.AsOfUnixMs).UTC()
snapshot.AsOf = &asOf
}
existing := &model.RateSnapshot{}
filter := repository.Filter("rateRef", snapshot.RateRef)
err := r.repo.FindOneByFilter(ctx, filter, existing)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
r.logger.Debug("inserting new rate snapshot", zap.String("rate_ref", snapshot.RateRef))
return r.repo.Insert(ctx, snapshot, filter)
}
r.logger.Error("failed to query rate snapshot", zap.Error(err), zap.String("rate_ref", snapshot.RateRef))
return err
}
if existing.GetID() != nil {
snapshot.SetID(*existing.GetID())
}
r.logger.Debug("updating rate snapshot", zap.String("rate_ref", snapshot.RateRef))
return r.repo.Update(ctx, snapshot)
}
func (r *ratesStore) LatestSnapshot(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
query := repository.Query().
Filter(repository.Field("pair").Dot("base"), pair.Base).
Filter(repository.Field("pair").Dot("quote"), pair.Quote)
if provider != "" {
query = query.Filter(repository.Field("provider"), provider)
}
limit := int64(1)
query = query.Sort(repository.Field("asOfUnixMs"), false).Limit(&limit)
var result *model.RateSnapshot
err := r.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
doc := &model.RateSnapshot{}
if err := cur.Decode(doc); err != nil {
return err
}
result = doc
return nil
})
if err != nil {
return nil, err
}
if result == nil {
return nil, merrors.ErrNoData
}
return result, nil
}

View File

@@ -0,0 +1,87 @@
package store
import (
"context"
"testing"
"time"
"github.com/tech/sendico/fx/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"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func TestRatesStoreUpsertInsert(t *testing.T) {
ctx := context.Background()
var inserted *model.RateSnapshot
repo := &repoStub{
findOneFn: func(context.Context, builder.Query, storable.Storable) error {
return merrors.ErrNoData
},
insertFn: func(_ context.Context, obj storable.Storable, _ builder.Query) error {
inserted = cloneRate(t, obj)
return nil
},
}
store := &ratesStore{logger: zap.NewNop(), repo: repo}
snapshot := &model.RateSnapshot{RateRef: "r1"}
if err := store.UpsertSnapshot(ctx, snapshot); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if inserted == nil || inserted.RateRef != "r1" {
t.Fatalf("expected snapshot to be inserted")
}
}
func TestRatesStoreUpsertUpdate(t *testing.T) {
ctx := context.Background()
existingID := primitive.NewObjectID()
var updated *model.RateSnapshot
repo := &repoStub{
findOneFn: func(_ context.Context, _ builder.Query, result storable.Storable) error {
snap := result.(*model.RateSnapshot)
snap.SetID(existingID)
snap.RateRef = "existing"
return nil
},
updateFn: func(_ context.Context, obj storable.Storable) error {
snap := obj.(*model.RateSnapshot)
updated = snap
return nil
},
}
store := &ratesStore{logger: zap.NewNop(), repo: repo}
toUpdate := &model.RateSnapshot{RateRef: "existing"}
if err := store.UpsertSnapshot(ctx, toUpdate); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated == nil || updated.GetID() == nil || *updated.GetID() != existingID {
t.Fatalf("expected update to preserve ID")
}
}
func TestRatesStoreLatestSnapshot(t *testing.T) {
now := time.Now().UnixMilli()
repo := &repoStub{
findManyFn: func(_ context.Context, _ builder.Query, decode rd.DecodingFunc) error {
doc := &model.RateSnapshot{RateRef: "latest", AsOfUnixMs: now}
return runDecoderWithDocs(t, decode, doc)
},
}
store := &ratesStore{logger: zap.NewNop(), repo: repo}
res, err := store.LatestSnapshot(context.Background(), model.CurrencyPair{Base: "USD", Quote: "EUR"}, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.RateRef != "latest" || res.AsOfUnixMs != now {
t.Fatalf("unexpected snapshot returned: %+v", res)
}
}

View File

@@ -0,0 +1,189 @@
package store
import (
"context"
"testing"
"github.com/tech/sendico/fx/storage/model"
"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/db/transaction"
"github.com/tech/sendico/pkg/merrors"
pmodel "github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
)
type repoStub struct {
insertFn func(ctx context.Context, obj storable.Storable, filter builder.Query) error
insertManyFn func(ctx context.Context, objects []storable.Storable) error
findOneFn func(ctx context.Context, query builder.Query, result storable.Storable) error
findManyFn func(ctx context.Context, query builder.Query, decoder rd.DecodingFunc) error
updateFn func(ctx context.Context, obj storable.Storable) error
patchManyFn func(ctx context.Context, filter builder.Query, patch builder.Patch) (int, error)
createIdxFn func(def *ri.Definition) error
}
func (r *repoStub) Aggregate(ctx context.Context, b builder.Pipeline, decoder rd.DecodingFunc) error {
return merrors.NotImplemented("Aggregate not used")
}
func (r *repoStub) Insert(ctx context.Context, obj storable.Storable, filter builder.Query) error {
if r.insertFn != nil {
return r.insertFn(ctx, obj, filter)
}
return nil
}
func (r *repoStub) InsertMany(ctx context.Context, objects []storable.Storable) error {
if r.insertManyFn != nil {
return r.insertManyFn(ctx, objects)
}
return nil
}
func (r *repoStub) Get(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
return merrors.NotImplemented("Get not used")
}
func (r *repoStub) FindOneByFilter(ctx context.Context, query builder.Query, result storable.Storable) error {
if r.findOneFn != nil {
return r.findOneFn(ctx, query, result)
}
return nil
}
func (r *repoStub) FindManyByFilter(ctx context.Context, query builder.Query, decoder rd.DecodingFunc) error {
if r.findManyFn != nil {
return r.findManyFn(ctx, query, decoder)
}
return nil
}
func (r *repoStub) Update(ctx context.Context, obj storable.Storable) error {
if r.updateFn != nil {
return r.updateFn(ctx, obj)
}
return nil
}
func (r *repoStub) Patch(ctx context.Context, id primitive.ObjectID, patch builder.Patch) error {
return merrors.NotImplemented("Patch not used")
}
func (r *repoStub) PatchMany(ctx context.Context, filter builder.Query, patch builder.Patch) (int, error) {
if r.patchManyFn != nil {
return r.patchManyFn(ctx, filter, patch)
}
return 0, nil
}
func (r *repoStub) Delete(ctx context.Context, id primitive.ObjectID) error {
return merrors.NotImplemented("Delete not used")
}
func (r *repoStub) DeleteMany(ctx context.Context, query builder.Query) error {
return merrors.NotImplemented("DeleteMany not used")
}
func (r *repoStub) CreateIndex(def *ri.Definition) error {
if r.createIdxFn != nil {
return r.createIdxFn(def)
}
return nil
}
func (r *repoStub) ListIDs(ctx context.Context, query builder.Query) ([]primitive.ObjectID, error) {
return nil, merrors.NotImplemented("ListIDs not used")
}
func (r *repoStub) ListPermissionBound(ctx context.Context, query builder.Query) ([]pmodel.PermissionBoundStorable, error) {
return nil, merrors.NotImplemented("ListPermissionBound not used")
}
func (r *repoStub) ListAccountBound(ctx context.Context, query builder.Query) ([]pmodel.AccountBoundStorable, error) {
return nil, merrors.NotImplemented("ListAccountBound not used")
}
func (r *repoStub) Collection() string { return "test" }
type txFactoryStub struct {
executeFn func(ctx context.Context, cb transaction.Callback) (any, error)
}
func (f *txFactoryStub) CreateTransaction() transaction.Transaction {
return &txStub{executeFn: f.executeFn}
}
type txStub struct {
executeFn func(ctx context.Context, cb transaction.Callback) (any, error)
}
func (t *txStub) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
if t.executeFn != nil {
return t.executeFn(ctx, cb)
}
return cb(ctx)
}
func cloneRate(t *testing.T, obj storable.Storable) *model.RateSnapshot {
t.Helper()
rate, ok := obj.(*model.RateSnapshot)
if !ok {
t.Fatalf("expected *model.RateSnapshot, got %T", obj)
}
copy := *rate
return &copy
}
func cloneQuote(t *testing.T, obj storable.Storable) *model.Quote {
t.Helper()
quote, ok := obj.(*model.Quote)
if !ok {
t.Fatalf("expected *model.Quote, got %T", obj)
}
copy := *quote
return &copy
}
func clonePair(t *testing.T, obj storable.Storable) *model.Pair {
t.Helper()
pair, ok := obj.(*model.Pair)
if !ok {
t.Fatalf("expected *model.Pair, got %T", obj)
}
copy := *pair
return &copy
}
func cloneCurrency(t *testing.T, obj storable.Storable) *model.Currency {
t.Helper()
currency, ok := obj.(*model.Currency)
if !ok {
t.Fatalf("expected *model.Currency, got %T", obj)
}
copy := *currency
return &copy
}
func runDecoderWithDocs(t *testing.T, decode rd.DecodingFunc, docs ...interface{}) error {
t.Helper()
cur, err := mongo.NewCursorFromDocuments(docs, nil, nil)
if err != nil {
t.Fatalf("failed to create cursor: %v", err)
}
defer cur.Close(context.Background())
if len(docs) > 0 {
if !cur.Next(context.Background()) {
return cur.Err()
}
}
if err := decode(cur); err != nil {
return err
}
return cur.Err()
}