From c8fb143532ee8ff2a8b13b463f9eb436a0fc651b Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 15 Jun 2026 11:04:06 +0700 Subject: [PATCH] feat(gold): remove XAU/USD fallback and use VNAppMob SJC only --- internal/modules/gold/composite_prices.go | 47 +++------ .../modules/gold/composite_prices_test.go | 96 +++++-------------- internal/modules/gold/format.go | 7 -- internal/modules/gold/handlers.go | 14 +-- internal/modules/gold/handlers_test.go | 15 ++- 5 files changed, 53 insertions(+), 126 deletions(-) diff --git a/internal/modules/gold/composite_prices.go b/internal/modules/gold/composite_prices.go index 9ff6c59..755e788 100644 --- a/internal/modules/gold/composite_prices.go +++ b/internal/modules/gold/composite_prices.go @@ -3,7 +3,6 @@ package gold import ( "context" - "github.com/tiennm99/miti99bot/internal/log" "github.com/tiennm99/miti99bot/internal/storage" ) @@ -13,24 +12,22 @@ type sjcPriceFetcher interface { FetchSJCPrice(ctx context.Context) (buy, sell float64, err error) } -// compositePriceFetcher prefers VNAppMob SJC prices and falls back to the -// existing XAU/USD-derived chain when SJC is unavailable. +// compositePriceFetcher uses VNAppMob SJC prices only. If VNAppMob fails, +// the error is surfaced to the user instead of falling back to XAU/USD. type compositePriceFetcher struct { vnappmob sjcPriceFetcher - fallback priceFetcher } // NewCompositePriceFetcherFromEnv builds the production price fetcher using -// env-driven VNAppMob and fallback clients. +// only the env-driven VNAppMob client. func NewCompositePriceFetcherFromEnv(kv storage.KVStore) priceFetcher { return &compositePriceFetcher{ vnappmob: NewVNAppMobClientFromEnv(kv), - fallback: NewGoldPriceClientFromEnv(), } } -// FetchLuongPrice returns a representative VND/lượng price. It uses the SJC -// mid price when available, otherwise the XAU/USD-derived spot price. +// FetchLuongPrice returns the SJC mid price. It errors when VNAppMob is +// unavailable so callers can show a failure message. func (f *compositePriceFetcher) FetchLuongPrice(ctx context.Context) (float64, error) { buy, sell, err := f.FetchLuongPrices(ctx) if err != nil { @@ -39,36 +36,22 @@ func (f *compositePriceFetcher) FetchLuongPrice(ctx context.Context) (float64, e return (buy + sell) / 2, nil } -// FetchLuongPrices returns SJC buy/sell VND/lượng when VNAppMob is healthy, -// otherwise the XAU/USD-derived spot price for both sides. +// FetchLuongPrices returns SJC buy/sell VND/lượng from VNAppMob. func (f *compositePriceFetcher) FetchLuongPrices(ctx context.Context) (float64, float64, error) { - buy, sell, err := f.vnappmob.FetchSJCPrice(ctx) - if err == nil { - return buy, sell, nil - } - log.Warn("vnappmob_sjc_failed", "err", err) - p, err := f.fallback.FetchLuongPrice(ctx) - return p, p, err + return f.vnappmob.FetchSJCPrice(ctx) } -// FetchPrice returns a GoldPrice. When VNAppMob succeeds the struct carries -// SJC buy/sell data and Source "vnappmob-sjc"; otherwise it falls back to the -// XAU/USD chain with Source "xau-fallback". +// FetchPrice returns a GoldPrice with SJC buy/sell data and Source +// "vnappmob-sjc". It errors when VNAppMob is unavailable. func (f *compositePriceFetcher) FetchPrice(ctx context.Context) (GoldPrice, error) { buy, sell, err := f.vnappmob.FetchSJCPrice(ctx) - if err == nil { - mid := (buy + sell) / 2 - return GoldPrice{ - VNDPerLuong: mid, - Source: "vnappmob-sjc", - SJC: &SJCPrice{Buy: buy, Sell: sell}, - }, nil - } - log.Warn("vnappmob_sjc_failed", "err", err) - p, err := f.fallback.FetchPrice(ctx) if err != nil { return GoldPrice{}, err } - p.Source = "xau-fallback" - return p, nil + mid := (buy + sell) / 2 + return GoldPrice{ + VNDPerLuong: mid, + Source: "vnappmob-sjc", + SJC: &SJCPrice{Buy: buy, Sell: sell}, + }, nil } diff --git a/internal/modules/gold/composite_prices_test.go b/internal/modules/gold/composite_prices_test.go index fd9f051..16b45e2 100644 --- a/internal/modules/gold/composite_prices_test.go +++ b/internal/modules/gold/composite_prices_test.go @@ -12,7 +12,7 @@ import ( // stubVNAppMobClient implements just enough of the VNAppMob client contract // for composite fetcher tests. type stubVNAppMobClient struct { - buy float64 + buy float64 sell float64 err error } @@ -21,30 +21,9 @@ func (s *stubVNAppMobClient) FetchSJCPrice(context.Context) (float64, float64, e return s.buy, s.sell, s.err } -type stubFallbackFetcher struct { - price float64 - err error -} - -func (s *stubFallbackFetcher) FetchLuongPrice(context.Context) (float64, error) { - return s.price, s.err -} - -func (s *stubFallbackFetcher) FetchLuongPrices(context.Context) (float64, float64, error) { - return s.price, s.price, s.err -} - -func (s *stubFallbackFetcher) FetchPrice(context.Context) (GoldPrice, error) { - if s.err != nil { - return GoldPrice{}, s.err - } - return GoldPrice{VNDPerLuong: s.price, Source: "xau-fallback"}, nil -} - -func TestCompositeFetcher_PrefersVNAppMob(t *testing.T) { +func TestCompositeFetcher_UsesVNAppMob(t *testing.T) { f := &compositePriceFetcher{ vnappmob: &stubVNAppMobClient{buy: 90_000_000, sell: 91_000_000}, - fallback: &stubFallbackFetcher{err: errors.New("fallback unavailable")}, } p, err := f.FetchPrice(context.Background()) @@ -79,63 +58,37 @@ func TestCompositeFetcher_PrefersVNAppMob(t *testing.T) { } } -func TestCompositeFetcher_FallsBack(t *testing.T) { +func TestCompositeFetcher_ErrorsWhenVNAppMobFails(t *testing.T) { + wantErr := errors.New("vnappmob down") f := &compositePriceFetcher{ - vnappmob: &stubVNAppMobClient{err: errors.New("vnappmob down")}, - fallback: &stubFallbackFetcher{price: 88_000_000}, + vnappmob: &stubVNAppMobClient{err: wantErr}, } - p, err := f.FetchPrice(context.Background()) - if err != nil { - t.Fatalf("FetchPrice: %v", err) + if _, err := f.FetchPrice(context.Background()); err == nil { + t.Fatal("FetchPrice: expected error") } - if p.Source != "xau-fallback" { - t.Fatalf("source: got %q, want xau-fallback", p.Source) + if _, err := f.FetchLuongPrice(context.Background()); err == nil { + t.Fatal("FetchLuongPrice: expected error") } - if p.VNDPerLuong != 88_000_000 { - t.Fatalf("VNDPerLuong: got %v, want 88000000", p.VNDPerLuong) - } - - mid, err := f.FetchLuongPrice(context.Background()) - if err != nil { - t.Fatalf("FetchLuongPrice: %v", err) - } - if mid != 88_000_000 { - t.Fatalf("FetchLuongPrice: got %v, want 88000000", mid) + if _, _, err := f.FetchLuongPrices(context.Background()); err == nil { + t.Fatal("FetchLuongPrices: expected error") } } func TestGoldPriceLines(t *testing.T) { - t.Run("sjc", func(t *testing.T) { - lines := goldPriceLines(GoldPrice{ - VNDPerLuong: 90_500_000, - Source: "vnappmob-sjc", - SJC: &SJCPrice{Buy: 90_000_000, Sell: 91_000_000}, - }) - want := []string{"Gold Spot Price (SJC)", "Buy:", "Sell:"} - if len(lines) != len(want) { - t.Fatalf("got %d lines, want %d: %v", len(lines), len(want), lines) - } - for i, w := range want { - if !strings.Contains(lines[i], w) { - t.Fatalf("line %d missing %q in %v", i, w, lines) - } - } + lines := goldPriceLines(GoldPrice{ + VNDPerLuong: 90_500_000, + Source: "vnappmob-sjc", + SJC: &SJCPrice{Buy: 90_000_000, Sell: 91_000_000}, }) - - t.Run("fallback", func(t *testing.T) { - lines := goldPriceLines(GoldPrice{ - XAUUSD: 3000, - USDVND: 25000, - VNDPerLuong: 90_000_000, - Source: "xau-fallback", - }) - want := []string{"Gold Spot Price", "XAU:", "Rate:", "VND:"} - for i, w := range want { - if i >= len(lines) || !strings.Contains(lines[i], w) { - t.Fatalf("line %d missing %q in %v", i, w, lines) - } + want := []string{"Gold Spot Price (SJC)", "Buy:", "Sell:"} + if len(lines) != len(want) { + t.Fatalf("got %d lines, want %d: %v", len(lines), len(want), lines) + } + for i, w := range want { + if !strings.Contains(lines[i], w) { + t.Fatalf("line %d missing %q in %v", i, w, lines) } - }) + } } func TestNewCompositePriceFetcherFromEnv(t *testing.T) { @@ -146,7 +99,4 @@ func TestNewCompositePriceFetcherFromEnv(t *testing.T) { if f.vnappmob == nil { t.Fatal("vnappmob client is nil") } - if f.fallback == nil { - t.Fatal("fallback client is nil") - } } diff --git a/internal/modules/gold/format.go b/internal/modules/gold/format.go index d9b495a..3976811 100644 --- a/internal/modules/gold/format.go +++ b/internal/modules/gold/format.go @@ -49,13 +49,6 @@ func FormatPnL(currentValue, invested float64) string { return sign + FormatVND(diff) + " (" + sign + strconv.FormatFloat(pct, 'f', 2, 64) + "%)" } -func FormatUSD(n float64) string { - if math.IsNaN(n) || math.IsInf(n, 0) { - return "invalid USD" - } - return "$" + strconv.FormatFloat(n, 'f', 2, 64) -} - func absInt64(n int64) int64 { if n < 0 { return -n diff --git a/internal/modules/gold/handlers.go b/internal/modules/gold/handlers.go index 288c0ad..320c530 100644 --- a/internal/modules/gold/handlers.go +++ b/internal/modules/gold/handlers.go @@ -32,18 +32,10 @@ func (s *state) handlePrice(ctx context.Context, b *bot.Bot, update *models.Upda } func goldPriceLines(p GoldPrice) []string { - if p.Source == "vnappmob-sjc" && p.SJC != nil { - return []string{ - "Gold Spot Price (SJC)", - "Buy: " + FormatVND(p.SJC.Buy) + "/luong", - "Sell: " + FormatVND(p.SJC.Sell) + "/luong", - } - } return []string{ - "Gold Spot Price", - "XAU: " + FormatUSD(p.XAUUSD) + " USD/oz", - "Rate: " + FormatVND(p.USDVND) + "/USD", - "VND: " + FormatVND(p.VNDPerLuong) + "/luong", + "Gold Spot Price (SJC)", + "Buy: " + FormatVND(p.SJC.Buy) + "/luong", + "Sell: " + FormatVND(p.SJC.Sell) + "/luong", } } diff --git a/internal/modules/gold/handlers_test.go b/internal/modules/gold/handlers_test.go index e3cf6d1..58cfa09 100644 --- a/internal/modules/gold/handlers_test.go +++ b/internal/modules/gold/handlers_test.go @@ -29,7 +29,11 @@ func (f fakePriceFetcher) FetchPrice(context.Context) (GoldPrice, error) { if f.err != nil { return GoldPrice{}, f.err } - return GoldPrice{XAUUSD: 3000, USDVND: 25000, VNDPerLuong: f.price}, nil + return GoldPrice{ + VNDPerLuong: f.price, + Source: "vnappmob-sjc", + SJC: &SJCPrice{Buy: f.price, Sell: f.price}, + }, nil } func newTestState(price float64, err error) *state { @@ -188,7 +192,12 @@ func (f spreadPriceFetcher) FetchPrice(context.Context) (GoldPrice, error) { if f.err != nil { return GoldPrice{}, f.err } - return GoldPrice{XAUUSD: 3000, USDVND: 25000, VNDPerLuong: (f.buy + f.sell) / 2}, nil + mid := (f.buy + f.sell) / 2 + return GoldPrice{ + VNDPerLuong: mid, + Source: "vnappmob-sjc", + SJC: &SJCPrice{Buy: f.buy, Sell: f.sell}, + }, nil } func modDepsForTest() modules.Deps { @@ -202,7 +211,7 @@ func TestHandlePrice(t *testing.T) { t.Fatalf("handlePrice: %v", err) } text := rb.LastSent().Text() - for _, want := range []string{"Gold Spot Price", "XAU:", "USD/oz", "VND:", "/luong"} { + for _, want := range []string{"Gold Spot Price (SJC)", "Buy:", "Sell:", "/luong"} { if !strings.Contains(text, want) { t.Fatalf("price missing %q in %q", want, text) }