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) } }