mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
refactor: remove the unused gold spot-price chain and lol ErrEmptyResult
This commit is contained in:
1 parent
897e37ba80
commit
7cc0ead54f
6 files changed
+27
-543
No files matched your search
@@ -12,8 +12,8 @@ type sjcPriceFetcher interface {
|
||||
FetchSJCPrice(ctx context.Context) (buy, sell float64, err error)
|
||||
}
|
||||
|
||||
// compositePriceFetcher uses VNAppMob SJC prices only. If VNAppMob fails,
|
||||
// the error is surfaced to the user instead of falling back to XAU/USD.
|
||||
// compositePriceFetcher uses VNAppMob SJC prices only. If VNAppMob fails, the
|
||||
// error is surfaced to the user; there is no fallback source.
|
||||
type compositePriceFetcher struct {
|
||||
vnappmob sjcPriceFetcher
|
||||
}
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
package gold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
)
|
||||
|
||||
// XAU/USD provider defaults. All three are free, keyless, and verified to
|
||||
// answer datacenter IPs (goldprice.org was dropped: it 403s cloud/datacenter IPs).
|
||||
const (
|
||||
goldAPIDefaultURL = "https://api.gold-api.com/price/XAU"
|
||||
swissquoteDefaultURL = "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/XAU/USD"
|
||||
nbpDefaultURL = "https://api.nbp.pl/api/cenyzlota?format=json"
|
||||
)
|
||||
|
||||
type xauProvider struct {
|
||||
name string
|
||||
url string
|
||||
fetch func(ctx context.Context, url string) (float64, error)
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) providers() []xauProvider {
|
||||
pick := func(override, def string) string {
|
||||
if s := strings.TrimSpace(override); s != "" {
|
||||
return s
|
||||
}
|
||||
return def
|
||||
}
|
||||
return []xauProvider{
|
||||
{name: "gold-api.com", url: pick(c.GoldURL, goldAPIDefaultURL), fetch: c.fetchGoldAPI},
|
||||
{name: "swissquote", url: pick(c.SwissquoteURL, swissquoteDefaultURL), fetch: c.fetchSwissquote},
|
||||
{name: "nbp", url: pick(c.NBPURL, nbpDefaultURL), fetch: c.fetchNBP},
|
||||
}
|
||||
}
|
||||
|
||||
// fetchXAUUSD walks the provider chain and returns the first USD/oz price.
|
||||
// Per-provider failures are logged; if every provider fails the joined error
|
||||
// deliberately does NOT wrap ErrNoGoldPrice so callers treat it as a
|
||||
// retryable fetch failure, not an empty-data reply.
|
||||
func (c *GoldPriceClient) fetchXAUUSD(ctx context.Context) (float64, error) {
|
||||
var failures []string
|
||||
for _, p := range c.providers() {
|
||||
price, err := p.fetch(ctx, p.url)
|
||||
if err == nil {
|
||||
return price, nil
|
||||
}
|
||||
log.Warn("gold_price_provider_failed", "provider", p.name, "err", err)
|
||||
failures = append(failures, fmt.Sprintf("%s: %v", p.name, err))
|
||||
}
|
||||
return 0, fmt.Errorf("gold: all price providers failed: %s", strings.Join(failures, "; "))
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) providerGet(ctx context.Context, url string, dst any) error {
|
||||
if err := validateEndpoint(url); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.getJSON(ctx, url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(dst); err != nil {
|
||||
return fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchGoldAPI parses gold-api.com: {"price": 4073.6, "currency": "USD"}.
|
||||
func (c *GoldPriceClient) fetchGoldAPI(ctx context.Context, url string) (float64, error) {
|
||||
var body struct {
|
||||
Currency string `json:"currency"`
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
if err := c.providerGet(ctx, url, &body); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if body.Currency != "" && body.Currency != "USD" {
|
||||
return 0, fmt.Errorf("unexpected currency %q", body.Currency)
|
||||
}
|
||||
if body.Price <= 0 {
|
||||
return 0, ErrNoGoldPrice
|
||||
}
|
||||
return body.Price, nil
|
||||
}
|
||||
|
||||
// fetchSwissquote parses the public best-bid/offer feed:
|
||||
// [{"spreadProfilePrices":[{"bid":4074.41,"ask":4075.10}, ...]}, ...]
|
||||
// and returns the mid price of the first quoted profile.
|
||||
func (c *GoldPriceClient) fetchSwissquote(ctx context.Context, url string) (float64, error) {
|
||||
var body []struct {
|
||||
SpreadProfilePrices []struct {
|
||||
Bid float64 `json:"bid"`
|
||||
Ask float64 `json:"ask"`
|
||||
} `json:"spreadProfilePrices"`
|
||||
}
|
||||
if err := c.providerGet(ctx, url, &body); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, platform := range body {
|
||||
for _, q := range platform.SpreadProfilePrices {
|
||||
if q.Bid > 0 && q.Ask > 0 {
|
||||
return (q.Bid + q.Ask) / 2, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, ErrNoGoldPrice
|
||||
}
|
||||
|
||||
// fetchNBP parses the Polish central bank daily fixing
|
||||
// [{"data":"2026-06-11","cena":492.71}] (PLN per gram) and converts to USD/oz
|
||||
// using the shared FX table. Daily granularity — last-resort fallback only.
|
||||
func (c *GoldPriceClient) fetchNBP(ctx context.Context, url string) (float64, error) {
|
||||
var body []struct {
|
||||
PLNPerGram float64 `json:"cena"`
|
||||
}
|
||||
if err := c.providerGet(ctx, url, &body); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(body) == 0 || body[0].PLNPerGram <= 0 {
|
||||
return 0, ErrNoGoldPrice
|
||||
}
|
||||
plnPerUSD, err := c.fetchFXRate(ctx, "PLN")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("PLN rate: %w", err)
|
||||
}
|
||||
return body[0].PLNPerGram * gramsPerTroyOunce / plnPerUSD, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package gold
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateEndpoint(t *testing.T) {
|
||||
if err := validateEndpoint("https://example.com/path"); err != nil {
|
||||
t.Fatalf("https should pass: %v", err)
|
||||
}
|
||||
if err := validateEndpoint("http://localhost:1234/path"); err != nil {
|
||||
t.Fatalf("localhost http should pass: %v", err)
|
||||
}
|
||||
if err := validateEndpoint("http://127.0.0.1:1234/path"); err != nil {
|
||||
t.Fatalf("loopback http should pass: %v", err)
|
||||
}
|
||||
if err := validateEndpoint("http://example.com/path"); err == nil {
|
||||
t.Fatal("remote http should fail")
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,17 @@
|
||||
package gold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
fxDefaultURL = "https://open.er-api.com/v6/latest/USD"
|
||||
goldHTTPTimeout = 3 * time.Second // kept under the handler deadline; see chathelper.FetchContext
|
||||
fxFallbackCacheTTL = time.Hour
|
||||
gramsPerLuong = 37.5
|
||||
gramsPerTroyOunce = 31.1034768
|
||||
)
|
||||
import "errors"
|
||||
|
||||
// ErrNoGoldPrice reports that an upstream returned no usable price; handlers
|
||||
// show it as "No gold price available." rather than a generic fetch failure.
|
||||
var ErrNoGoldPrice = errors.New("gold: no price available")
|
||||
|
||||
// GoldPrice is one SJC gold quote from VNAppMob. VNDPerLuong is the buy/sell
|
||||
// mid price.
|
||||
type GoldPrice struct {
|
||||
XAUUSD float64
|
||||
USDVND float64
|
||||
VNDPerLuong float64
|
||||
Source string // "vnappmob-sjc" or "xau-fallback"
|
||||
SJC *SJCPrice // non-nil when Source == "vnappmob-sjc"
|
||||
Source string // always "vnappmob-sjc"
|
||||
SJC *SJCPrice // buy/sell quotes behind VNDPerLuong
|
||||
}
|
||||
|
||||
// SJCPrice holds VNAppMob SJC buy/sell quotes per lượng (VND).
|
||||
@@ -35,142 +19,3 @@ type SJCPrice struct {
|
||||
Buy float64
|
||||
Sell float64
|
||||
}
|
||||
|
||||
// GoldPriceClient fetches XAU/USD through a chain of free providers (see
|
||||
// price_providers.go) and converts to VND via a cached USD FX-rate table.
|
||||
type GoldPriceClient struct {
|
||||
HTTP *http.Client
|
||||
|
||||
// URL fields are explicit injection points for tests; production uses the
|
||||
// provider defaults.
|
||||
GoldURL string // primary: gold-api.com
|
||||
SwissquoteURL string
|
||||
NBPURL string
|
||||
FXURL string
|
||||
|
||||
defaultOnce sync.Once
|
||||
defaultClient *http.Client
|
||||
nowFn func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
fxRates map[string]float64
|
||||
fxExpiry time.Time
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) FetchPrice(ctx context.Context) (GoldPrice, error) {
|
||||
xauUSD, err := c.fetchXAUUSD(ctx)
|
||||
if err != nil {
|
||||
return GoldPrice{}, err
|
||||
}
|
||||
usdToVND, err := c.fetchFXRate(ctx, "VND")
|
||||
if err != nil {
|
||||
return GoldPrice{}, err
|
||||
}
|
||||
vndPerLuong := xauUSD * usdToVND * (gramsPerLuong / gramsPerTroyOunce)
|
||||
if vndPerLuong <= 0 || math.IsNaN(vndPerLuong) || math.IsInf(vndPerLuong, 0) {
|
||||
return GoldPrice{}, ErrNoGoldPrice
|
||||
}
|
||||
return GoldPrice{XAUUSD: xauUSD, USDVND: usdToVND, VNDPerLuong: vndPerLuong}, nil
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) FetchLuongPrice(ctx context.Context) (float64, error) {
|
||||
p, err := c.FetchPrice(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return p.VNDPerLuong, nil
|
||||
}
|
||||
|
||||
// FetchLuongPrices returns the same representative spot price for both buy and
|
||||
// sell because the XAU/USD fallback has no bid/ask spread.
|
||||
func (c *GoldPriceClient) FetchLuongPrices(ctx context.Context) (float64, float64, error) {
|
||||
p, err := c.FetchLuongPrice(ctx)
|
||||
return p, p, err
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) httpClient() *http.Client {
|
||||
if c.HTTP != nil {
|
||||
return c.HTTP
|
||||
}
|
||||
c.defaultOnce.Do(func() {
|
||||
c.defaultClient = &http.Client{Timeout: goldHTTPTimeout}
|
||||
})
|
||||
return c.defaultClient
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) now() time.Time {
|
||||
if c.nowFn != nil {
|
||||
return c.nowFn()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) fxURL() string {
|
||||
if strings.TrimSpace(c.FXURL) != "" {
|
||||
return strings.TrimSpace(c.FXURL)
|
||||
}
|
||||
return fxDefaultURL
|
||||
}
|
||||
|
||||
type fxResponse struct {
|
||||
Result string `json:"result"`
|
||||
Rates map[string]float64 `json:"rates"`
|
||||
TimeNextUpdateUnix int64 `json:"time_next_update_unix"`
|
||||
}
|
||||
|
||||
// fetchFXRate returns the USD→code rate from a cached full rate table so one
|
||||
// FX call serves both the VND conversion and the NBP fallback (PLN).
|
||||
func (c *GoldPriceClient) fetchFXRate(ctx context.Context, code string) (float64, error) {
|
||||
c.mu.Lock()
|
||||
now := c.now()
|
||||
if rate := c.fxRates[code]; rate > 0 && now.Before(c.fxExpiry) {
|
||||
c.mu.Unlock()
|
||||
return rate, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
endpoint := c.fxURL()
|
||||
if err := validateEndpoint(endpoint); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
resp, err := c.getJSON(ctx, endpoint)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("gold: FX request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return 0, errors.New("gold: FX rate limited")
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return 0, fmt.Errorf("gold: FX status %d: %w", resp.StatusCode, ErrNoGoldPrice)
|
||||
}
|
||||
var body fxResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return 0, fmt.Errorf("gold: FX decode: %w", err)
|
||||
}
|
||||
if body.Result != "" && body.Result != "success" {
|
||||
return 0, ErrNoGoldPrice
|
||||
}
|
||||
rate := body.Rates[code]
|
||||
if rate <= 0 {
|
||||
return 0, ErrNoGoldPrice
|
||||
}
|
||||
expiry := now.Add(fxFallbackCacheTTL)
|
||||
if body.TimeNextUpdateUnix > now.Unix() {
|
||||
expiry = time.Unix(body.TimeNextUpdateUnix, 0)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.fxRates = body.Rates
|
||||
c.fxExpiry = expiry
|
||||
c.mu.Unlock()
|
||||
return rate, nil
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) getJSON(ctx context.Context, endpoint string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (miti99bot)")
|
||||
return c.httpClient().Do(req)
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
package gold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newChainTestClient pins every provider URL to the test server so no test
|
||||
// ever falls through to a real network endpoint.
|
||||
func newChainTestClient(srv *httptest.Server) *GoldPriceClient {
|
||||
return &GoldPriceClient{
|
||||
GoldURL: srv.URL + "/gold",
|
||||
SwissquoteURL: srv.URL + "/swissquote",
|
||||
NBPURL: srv.URL + "/nbp",
|
||||
FXURL: srv.URL + "/fx",
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoldPriceClient_FetchLuongPrice(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
var fxHits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/gold":
|
||||
_, _ = w.Write([]byte(`{"currency":"USD","price":2000}`))
|
||||
case "/fx":
|
||||
atomic.AddInt32(&fxHits, 1)
|
||||
_, _ = w.Write([]byte(`{"result":"success","rates":{"VND":25000},"time_next_update_unix":1000}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := newChainTestClient(srv)
|
||||
c.nowFn = func() time.Time { return now }
|
||||
|
||||
got, err := c.FetchLuongPrice(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchLuongPrice: %v", err)
|
||||
}
|
||||
want := 2000 * 25000 * (gramsPerLuong / gramsPerTroyOunce)
|
||||
if math.Abs(got-want) > 0.01 {
|
||||
t.Errorf("price: got %v, want %v", got, want)
|
||||
}
|
||||
if _, err := c.FetchLuongPrice(context.Background()); err != nil {
|
||||
t.Fatalf("FetchLuongPrice cached: %v", err)
|
||||
}
|
||||
if atomic.LoadInt32(&fxHits) != 1 {
|
||||
t.Errorf("FX hits: got %d, want 1", fxHits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoldPriceClient_FallbackToSwissquote(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/gold":
|
||||
w.WriteHeader(http.StatusForbidden) // primary blocked, like goldprice.org was
|
||||
case "/swissquote":
|
||||
_, _ = w.Write([]byte(`[{"spreadProfilePrices":[{"bid":4000,"ask":4010}]}]`))
|
||||
case "/fx":
|
||||
_, _ = w.Write([]byte(`{"result":"success","rates":{"VND":25000}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
p, err := newChainTestClient(srv).FetchPrice(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchPrice: %v", err)
|
||||
}
|
||||
if p.XAUUSD != 4005 { // mid of bid/ask
|
||||
t.Errorf("XAUUSD: got %v, want 4005", p.XAUUSD)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoldPriceClient_FallbackToNBP(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/gold", "/swissquote":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
case "/nbp":
|
||||
_, _ = w.Write([]byte(`[{"data":"2026-06-11","cena":500}]`))
|
||||
case "/fx":
|
||||
_, _ = w.Write([]byte(`{"result":"success","rates":{"VND":25000,"PLN":4}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
p, err := newChainTestClient(srv).FetchPrice(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchPrice: %v", err)
|
||||
}
|
||||
want := 500 * gramsPerTroyOunce / 4 // PLN/gram → USD/oz via USD→PLN rate
|
||||
if math.Abs(p.XAUUSD-want) > 0.01 {
|
||||
t.Errorf("XAUUSD: got %v, want %v", p.XAUUSD, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoldPriceClient_AllProvidersFailIsRetryable(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, err := newChainTestClient(srv).FetchPrice(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("want error when every provider fails")
|
||||
}
|
||||
// A full-chain outage is a fetch failure, not a benign "no data" reply.
|
||||
if errors.Is(err, ErrNoGoldPrice) {
|
||||
t.Fatalf("got ErrNoGoldPrice, want retryable error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoldPriceClient_InvalidProviderResponses(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
gold string
|
||||
swissquote string
|
||||
nbp string
|
||||
}{
|
||||
{name: "empty bodies", gold: `{}`, swissquote: `[]`, nbp: `[]`},
|
||||
{name: "wrong currency and zero quotes", gold: `{"currency":"EUR","price":2000}`, swissquote: `[{"spreadProfilePrices":[{"bid":0,"ask":0}]}]`, nbp: `[{"cena":0}]`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/gold":
|
||||
_, _ = w.Write([]byte(tc.gold))
|
||||
case "/swissquote":
|
||||
_, _ = w.Write([]byte(tc.swissquote))
|
||||
case "/nbp":
|
||||
_, _ = w.Write([]byte(tc.nbp))
|
||||
case "/fx":
|
||||
_, _ = w.Write([]byte(`{"result":"success","rates":{"VND":25000,"PLN":4}}`))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
if _, err := newChainTestClient(srv).FetchPrice(context.Background()); err == nil {
|
||||
t.Fatal("want error for invalid provider data")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoldPriceClient_MissingFXRateReturnsNoPrice(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/gold" {
|
||||
_, _ = w.Write([]byte(`{"currency":"USD","price":2000}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"result":"success","rates":{}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, err := newChainTestClient(srv).FetchPrice(context.Background())
|
||||
if !errors.Is(err, ErrNoGoldPrice) {
|
||||
t.Fatalf("got %v, want ErrNoGoldPrice", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoldPriceClient_OverflowPriceReturnsNoPrice(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/gold" {
|
||||
_, _ = w.Write([]byte(`{"currency":"USD","price":1e308}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"result":"success","rates":{"VND":1e308}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, err := newChainTestClient(srv).FetchLuongPrice(context.Background())
|
||||
if !errors.Is(err, ErrNoGoldPrice) {
|
||||
t.Fatalf("got %v, want ErrNoGoldPrice", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoldPriceClient_FXRateLimitedIsRetryable(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/gold" {
|
||||
_, _ = w.Write([]byte(`{"currency":"USD","price":2000}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, err := newChainTestClient(srv).FetchLuongPrice(context.Background())
|
||||
if err == nil || errors.Is(err, ErrNoGoldPrice) {
|
||||
t.Fatalf("got %v, want retryable non-ErrNoGoldPrice", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoldPriceClient_FetchPrice(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/gold":
|
||||
_, _ = w.Write([]byte(`{"currency":"USD","price":3000}`))
|
||||
case "/fx":
|
||||
_, _ = w.Write([]byte(`{"result":"success","rates":{"VND":25000}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
p, err := newChainTestClient(srv).FetchPrice(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchPrice: %v", err)
|
||||
}
|
||||
if p.XAUUSD != 3000 {
|
||||
t.Errorf("XAUUSD: got %v, want 3000", p.XAUUSD)
|
||||
}
|
||||
if p.USDVND != 25000 {
|
||||
t.Errorf("USDVND: got %v, want 25000", p.USDVND)
|
||||
}
|
||||
want := 3000 * 25000 * (gramsPerLuong / gramsPerTroyOunce)
|
||||
if math.Abs(p.VNDPerLuong-want) > 0.01 {
|
||||
t.Errorf("VNDPerLuong: got %v, want %v", p.VNDPerLuong, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEndpoint(t *testing.T) {
|
||||
if err := validateEndpoint("https://example.com/path"); err != nil {
|
||||
t.Fatalf("https should pass: %v", err)
|
||||
}
|
||||
if err := validateEndpoint("http://localhost:1234/path"); err != nil {
|
||||
t.Fatalf("localhost http should pass: %v", err)
|
||||
}
|
||||
if err := validateEndpoint("http://127.0.0.1:1234/path"); err != nil {
|
||||
t.Fatalf("loopback http should pass: %v", err)
|
||||
}
|
||||
if err := validateEndpoint("http://example.com/path"); err == nil {
|
||||
t.Fatal("remote http should fail")
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ package lol
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -420,8 +419,3 @@ func truncate(s string, maxLen int) string {
|
||||
}
|
||||
return s[:cut] + "..."
|
||||
}
|
||||
|
||||
// ErrEmptyResult is reserved for explicit "no events" scenarios where the
|
||||
// fetch succeeded but returned zero matches. Currently unused outside tests
|
||||
// but kept exported so callers can distinguish from network errors.
|
||||
var ErrEmptyResult = errors.New("lol: no events in range")
|
||||
Reference in new issue
Block a user