aboutsummaryrefslogtreecommitdiff
path: root/internal/cryptomarket/client_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/cryptomarket/client_test.go')
-rw-r--r--internal/cryptomarket/client_test.go216
1 files changed, 216 insertions, 0 deletions
diff --git a/internal/cryptomarket/client_test.go b/internal/cryptomarket/client_test.go
new file mode 100644
index 0000000..39367fc
--- /dev/null
+++ b/internal/cryptomarket/client_test.go
@@ -0,0 +1,216 @@
+package cryptomarket
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func mockKraken(t *testing.T, mode string) (*Client, *atomic.Int32) {
+ t.Helper()
+ calls := &atomic.Int32{}
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ if mode == "http" {
+ w.WriteHeader(429)
+ return
+ }
+ if mode == "error" {
+ w.Write([]byte(`{"error":["EAPI:Rate limit exceeded"]}`))
+ return
+ }
+ result := any(nil)
+ switch r.URL.Path {
+ case "/SystemStatus":
+ status := "online"
+ if mode == "maintenance" {
+ status = "maintenance"
+ }
+ result = map[string]string{"status": status}
+ case "/AssetPairs":
+ m := map[string]any{}
+ for _, i := range Instruments {
+ status := "online"
+ if mode == "halted" && i.Symbol == "BTC" {
+ status = "cancel_only"
+ }
+ m[i.Key] = map[string]string{"status": status}
+ }
+ result = m
+ case "/Ticker":
+ m := map[string]any{}
+ for _, i := range Instruments {
+ bid, ask := "100", "101"
+ if mode == "crossed" && i.Symbol == "BTC" {
+ bid = "102"
+ }
+ m[i.Key] = map[string]any{"a": []string{ask}, "b": []string{bid}}
+ }
+ result = m
+ case "/OHLC":
+ key := ""
+ for _, i := range Instruments {
+ if i.Pair == r.URL.Query().Get("pair") {
+ key = i.Key
+ }
+ }
+ now := time.Now().UTC().Truncate(24 * time.Hour).Unix()
+ high := "110"
+ if mode == "bad-candle" {
+ high = "99"
+ }
+ result = map[string]any{key: [][]any{{now - 86400, "100", high, "90", "101", "100", "1", 1}, {now, "100", "110", "90", "102", "100", "1", 1}}, "last": now}
+ default:
+ t.Errorf("unexpected URL %s", r.URL)
+ }
+ json.NewEncoder(w).Encode(map[string]any{"error": []string{}, "result": result})
+ }))
+ t.Cleanup(srv.Close)
+ c := NewClient()
+ c.Base = srv.URL
+ return c, calls
+}
+func TestClientValidatesQuotesAndCompletedCandles(t *testing.T) {
+ for _, mode := range []string{"ok", "halted", "crossed", "bad-candle"} {
+ t.Run(mode, func(t *testing.T) {
+ c, _ := mockKraken(t, mode)
+ s, e := c.Fetch(context.Background(), []string{"BTC"})
+ if e != nil {
+ t.Fatal(e)
+ }
+ _, quoted := s.Quotes["BTC"]
+ if quoted != (mode != "halted" && mode != "crossed") {
+ t.Fatal(s.Quotes)
+ }
+ n := len(s.Candles["BTC"])
+ if mode == "bad-candle" {
+ if n != 0 {
+ t.Fatal("invalid candle")
+ }
+ } else if n != 1 {
+ t.Fatal("unfinished candle included", n)
+ }
+ })
+ }
+}
+func TestClientErrorsBackoffAndCancellation(t *testing.T) {
+ for _, mode := range []string{"http", "error", "maintenance"} {
+ c, calls := mockKraken(t, mode)
+ for i := 0; i < 2; i++ {
+ if _, e := c.Fetch(context.Background(), []string{"BTC"}); e == nil {
+ t.Fatal(mode)
+ }
+ }
+ if calls.Load() != 1 {
+ t.Fatal("no backoff")
+ }
+ }
+ c, _ := mockKraken(t, "ok")
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, e := c.Fetch(ctx, []string{"BTC"}); e == nil {
+ t.Fatal("ignored cancellation")
+ }
+ if _, e := c.Fetch(context.Background(), []string{"BTC/../../"}); e == nil {
+ t.Fatal("symbol injection")
+ }
+}
+func TestClientCoalescesAndDoesNotShareMutableCache(t *testing.T) {
+ c, calls := mockKraken(t, "ok")
+ var wg sync.WaitGroup
+ for i := 0; i < 10; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ s, e := c.Fetch(context.Background(), []string{"BTC"})
+ if e != nil {
+ t.Error(e)
+ return
+ }
+ delete(s.Quotes, "BTC")
+ s.Candles["BTC"][0].Close = "1"
+ }()
+ }
+ wg.Wait()
+ // One explicit interaction fetches only the requested pair: status,
+ // metadata, ticker and daily history. The rest are cache hits.
+ if calls.Load() != 4 {
+ t.Fatalf("requests %d want 4", calls.Load())
+ }
+ s, e := c.Fetch(context.Background(), []string{"BTC"})
+ if e != nil || s.Quotes["BTC"].Bid != "100" || s.Candles["BTC"][0].Close != "101" {
+ t.Fatal(s, e)
+ }
+}
+func TestKrakenLive(t *testing.T) {
+ if os.Getenv("TEST_KRAKEN_LIVE") != "1" {
+ t.Skip("set TEST_KRAKEN_LIVE=1 for public-API smoke test")
+ }
+ c := NewClient()
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second)
+ defer cancel()
+ s, e := c.Fetch(ctx, []string{"BTC", "SHIB"})
+ if e != nil {
+ t.Fatal(e)
+ }
+ for _, symbol := range []string{"BTC", "SHIB"} {
+ q, ok := s.Quotes[symbol]
+ if !ok || q.Validate(time.Now()) != nil || len(s.Candles[symbol]) < 2 {
+ t.Fatalf("%s missing quotes/candles", symbol)
+ }
+ t.Log(symbol, q.Bid, q.Ask, strings.Join(Chart(s.Candles[symbol], 30, false, nil), "\n"))
+ }
+}
+
+func TestClientCancellationDoesNotPoisonSharedBackoff(t *testing.T) {
+ started := make(chan struct{})
+ release := make(chan struct{})
+ var once sync.Once
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Block the first request so the caller can cancel it mid-fetch.
+ once.Do(func() { close(started); <-release })
+ result := any(nil)
+ switch r.URL.Path {
+ case "/SystemStatus":
+ result = map[string]string{"status": "online"}
+ case "/AssetPairs":
+ m := map[string]any{}
+ for _, i := range Instruments {
+ m[i.Key] = map[string]string{"status": "online"}
+ }
+ result = m
+ case "/Ticker":
+ m := map[string]any{}
+ for _, i := range Instruments {
+ m[i.Key] = map[string]any{"a": []string{"100"}, "b": []string{"99"}}
+ }
+ result = m
+ case "/OHLC":
+ result = map[string]any{}
+ default:
+ t.Errorf("unexpected URL %s", r.URL)
+ }
+ json.NewEncoder(w).Encode(map[string]any{"error": []string{}, "result": result})
+ }))
+ defer srv.Close()
+ c := NewClient()
+ c.Base = srv.URL
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() { _, _ = c.Fetch(ctx, []string{"BTC"}); close(done) }()
+ <-started
+ cancel()
+ <-done
+ close(release)
+ // A later caller must not inherit the cancelled caller's backoff.
+ if _, e := c.Fetch(context.Background(), []string{"BTC"}); e != nil {
+ t.Fatalf("cancelled fetch poisoned the shared client: %v", e)
+ }
+}