package quotes import ( "context" "errors" "net/http" "net/http/httptest" "strings" "testing" ) // secret stands in for anything a provider might put in a response body: no // part of it may reach a message shown to the user. const secret = "SUPER-SECRET-PROVIDER-TEXT" func stub(t *testing.T, handler http.HandlerFunc) Client { t.Helper() server := httptest.NewServer(handler) t.Cleanup(server.Close) return Client{BaseURL: server.URL, HTTPClient: server.Client()} } func body(payload string) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(payload)) } } const chartVWCE = `{"chart":{"result":[{"meta":{"currency":"EUR","symbol":"VWCE.DE","exchangeName":"GER"}, "timestamp":[1757376000,1757462400], "indicators":{"quote":[{"close":[127.11,128.42],"volume":[1,2]}]}}],"error":null}}` func TestLatestReadsLastClose(t *testing.T) { var path, query string client := stub(t, func(w http.ResponseWriter, r *http.Request) { path, query = r.URL.Path, r.URL.RawQuery body(chartVWCE)(w, r) }) quote, err := client.Latest(context.Background(), "VWCE.DE") if err != nil { t.Fatal(err) } if quote.Symbol != "VWCE.DE" || quote.Price != "128.42" || quote.Currency != "EUR" || quote.Day != "2025-09-10" { t.Fatalf("quote: %+v", quote) } if path != "/v8/finance/chart/VWCE.DE" || query != "range=5d&interval=1d" { t.Fatalf("request: %q %q", path, query) } } func TestLatestSkipsTrailingNullCloses(t *testing.T) { client := stub(t, body(`{"chart":{"result":[{"meta":{"currency":"EUR"}, "timestamp":[1757376000,1757462400,1757548800], "indicators":{"quote":[{"close":[127.11,128.42,null]}]}}],"error":null}}`)) quote, err := client.Latest(context.Background(), "VWCE.DE") if err != nil { t.Fatal(err) } // The day must come from the candle that priced, not from the newest one. if quote.Price != "128.42" || quote.Day != "2025-09-10" { t.Fatalf("quote: %+v", quote) } } func TestLatestReportsForeignCurrency(t *testing.T) { client := stub(t, body(`{"chart":{"result":[{"meta":{"currency":"USD"}, "timestamp":[1757376000],"indicators":{"quote":[{"close":[9.4079999923706]}]}}],"error":null}}`)) quote, err := client.Latest(context.Background(), "VUSA") if err != nil { t.Fatal(err) } // A foreign currency is the caller's decision to reject, not a fetch failure. if quote.Currency != "USD" || quote.Price != "9.408" { t.Fatalf("quote: %+v", quote) } } func TestLatestRejectsUnusableResponses(t *testing.T) { cases := []struct { name string handler http.HandlerFunc }{ {"every close null", body(`{"chart":{"result":[{"meta":{"currency":"EUR"}, "timestamp":[1757376000,1757462400],"indicators":{"quote":[{"close":[null,null]}]}}],"error":null}}`)}, {"server failure", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"chart":{"result":null,"error":{"description":"` + secret + `"}}}`)) }}, {"chart error", body(`{"chart":{"result":null,"error":{"code":"Not Found","description":"` + secret + `"}}}`)}, {"empty result", body(`{"chart":{"result":[],"error":null}}`)}, {"no currency", body(`{"chart":{"result":[{"meta":{"currency":"eur"}, "timestamp":[1757376000],"indicators":{"quote":[{"close":[128.42]}]}}],"error":null}}`)}, {"close not positive", body(`{"chart":{"result":[{"meta":{"currency":"EUR"}, "timestamp":[1757376000],"indicators":{"quote":[{"close":[0]}]}}],"error":null}}`)}, {"close without timestamp", body(`{"chart":{"result":[{"meta":{"currency":"EUR"}, "timestamp":[],"indicators":{"quote":[{"close":[128.42]}]}}],"error":null}}`)}, {"not json", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("" + secret + "")) }}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { quote, err := stub(t, c.handler).Latest(context.Background(), "VWCE.DE") if err == nil { t.Fatalf("expected failure, got %+v", quote) } var provider Error if !errors.As(err, &provider) || provider.Symbol != "VWCE.DE" || provider.Reason == "" { t.Fatalf("want typed provider error, got %#v", err) } if strings.Contains(err.Error(), secret) { t.Fatalf("response text leaked into %q", err) } if !strings.Contains(err.Error(), "VWCE.DE") { t.Fatalf("error must name the symbol: %q", err) } }) } } func TestLatestRejectsUnusableSymbolAndAddress(t *testing.T) { client := stub(t, func(http.ResponseWriter, *http.Request) { t.Fatal("no request may be made for a rejected symbol or address") }) if _, err := client.Latest(context.Background(), "../secrets"); err == nil { t.Fatal("expected a path-shaping symbol to be rejected") } plain := Client{BaseURL: "http://prices.example.com"} if _, err := plain.Latest(context.Background(), "VWCE.DE"); err == nil { t.Fatal("expected non-loopback plain HTTP to be rejected") } } func TestLatestKeepsCancellationIdentity(t *testing.T) { client := stub(t, body(chartVWCE)) ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := client.Latest(ctx, "VWCE.DE"); !errors.Is(err, context.Canceled) { t.Fatalf("want context.Canceled, got %#v", err) } } func TestDecimalQuantityRoundsHalfAwayFromZero(t *testing.T) { cases := []struct { text string want string }{ // Real closes, copied from a live response: every one is a 32-bit float // widened to 64, and the decimal the exchange published has to come // back out of it. {"165.25999450683594", "165.26"}, {"125.44999694824219", "125.45"}, {"127.1449966430664", "127.145"}, {"167.77999877929688", "167.78"}, {"0.41578700000001", "0.415787"}, {"9.4079999923706", "9.408"}, {"-9.4079999923706", "-9.408"}, {"128.42", "128.42"}, {"0.000000005", "0.00000001"}, {"0.000000004", "0"}, {"0.999999995", "1"}, {"42", "42"}, {"0007.5", "7.5"}, // Past the seventh digit the provider is describing its own encoding, // so the eighth place moves rather than being preserved. {"12345.678912345", "12345.68"}, {"12345678.94999999", "12345680"}, } for _, c := range cases { got, err := decimalQuantity(c.text) if err != nil || string(got) != c.want { t.Fatalf("decimalQuantity(%q) = %q, %v; want %q", c.text, got, err, c.want) } } for _, text := range []string{"", "-", ".5", "5.", "1.2.3", "1e5", "12e-3", "abc", "1 2", "999999999", "99999999.999999995"} { if got, err := decimalQuantity(text); err == nil { t.Fatalf("decimalQuantity(%q) = %q, want an error", text, got) } } } // The provider answers 429 to every request whose agent names a programming // language, so a missing or Go-default User-Agent breaks every quote on the // first call rather than under load. The header is load-bearing, not decor. func TestLatestIdentifiesAsABrowser(t *testing.T) { agent := "unset" client := stub(t, func(w http.ResponseWriter, r *http.Request) { agent = r.Header.Get("User-Agent") body(chartVWCE)(w, r) }) if _, err := client.Latest(context.Background(), "VWCE.DE"); err != nil { t.Fatal(err) } if !strings.HasPrefix(agent, "Mozilla/") || strings.Contains(agent, "Go-http-client") { t.Fatalf("User-Agent %q is refused by the provider", agent) } }