From 90ad8c0c33b519d5830c69d4128bdc984b5e9de8 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Mon, 5 Oct 2026 14:22:14 -0400 Subject: feat: cryptocurrency exchange added to casino --- internal/cryptomarket/client_test.go | 216 +++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 internal/cryptomarket/client_test.go (limited to 'internal/cryptomarket/client_test.go') 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) + } +} -- cgit v1.2.3