diff --git a/internal/modules/stock/handlers.go b/internal/modules/stock/handlers.go index 1429ea6..ecc010d 100644 --- a/internal/modules/stock/handlers.go +++ b/internal/modules/stock/handlers.go @@ -328,9 +328,8 @@ func (s *state) handleConvert(ctx context.Context, b *bot.Bot, update *models.Up "Currency exchange is not available yet.\n"+s.comingSoonMessage) } -// handleStats fetches every held ticker's current price sequentially (reusing -// the pooled KBS connection) and renders the portfolio. Read-only — no -// portfolio mutation, so no keylock. +// handleStats fetches current prices for held tickers in one SSI batch request +// and renders the portfolio. Read-only; no portfolio mutation, so no keylock. func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error { userID, ok := senderInfo(update) if !ok { @@ -366,21 +365,21 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda if len(heldList) > 0 { lines = append(lines, "\nStocks:") - // Fetch sequentially, NOT concurrently. The memoised HTTP client keeps a - // keep-alive connection pool across calls (see PriceClient), so serial - // fetches to the same KBS host pay one TLS handshake and reuse the - // connection. Firing them in parallel instead opens N simultaneous - // handshakes into an empty pool; on the memory-constrained Lambda - // (256MB ≈ 0.15 vCPU) those CPU-bound handshakes thrash and each blows - // past the per-fetch timeout. The reply-reserved sub-context bounds the - // whole loop so the final Reply keeps its budget; a failed/slow ticker - // degrades to "(no price)" rather than failing the summary. fetchCtx, cancel := chathelper.FetchContext(ctx) defer cancel() + + symbols := make([]string, 0, len(heldList)) for _, h := range heldList { - price, err := s.prices.FetchPrice(fetchCtx, h.symbol) - if err != nil { - log.Error("stock_fetch_price", "symbol", h.symbol, "err", err) + symbols = append(symbols, h.symbol) + } + prices, fetchErr := s.prices.FetchPrices(fetchCtx, symbols) + if fetchErr != nil { + log.Error("stock_fetch_prices", "symbols", strings.Join(symbols, ","), "err", fetchErr) + } + + for _, h := range heldList { + price := prices[h.symbol] + if fetchErr != nil || price <= 0 { lines = append(lines, " "+h.symbol+" x"+FormatStock(float64(h.qty))+" (no price)") continue } @@ -390,7 +389,6 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda " @ "+FormatVND(price)+" = "+FormatVND(val)) } } - lines = append(lines, "\nTotal value: "+FormatVND(totalValue)) lines = append(lines, "Invested: "+FormatVND(p.Meta.Invested)) lines = append(lines, "P&L: "+FormatPnL(totalValue, p.Meta.Invested)) diff --git a/internal/modules/stock/income_events_test.go b/internal/modules/stock/income_events_test.go index 3a0058a..c73d178 100644 --- a/internal/modules/stock/income_events_test.go +++ b/internal/modules/stock/income_events_test.go @@ -115,7 +115,7 @@ func installTradingIncomeEvents(t *testing.T, eventBody string, now time.Time) ( priceSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"data_day":[{"c":24500}]}`)) + _, _ = w.Write([]byte(`{"data":{"stockSymbol":"TCX","matchedPrice":24500}}`)) })) t.Cleanup(priceSrv.Close) diff --git a/internal/modules/stock/prices.go b/internal/modules/stock/prices.go index 5a9a92c..d010f21 100644 --- a/internal/modules/stock/prices.go +++ b/internal/modules/stock/prices.go @@ -5,34 +5,29 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" + "strings" "sync" "time" ) -// kbsDefaultURL is the KBS public stock data endpoint base. -const kbsDefaultURL = "https://kbbuddywts.kbsec.com.vn/iis-server/investment/stocks" +// ssiQueryDefaultURL is the SSI iBoard stock-query endpoint base. +const ssiQueryDefaultURL = "https://iboard-query.ssi.com.vn" -// kbsLookbackDays widens the requested window to absorb weekends and Vietnam -// market holidays — KBS returns the latest bar within the window in [0]. -const kbsLookbackDays = 14 +// ssiHTTPTimeout caps a stock quote request. Kept under the handler deadline +// so a slow upstream cannot starve the Telegram reply budget. +const ssiHTTPTimeout = 3 * time.Second -// kbsHTTPTimeout caps a single ticker's price fetch. Kept well under the -// handler's overall deadline so one slow/hung ticker cannot drain the budget -// the handler needs to deliver its Telegram reply (see chathelper.FetchContext). -const kbsHTTPTimeout = 3 * time.Second - -// PriceClient is the KBS price fetcher. Zero value uses the default URL + -// `&{Timeout: kbsHTTPTimeout}` HTTP client; tests inject HTTP + URL. +// PriceClient is the SSI iBoard stock quote fetcher. Zero value uses the +// default SSI URL + a timeout-bound HTTP client; tests inject HTTP + URL. type PriceClient struct { HTTP *http.Client URL string - // defaultClient memoises the zero-value HTTP fallback so the transport's - // connection pool survives across FetchPrice calls — /stock_stats fans - // out per held ticker, and a fresh client per call means a fresh TLS - // handshake per ticker. + // defaultClient memoises the zero-value HTTP client so the transport's + // connection pool survives across stock commands. defaultOnce sync.Once defaultClient *http.Client } @@ -42,84 +37,127 @@ func (c *PriceClient) httpClient() *http.Client { return c.HTTP } c.defaultOnce.Do(func() { - c.defaultClient = &http.Client{Timeout: kbsHTTPTimeout} + c.defaultClient = &http.Client{Timeout: ssiHTTPTimeout} }) return c.defaultClient } func (c *PriceClient) baseURL() string { if c.URL != "" { - return c.URL + return strings.TrimRight(c.URL, "/") } - return kbsDefaultURL + return ssiQueryDefaultURL } -// kbsResponse is the slice of the KBS payload we care about. We intentionally -// don't model the full response (open/high/low/volume) — only the latest -// close. The struct still names them so Json doesn't error on unknown fields -// (Go's json decoder ignores them by default). -type kbsResponse struct { - DataDay []kbsBar `json:"data_day"` +type ssiSingleResponse struct { + Data ssiStockQuote `json:"data"` } -type kbsBar struct { - C float64 `json:"c"` // close, already in VND, unscaled +type ssiMultipleResponse struct { + Data []ssiStockQuote `json:"data"` } -// kbsFormatDate formats t as "DD-MM-YYYY" — KBS's expected query date shape. -func kbsFormatDate(t time.Time) string { - t = t.UTC() - return fmt.Sprintf("%02d-%02d-%04d", t.Day(), int(t.Month()), t.Year()) +type ssiStockQuote struct { + StockSymbol string `json:"stockSymbol"` + MatchedPrice float64 `json:"matchedPrice"` } -// FetchPrice returns the latest VND close for ticker, or ErrNoPrice if KBS -// has no data (unknown ticker, suspended, holiday-only window). Network / -// decode errors are returned wrapped. +// FetchPrice returns SSI's current matched price in VND for ticker, or +// ErrNoPrice if SSI returns no usable quote. Network / decode errors are +// returned wrapped. func (c *PriceClient) FetchPrice(ctx context.Context, ticker string) (float64, error) { if ticker == "" { return 0, errors.New("stock: ticker is empty") } - now := time.Now().UTC() - edate := kbsFormatDate(now) - sdate := kbsFormatDate(now.Add(-time.Duration(kbsLookbackDays) * 24 * time.Hour)) - - endpoint := c.baseURL() + "/" + url.PathEscape(ticker) + "/data_day" - q := url.Values{} - q.Set("sdate", sdate) - q.Set("edate", edate) - full := endpoint + "?" + q.Encode() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil) + full := c.baseURL() + "/stock/" + url.PathEscape(ticker) + req, err := newSSIRequest(ctx, http.MethodGet, full, nil) if err != nil { - return 0, fmt.Errorf("stock: build KBS request: %w", err) + return 0, err } - req.Header.Set("User-Agent", "Mozilla/5.0 (miti99bot)") + var body ssiSingleResponse + if err := c.doJSON(req, &body); err != nil { + return 0, err + } + price := body.Data.MatchedPrice + if price <= 0 { + return 0, ErrNoPrice + } + return price, nil +} + +// FetchPrices returns current matched prices for the requested tickers using +// SSI's batch quote endpoint. Missing or invalid quotes are omitted from the +// returned map; callers can degrade those symbols individually. +func (c *PriceClient) FetchPrices(ctx context.Context, tickers []string) (map[string]float64, error) { + if len(tickers) == 0 { + return map[string]float64{}, nil + } + form := url.Values{} + for _, ticker := range tickers { + ticker = strings.TrimSpace(ticker) + if ticker == "" { + continue + } + form.Add("stocks", ticker) + } + if len(form) == 0 { + return nil, errors.New("stock: no tickers to fetch") + } + + req, err := newSSIRequest(ctx, http.MethodPost, c.baseURL()+"/stock/multiple", strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + var body ssiMultipleResponse + if err := c.doJSON(req, &body); err != nil { + return nil, err + } + out := make(map[string]float64, len(body.Data)) + for _, quote := range body.Data { + symbol := strings.ToUpper(strings.TrimSpace(quote.StockSymbol)) + if symbol == "" || quote.MatchedPrice <= 0 { + continue + } + out[symbol] = quote.MatchedPrice + } + if len(out) == 0 { + return nil, ErrNoPrice + } + return out, nil +} + +func newSSIRequest(ctx context.Context, method, full string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, full, body) + if err != nil { + return nil, fmt.Errorf("stock: build SSI request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Origin", "https://iboard.ssi.com.vn") + req.Header.Set("Referer", "https://iboard.ssi.com.vn/") + req.Header.Set("User-Agent", "Mozilla/5.0 (miti99bot)") + return req, nil +} + +func (c *PriceClient) doJSON(req *http.Request, dst any) error { resp, err := c.httpClient().Do(req) if err != nil { - return 0, fmt.Errorf("stock: KBS request: %w", err) + return fmt.Errorf("stock: SSI request: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return 0, ErrNoPrice + return ErrNoPrice } - - var body kbsResponse - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - return 0, fmt.Errorf("stock: KBS decode: %w", err) + if err := json.NewDecoder(resp.Body).Decode(dst); err != nil { + return fmt.Errorf("stock: SSI decode: %w", err) } - if len(body.DataDay) == 0 { - return 0, ErrNoPrice - } - close := body.DataDay[0].C - if close <= 0 { - return 0, ErrNoPrice - } - return close, nil + return nil } -// ErrNoPrice means KBS returned no usable price for the ticker — either the +// ErrNoPrice means SSI returned no usable price for the ticker - either the // symbol is unknown, the market hasn't traded recently, or the data was // invalid. Used by symbol resolution to detect "is this a real ticker". var ErrNoPrice = errors.New("stock: no price available") diff --git a/internal/modules/stock/prices_test.go b/internal/modules/stock/prices_test.go index c0e4632..7365300 100644 --- a/internal/modules/stock/prices_test.go +++ b/internal/modules/stock/prices_test.go @@ -22,27 +22,73 @@ func newTestPriceClient(t *testing.T, handler http.HandlerFunc) (*PriceClient, * func TestPriceClient_HappyPath(t *testing.T) { c, _ := newTestPriceClient(t, func(w http.ResponseWriter, r *http.Request) { - if !strings.Contains(r.URL.Path, "/TCB/data_day") { + if r.Method != http.MethodGet { + t.Errorf("method = %s, want GET", r.Method) + } + if r.URL.Path != "/stock/TCB" { t.Errorf("unexpected path: %s", r.URL.Path) } - if r.URL.Query().Get("sdate") == "" || r.URL.Query().Get("edate") == "" { - t.Errorf("missing sdate/edate: %s", r.URL.RawQuery) + if got := r.Header.Get("Origin"); got != "https://iboard.ssi.com.vn" { + t.Errorf("origin = %q", got) } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"data_day":[{"c":24500}, {"c":24300}]}`)) + _, _ = w.Write([]byte(`{"data":{"stockSymbol":"TCB","matchedPrice":24500}}`)) }) got, err := c.FetchPrice(context.Background(), "TCB") if err != nil { t.Fatalf("FetchPrice: %v", err) } if got != 24500 { - t.Errorf("price: got %v, want 24500 (latest bar = data_day[0])", got) + t.Errorf("price: got %v, want 24500", got) + } +} + +func TestPriceClient_BatchHappyPath(t *testing.T) { + c, _ := newTestPriceClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/stock/multiple" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if got := r.Header.Get("Content-Type"); got != "application/x-www-form-urlencoded" { + t.Errorf("content-type = %q", got) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + stocks := r.Form["stocks"] + if strings.Join(stocks, ",") != "TCB,FPT" { + t.Errorf("stocks = %v, want [TCB FPT]", stocks) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"stockSymbol":"TCB","matchedPrice":24500},{"stockSymbol":"FPT","matchedPrice":120000}]}`)) + }) + got, err := c.FetchPrices(context.Background(), []string{"TCB", "FPT"}) + if err != nil { + t.Fatalf("FetchPrices: %v", err) + } + if got["TCB"] != 24500 || got["FPT"] != 120000 { + t.Errorf("prices = %+v", got) + } +} + +func TestPriceClient_BatchOmitsInvalidQuotes(t *testing.T) { + c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":[{"stockSymbol":"TCB","matchedPrice":24500},{"stockSymbol":"BAD","matchedPrice":0},{"stockSymbol":"NEG","matchedPrice":-1}]}`)) + }) + got, err := c.FetchPrices(context.Background(), []string{"TCB", "BAD", "NEG"}) + if err != nil { + t.Fatalf("FetchPrices: %v", err) + } + if len(got) != 1 || got["TCB"] != 24500 { + t.Errorf("prices = %+v, want only TCB", got) } } func TestPriceClient_NoData_ReturnsErrNoPrice(t *testing.T) { c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte(`{"data_day":[]}`)) + _, _ = w.Write([]byte(`{"data":null}`)) }) _, err := c.FetchPrice(context.Background(), "NOPE") if !errors.Is(err, ErrNoPrice) { @@ -50,6 +96,16 @@ func TestPriceClient_NoData_ReturnsErrNoPrice(t *testing.T) { } } +func TestPriceClient_BatchNoUsableData_ReturnsErrNoPrice(t *testing.T) { + c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":[{"stockSymbol":"NOPE","matchedPrice":0}]}`)) + }) + _, err := c.FetchPrices(context.Background(), []string{"NOPE"}) + if !errors.Is(err, ErrNoPrice) { + t.Errorf("got %v, want ErrNoPrice", err) + } +} + func TestPriceClient_4xx_ReturnsErrNoPrice(t *testing.T) { c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) @@ -60,9 +116,9 @@ func TestPriceClient_4xx_ReturnsErrNoPrice(t *testing.T) { } } -func TestPriceClient_NegativeClose_ReturnsErrNoPrice(t *testing.T) { +func TestPriceClient_NegativeMatchedPrice_ReturnsErrNoPrice(t *testing.T) { c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte(`{"data_day":[{"c":-1}]}`)) + _, _ = w.Write([]byte(`{"data":{"stockSymbol":"WEIRD","matchedPrice":-1}}`)) }) _, err := c.FetchPrice(context.Background(), "WEIRD") if !errors.Is(err, ErrNoPrice) { @@ -77,19 +133,3 @@ func TestPriceClient_EmptyTicker(t *testing.T) { t.Error("empty ticker: expected error, got nil") } } - -func TestKBSFormatDate(t *testing.T) { - cases := []struct { - in time.Time - want string - }{ - {time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC), "10-05-2026"}, - {time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC), "05-01-2026"}, - {time.Date(2026, 12, 31, 23, 59, 0, 0, time.UTC), "31-12-2026"}, - } - for _, c := range cases { - if got := kbsFormatDate(c.in); got != c.want { - t.Errorf("kbsFormatDate(%v): got %q, want %q", c.in, got, c.want) - } - } -} diff --git a/internal/modules/stock/stats_test.go b/internal/modules/stock/stats_test.go new file mode 100644 index 0000000..446474d --- /dev/null +++ b/internal/modules/stock/stats_test.go @@ -0,0 +1,84 @@ +package stock + +import ( + "context" + "net/http" + "net/http/httptest" + "reflect" + "sort" + "strings" + "testing" + "time" + + "github.com/tiennm99/miti99bot/internal/storage" + "github.com/tiennm99/miti99bot/internal/testutil" +) + +func TestHandleStats_UsesSSIBatchPrices(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC) + + requests := 0 + var gotStocks []string + priceSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/stock/multiple" { + t.Errorf("path = %q, want /stock/multiple", r.URL.Path) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + gotStocks = append([]string(nil), r.PostForm["stocks"]...) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"stockSymbol":"MWG","matchedPrice":70000},{"stockSymbol":"TCB","matchedPrice":30000},{"stockSymbol":"FPT","matchedPrice":120000}]}`)) + })) + t.Cleanup(priceSrv.Close) + + kv := storage.NewMemoryKVStore() + p := NewPortfolio(now.UnixMilli()) + p.Currency["VND"] = 2335000 + p.Meta.Invested = 1000000000 + p.AddAsset("MWG", 1800) + p.AddAsset("TCB", 4200) + p.AddAsset("FPT", 2300) + if err := SavePortfolio(ctx, kv, 7, p); err != nil { + t.Fatalf("SavePortfolio: %v", err) + } + + s := &state{ + kv: kv, + prices: &PriceClient{HTTP: priceSrv.Client(), URL: priceSrv.URL}, + nowFn: func() time.Time { return now }, + } + rb := testutil.NewRecordingBot(t) + if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_stats")); err != nil { + t.Fatalf("handleStats: %v", err) + } + + if requests != 1 { + t.Fatalf("requests = %d, want one SSI batch request", requests) + } + sort.Strings(gotStocks) + if wantStocks := []string{"FPT", "MWG", "TCB"}; !reflect.DeepEqual(gotStocks, wantStocks) { + t.Fatalf("stocks form values = %#v, want %#v", gotStocks, wantStocks) + } + + text := rb.LastSent().Text() + for _, want := range []string{ + "MWG x1800 @ 70.000 VND = 126.000.000 VND", + "TCB x4200 @ 30.000 VND = 126.000.000 VND", + "FPT x2300 @ 120.000 VND = 276.000.000 VND", + "Total value: 530.335.000 VND", + "P&L: -469.665.000 VND (-46.97%)", + } { + if !strings.Contains(text, want) { + t.Fatalf("stats missing %q in:\n%s", want, text) + } + } + if strings.Contains(text, "(no price)") { + t.Fatalf("stats rendered missing prices:\n%s", text) + } +} diff --git a/internal/modules/stock/symbols.go b/internal/modules/stock/symbols.go index 05771d5..80ec28e 100644 --- a/internal/modules/stock/symbols.go +++ b/internal/modules/stock/symbols.go @@ -11,7 +11,7 @@ import ( ) // tickerRe restricts tickers to ASCII alphanumeric, 1-16 chars. Stops -// Cyrillic / unicode-lookalike inputs from amplifying KBS lookups, and +// Cyrillic / unicode-lookalike inputs from amplifying price-provider lookups, and // guards the cache key alphabet (sym:) from oddities. var tickerRe = regexp.MustCompile(`^[A-Z0-9]{1,16}$`) @@ -23,12 +23,12 @@ type ResolvedSymbol struct { Label string `json:"label"` } -// ErrUnknownTicker means KBS has no price data for the given ticker — i.e. +// ErrUnknownTicker means the price provider has no price data for the given ticker — i.e. // the symbol is not a tradeable VN stock as far as our source is concerned. var ErrUnknownTicker = errors.New("stock: unknown ticker") // ResolveSymbol returns the cached ResolvedSymbol if any, otherwise queries -// KBS to validate the ticker and caches the result permanently. Tickers +// the price provider to validate the ticker and caches the result permanently. Tickers // don't change; permanent caching is correct. // // The empty-input case returns ErrUnknownTicker to keep the caller's branch @@ -47,7 +47,7 @@ func ResolveSymbol(ctx context.Context, kv storage.KVStore, prices *PriceClient, return ResolvedSymbol{}, fmt.Errorf("stock: cache read %s: %w", ticker, err) } - // Cache miss → validate against KBS by attempting a price fetch. + // Cache miss → validate against the price provider by attempting a price fetch. if _, err := prices.FetchPrice(ctx, ticker); err != nil { if errors.Is(err, ErrNoPrice) { return ResolvedSymbol{}, ErrUnknownTicker diff --git a/internal/modules/stock/symbols_test.go b/internal/modules/stock/symbols_test.go index a0b91b8..c2053a5 100644 --- a/internal/modules/stock/symbols_test.go +++ b/internal/modules/stock/symbols_test.go @@ -5,7 +5,6 @@ import ( "errors" "net/http" "net/http/httptest" - "strings" "sync/atomic" "testing" @@ -18,7 +17,7 @@ func TestResolveSymbol_FirstTime_QueriesAndCaches(t *testing.T) { var hits int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { atomic.AddInt32(&hits, 1) - _, _ = w.Write([]byte(`{"data_day":[{"c":24500}]}`)) + _, _ = w.Write([]byte(`{"data":{"stockSymbol":"TCB","matchedPrice":24500}}`)) })) defer srv.Close() prices := &PriceClient{URL: srv.URL} @@ -31,23 +30,23 @@ func TestResolveSymbol_FirstTime_QueriesAndCaches(t *testing.T) { t.Errorf("resolved: got %+v, want {TCB stock TCB}", got) } if atomic.LoadInt32(&hits) != 1 { - t.Errorf("KBS hits: got %d, want 1", hits) + t.Errorf("price hits: got %d, want 1", hits) } - // Second call should hit the cache, not KBS. + // Second call should hit the cache, not the price provider. _, err = ResolveSymbol(context.Background(), kv, prices, "TCB") if err != nil { t.Fatalf("ResolveSymbol (cached): %v", err) } if atomic.LoadInt32(&hits) != 1 { - t.Errorf("KBS hits after cache: got %d, want 1 (cached)", hits) + t.Errorf("price hits after cache: got %d, want 1 (cached)", hits) } } func TestResolveSymbol_Unknown(t *testing.T) { kv := storage.NewMemoryKVStore() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte(`{"data_day":[]}`)) + _, _ = w.Write([]byte(`{"data":{"stockSymbol":"NOPE","matchedPrice":0}}`)) })) defer srv.Close() prices := &PriceClient{URL: srv.URL} @@ -68,11 +67,11 @@ func TestResolveSymbol_EmptyInput(t *testing.T) { func TestResolveSymbol_NormalizesCase(t *testing.T) { kv := storage.NewMemoryKVStore() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // KBS endpoint should receive the upper-cased ticker. - if !strings.Contains(r.URL.Path, "/FPT/") { + // SSI endpoint should receive the upper-cased ticker. + if r.URL.Path != "/stock/FPT" { t.Errorf("ticker not upper-cased in URL: %s", r.URL.Path) } - _, _ = w.Write([]byte(`{"data_day":[{"c":120000}]}`)) + _, _ = w.Write([]byte(`{"data":{"stockSymbol":"FPT","matchedPrice":120000}}`)) })) defer srv.Close() prices := &PriceClient{URL: srv.URL}