diff --git a/internal/modules/gold/composite_prices.go b/internal/modules/gold/composite_prices.go index df6fc76..a46c2d3 100644 --- a/internal/modules/gold/composite_prices.go +++ b/internal/modules/gold/composite_prices.go @@ -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 } diff --git a/internal/modules/gold/price_providers.go b/internal/modules/gold/price_providers.go deleted file mode 100644 index f10d6c8..0000000 --- a/internal/modules/gold/price_providers.go +++ /dev/null @@ -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 -} diff --git a/internal/modules/gold/price_urls_test.go b/internal/modules/gold/price_urls_test.go new file mode 100644 index 0000000..6a90983 --- /dev/null +++ b/internal/modules/gold/price_urls_test.go @@ -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") + } +} diff --git a/internal/modules/gold/prices.go b/internal/modules/gold/prices.go index 4a3375f..3fc8e4f 100644 --- a/internal/modules/gold/prices.go +++ b/internal/modules/gold/prices.go @@ -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) -} diff --git a/internal/modules/gold/prices_test.go b/internal/modules/gold/prices_test.go deleted file mode 100644 index 5c0b61a..0000000 --- a/internal/modules/gold/prices_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/modules/lol/api_client.go b/internal/modules/lol/api_client.go index 30044a8..b96f5e2 100644 --- a/internal/modules/lol/api_client.go +++ b/internal/modules/lol/api_client.go @@ -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")