package ingestor import ( "context" "errors" "testing" "github.com/google/go-cmp/cmp" "github.com/tech/sendico/fx/ingestor/internal/config" mmarket "github.com/tech/sendico/fx/ingestor/internal/model" "github.com/tech/sendico/fx/storage" "github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/pkg/merrors" "go.uber.org/zap" ) func TestParseDecimal(t *testing.T) { got, err := parseDecimal("123.456") if err != nil { t.Fatalf("parseDecimal returned error: %v", err) } if got.String() != "15432/125" { // 123.456 expressed as a rational t.Fatalf("unexpected rational value: %s", got.String()) } if _, err := parseDecimal("not-a-number"); err == nil { t.Fatalf("parseDecimal should fail on invalid decimal string") } } func TestInvertPrices(t *testing.T) { bid, err := parseDecimal("2") if err != nil { t.Fatalf("parseDecimal: %v", err) } ask, err := parseDecimal("4") if err != nil { t.Fatalf("parseDecimal: %v", err) } invBid, invAsk := invertPrices(bid, ask) if diff := cmp.Diff("0.5", invAsk.FloatString(1)); diff != "" { t.Fatalf("unexpected inverted ask (-want +got):\n%s", diff) } if diff := cmp.Diff("0.25", invBid.FloatString(2)); diff != "" { t.Fatalf("unexpected inverted bid (-want +got):\n%s", diff) } } func TestServiceUpsertPairStoresSnapshot(t *testing.T) { store := &ratesStoreStub{} svc := testService(store, map[mmarket.Driver]mmarket.Connector{ mmarket.DriverBinance: &connectorStub{ id: mmarket.DriverBinance, ticker: &mmarket.Ticker{ Symbol: "EURUSDT", BidPrice: "1.0000", AskPrice: "1.2000", Provider: "binance", }, }, }) pair := config.Pair{ PairConfig: config.PairConfig{ Base: "USDT", Quote: "EUR", Symbol: "EURUSDT", Provider: "binance", }, Source: mmarket.DriverBinance, } if err := svc.upsertPair(context.Background(), pair); err != nil { t.Fatalf("upsertPair returned error: %v", err) } if len(store.snapshots) != 1 { t.Fatalf("expected 1 snapshot stored, got %d", len(store.snapshots)) } snap := store.snapshots[0] if snap.Pair.Base != "USDT" || snap.Pair.Quote != "EUR" { t.Fatalf("unexpected currency pair stored: %+v", snap.Pair) } if snap.Provider != "binance" { t.Fatalf("unexpected provider: %s", snap.Provider) } if snap.Bid != "1.00000000" || snap.Ask != "1.20000000" { t.Fatalf("unexpected bid/ask: %s / %s", snap.Bid, snap.Ask) } if snap.Mid != "1.10000000" { t.Fatalf("unexpected mid price: %s", snap.Mid) } if snap.SpreadBps != "1818.18181818" { t.Fatalf("unexpected spread bps: %s", snap.SpreadBps) } } func TestServiceUpsertPairInvertsPrices(t *testing.T) { store := &ratesStoreStub{} svc := testService(store, map[mmarket.Driver]mmarket.Connector{ mmarket.DriverCoinGecko: &connectorStub{ id: mmarket.DriverCoinGecko, ticker: &mmarket.Ticker{ Symbol: "RUBUSDT", BidPrice: "2", AskPrice: "4", Provider: "coingecko", }, }, }) pair := config.Pair{ PairConfig: config.PairConfig{ Base: "RUB", Quote: "USDT", Symbol: "RUBUSDT", Provider: "coingecko", Invert: true, }, Source: mmarket.DriverCoinGecko, } if err := svc.upsertPair(context.Background(), pair); err != nil { t.Fatalf("upsertPair returned error: %v", err) } snap := store.snapshots[0] if snap.Bid != "0.25000000" || snap.Ask != "0.50000000" { t.Fatalf("unexpected inverted bid/ask: %s / %s", snap.Bid, snap.Ask) } } func TestServicePollOnceReturnsFirstError(t *testing.T) { errFetch := merrors.Internal("fetch failed") connectorSuccess := &connectorStub{ id: mmarket.DriverBinance, ticker: &mmarket.Ticker{ Symbol: "EURUSDT", BidPrice: "1", AskPrice: "1", Provider: "binance", }, } connectorFail := &connectorStub{ id: mmarket.DriverCoinGecko, err: errFetch, } store := &ratesStoreStub{} svc := testService(store, map[mmarket.Driver]mmarket.Connector{ mmarket.DriverBinance: connectorSuccess, mmarket.DriverCoinGecko: connectorFail, }) svc.pairs = []config.Pair{ {PairConfig: config.PairConfig{Base: "USDT", Quote: "EUR", Symbol: "EURUSDT"}, Source: mmarket.DriverBinance}, {PairConfig: config.PairConfig{Base: "USDT", Quote: "RUB", Symbol: "RUBUSDT"}, Source: mmarket.DriverCoinGecko}, } err := svc.pollOnce(context.Background()) if err == nil { t.Fatalf("pollOnce expected to return error") } if !errors.Is(err, errFetch) { t.Fatalf("pollOnce returned unexpected error: %v", err) } if connectorSuccess.calls != 1 { t.Fatalf("expected success connector called once, got %d", connectorSuccess.calls) } if connectorFail.calls != 1 { t.Fatalf("expected failing connector called once, got %d", connectorFail.calls) } if len(store.snapshots) != 1 { t.Fatalf("expected snapshot stored only for successful pair, got %d", len(store.snapshots)) } } // -- test helpers ----------------------------------------------------------------- type ratesStoreStub struct { snapshots []*model.RateSnapshot err error } func (r *ratesStoreStub) UpsertSnapshot(_ context.Context, snapshot *model.RateSnapshot) error { if r.err != nil { return r.err } cp := *snapshot r.snapshots = append(r.snapshots, &cp) return nil } func (r *ratesStoreStub) LatestSnapshot(context.Context, model.CurrencyPair, string) (*model.RateSnapshot, error) { return nil, nil } type repositoryStub struct { rates storage.RatesStore } func (r *repositoryStub) Ping(context.Context) error { return nil } func (r *repositoryStub) Rates() storage.RatesStore { return r.rates } func (r *repositoryStub) Quotes() storage.QuotesStore { return nil } func (r *repositoryStub) Pairs() storage.PairStore { return nil } func (r *repositoryStub) Currencies() storage.CurrencyStore { return nil } type connectorStub struct { id mmarket.Driver ticker *mmarket.Ticker err error calls int } func (c *connectorStub) ID() mmarket.Driver { return c.id } func (c *connectorStub) FetchTicker(_ context.Context, symbol string) (*mmarket.Ticker, error) { c.calls++ if c.ticker != nil { cp := *c.ticker cp.Symbol = symbol return &cp, c.err } return nil, c.err } func testService(store storage.RatesStore, connectors map[mmarket.Driver]mmarket.Connector) *Service { return &Service{ logger: zap.NewNop(), cfg: &config.Config{}, rates: store, connectors: connectors, pairs: nil, metrics: nil, } }