diff --git a/README.md b/README.md index 490262d..dc477b4 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,16 @@ chronological order, split into Telegram-safe chunks when needed, and show the raw SSI corporate-action details. The feature is best-effort because SSI's API is undocumented. +### Stock quote details + +`/stock_info ` shows a compact SSI iBoard quote snapshot: company, +exchange, current price, gain/loss since open, change versus the reference +price, open/high/low prices, and normal traded volume. It makes exactly one +SSI single-ticker request and does not use the KBS or VCI price fallbacks. +Unavailable optional fields are shown as `N/A`. This read-only command is +best-effort because SSI's API is undocumented. The existing `/stock_price` +command and its provider fallbacks are unchanged. + ### Stock dividend commands Stock dividends are manual portfolio adjustments: diff --git a/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go index 514ad56..08dc52a 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -70,6 +70,7 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { "random": "", "stats": "[users | user | cmd ]", "stock_events": " [days]", + "stock_info": "", "stock_price": "", "stock_topup": "", "stock_buy": " ", diff --git a/internal/modules/stock/handlers_test.go b/internal/modules/stock/handlers_test.go index 2b576dd..fbdbcc9 100644 --- a/internal/modules/stock/handlers_test.go +++ b/internal/modules/stock/handlers_test.go @@ -24,6 +24,7 @@ func TestModuleRegistersExpectedCommands(t *testing.T) { } for _, name := range []string{ "stock_price", + "stock_info", "stock_events", "stock_topup", "stock_buy", diff --git a/internal/modules/stock/prices_ssi.go b/internal/modules/stock/prices_ssi.go index 80eaa68..876c98d 100644 --- a/internal/modules/stock/prices_ssi.go +++ b/internal/modules/stock/prices_ssi.go @@ -21,6 +21,10 @@ type ssiSingleResponse struct { Data ssiStockQuote `json:"data"` } +type ssiQuoteDetailResponse struct { + Data ssiStockQuoteDetail `json:"data"` +} + type ssiMultipleResponse struct { Data []ssiStockQuote `json:"data"` } @@ -30,6 +34,19 @@ type ssiStockQuote struct { MatchedPrice float64 `json:"matchedPrice"` } +type ssiStockQuoteDetail struct { + StockSymbol string `json:"stockSymbol"` + CompanyNameVi string `json:"companyNameVi"` + CompanyNameEn string `json:"companyNameEn"` + Exchange string `json:"exchange"` + RefPrice float64 `json:"refPrice"` + OpenPrice float64 `json:"openPrice"` + Highest float64 `json:"highest"` + Lowest float64 `json:"lowest"` + MatchedPrice float64 `json:"matchedPrice"` + NMTotalTradedQty *float64 `json:"nmTotalTradedQty"` +} + func (c *PriceClient) baseURL() string { if c.URL != "" { return strings.TrimRight(c.URL, "/") @@ -58,6 +75,35 @@ func (c *PriceClient) fetchSSIPrice(ctx context.Context, ticker string) (float64 return price, nil } +// fetchSSIQuote returns the detailed SSI quote using exactly one single-stock +// request. It intentionally bypasses FetchPrice and its KBS/VCI fallbacks. +func (c *PriceClient) fetchSSIQuote(ctx context.Context, ticker string) (ssiStockQuoteDetail, error) { + if ticker == "" { + return ssiStockQuoteDetail{}, errors.New("stock: ticker is empty") + } + full := c.baseURL() + "/stock/" + url.PathEscape(ticker) + req, err := newSSIRequest(ctx, http.MethodGet, full, nil) + if err != nil { + return ssiStockQuoteDetail{}, err + } + + // A redirect would turn one command into multiple outbound requests. Clone + // the client shallowly so only this detail request disables redirects. + client := *c.httpClient() + client.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + + var body ssiQuoteDetailResponse + if err := doSSIJSON(&client, req, &body); err != nil { + return ssiStockQuoteDetail{}, err + } + if body.Data.MatchedPrice <= 0 { + return ssiStockQuoteDetail{}, fmt.Errorf("%w: SSI quote has no matchedPrice for %s", ErrNoPrice, strings.ToUpper(ticker)) + } + return body.Data, nil +} + func (c *PriceClient) fetchSSIPrices(ctx context.Context, tickers []string) (map[string]float64, error) { if len(tickers) == 0 { return map[string]float64{}, nil @@ -108,7 +154,11 @@ func newSSIRequest(ctx context.Context, method, full string, body io.Reader) (*h } func (c *PriceClient) doSSIJSON(req *http.Request, dst any) error { - resp, err := c.httpClient().Do(req) + return doSSIJSON(c.httpClient(), req, dst) +} + +func doSSIJSON(client *http.Client, req *http.Request, dst any) error { + resp, err := client.Do(req) if err != nil { return fmt.Errorf("stock: SSI request: %w", err) } diff --git a/internal/modules/stock/prices_test.go b/internal/modules/stock/prices_test.go index 0f810f7..b7f487a 100644 --- a/internal/modules/stock/prices_test.go +++ b/internal/modules/stock/prices_test.go @@ -44,6 +44,179 @@ func TestPriceClient_HappyPath(t *testing.T) { } } +func TestPriceClient_FetchSSIQuoteOneRequest(t *testing.T) { + requests := 0 + c, _ := newTestPriceClient(t, func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("method = %s, want GET", r.Method) + } + if r.URL.Path != "/stock/TCB" { + t.Errorf("path = %q, want /stock/TCB", r.URL.Path) + } + for header, want := range map[string]string{ + "Accept": "application/json", + "Origin": "https://iboard.ssi.com.vn", + "Referer": "https://iboard.ssi.com.vn/", + } { + if got := r.Header.Get(header); got != want { + t.Errorf("%s = %q, want %q", header, got, want) + } + } + if got := r.Header.Get("User-Agent"); !strings.Contains(got, "miti99bot") { + t.Errorf("User-Agent = %q, want miti99bot", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{ + "stockSymbol":"TCB", + "companyNameVi":"Ngân hàng TMCP Kỹ Thương Việt Nam", + "companyNameEn":"Vietnam Technological and Commercial Joint Stock Bank", + "exchange":"HOSE", + "refPrice":30000, + "openPrice":29500, + "highest":31000, + "lowest":29200, + "matchedPrice":30500, + "nmTotalTradedQty":1234567 + }}`)) + }) + + got, err := c.fetchSSIQuote(context.Background(), "TCB") + if err != nil { + t.Fatalf("fetchSSIQuote: %v", err) + } + if requests != 1 { + t.Fatalf("requests = %d, want 1", requests) + } + if got.StockSymbol != "TCB" || + got.CompanyNameVi != "Ngân hàng TMCP Kỹ Thương Việt Nam" || + got.CompanyNameEn != "Vietnam Technological and Commercial Joint Stock Bank" || + got.Exchange != "HOSE" || + got.RefPrice != 30000 || + got.OpenPrice != 29500 || + got.Highest != 31000 || + got.Lowest != 29200 || + got.MatchedPrice != 30500 || + got.NMTotalTradedQty == nil || + *got.NMTotalTradedQty != 1234567 { + t.Fatalf("quote = %+v", got) + } +} + +func TestPriceClient_LegacyQuotesIgnoreDetailSchemaDrift(t *testing.T) { + t.Run("single", func(t *testing.T) { + c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{ + "stockSymbol":"TCB", + "matchedPrice":24500, + "companyNameVi":{"unexpected":"object"}, + "companyNameEn":123, + "exchange":false, + "refPrice":"bad", + "openPrice":null, + "highest":[], + "lowest":{}, + "nmTotalTradedQty":"unknown" + }}`)) + }) + got, err := c.FetchPrice(context.Background(), "TCB") + if err != nil { + t.Fatalf("FetchPrice: %v", err) + } + if got != 24500 { + t.Fatalf("price = %v, want 24500", got) + } + }) + + t.Run("batch", func(t *testing.T) { + c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":[{ + "stockSymbol":"TCB", + "matchedPrice":24500, + "companyNameVi":123, + "exchange":{"unexpected":"object"}, + "openPrice":"bad", + "nmTotalTradedQty":null + }]}`)) + }) + got, err := c.FetchPrices(context.Background(), []string{"TCB"}) + if err != nil { + t.Fatalf("FetchPrices: %v", err) + } + if got["TCB"] != 24500 { + t.Fatalf("prices = %+v, want TCB=24500", got) + } + }) +} + +func TestPriceClient_FetchSSIQuoteStopsRedirectWithoutMutatingLegacyClient(t *testing.T) { + requests := 0 + c, srv := newTestPriceClient(t, func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.URL.Path == "/stock/TCB" { + http.Redirect(w, r, "/quote", http.StatusFound) + return + } + if r.URL.Path != "/quote" { + t.Errorf("path = %q, want /quote", r.URL.Path) + } + _, _ = w.Write([]byte(`{"data":{"stockSymbol":"TCB","matchedPrice":24500}}`)) + }) + + _, err := c.fetchSSIQuote(context.Background(), "TCB") + if err == nil || !strings.Contains(err.Error(), "SSI status 302") { + t.Fatalf("fetchSSIQuote error = %v, want SSI status 302", err) + } + if requests != 1 { + t.Fatalf("detail requests = %d, want 1", requests) + } + + requests = 0 + got, err := c.FetchPrice(context.Background(), "TCB") + if err != nil { + t.Fatalf("legacy FetchPrice through redirect: %v", err) + } + if got != 24500 || requests != 2 { + t.Fatalf("legacy price = %v, requests = %d; want 24500 and 2", got, requests) + } + if c.HTTP.CheckRedirect != nil { + t.Fatalf("shared client CheckRedirect was mutated after request to %s", srv.URL) + } +} + +func TestPriceClient_FetchSSIQuoteFailureUsesOneRequest(t *testing.T) { + for _, tc := range []struct { + name string + body string + want error + }{ + {name: "no price", body: `{"data":{"stockSymbol":"TCB","matchedPrice":0}}`, want: ErrNoPrice}, + {name: "malformed", body: `{"data":`, want: nil}, + } { + t.Run(tc.name, func(t *testing.T) { + requests := 0 + c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) { + requests++ + _, _ = w.Write([]byte(tc.body)) + }) + + _, err := c.fetchSSIQuote(context.Background(), "TCB") + if err == nil { + t.Fatal("fetchSSIQuote: expected error") + } + if tc.want != nil && !errors.Is(err, tc.want) { + t.Fatalf("error = %v, want %v", err, tc.want) + } + if tc.want == nil && !strings.Contains(err.Error(), "SSI decode") { + t.Fatalf("error = %v, want SSI decode error", err) + } + if requests != 1 { + t.Fatalf("requests = %d, want 1", requests) + } + }) + } +} + func TestPriceClient_BatchHappyPath(t *testing.T) { c, _ := newTestPriceClient(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/internal/modules/stock/stock.go b/internal/modules/stock/stock.go index df6c231..6588fa6 100644 --- a/internal/modules/stock/stock.go +++ b/internal/modules/stock/stock.go @@ -5,7 +5,7 @@ import ( "github.com/tiennm99/miti99bot/internal/storage" ) -// New is the stock module Factory. Eight user-facing commands. +// New is the stock module Factory. Nine user-facing commands. func New(deps modules.Deps) modules.Module { s := newState( storage.Typed[Portfolio](deps.Store), @@ -28,6 +28,13 @@ func New(deps modules.Deps) modules.Module { Parameters: "", Handler: s.handlePrice, }, + { + Name: "stock_info", + Visibility: modules.VisibilityPublic, + Description: "Show detailed SSI quote for a VN stock", + Parameters: "", + Handler: s.handleStockInfo, + }, { Name: "stock_topup", Visibility: modules.VisibilityPublic, diff --git a/internal/modules/stock/stock_info.go b/internal/modules/stock/stock_info.go new file mode 100644 index 0000000..e4c8b1b --- /dev/null +++ b/internal/modules/stock/stock_info.go @@ -0,0 +1,119 @@ +package stock + +import ( + "context" + "errors" + "math" + "strconv" + "strings" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/log" + "github.com/tiennm99/miti99bot/internal/modules/util/chathelper" +) + +const ( + stockInfoCompanyLimit = 240 + stockInfoExchangeLimit = 40 + stockInfoReplyLimit = 3990 +) + +func (s *state) handleStockInfo(ctx context.Context, b *bot.Bot, update *models.Update) error { + args := argsAfterCommand(update.Message.Text) + if len(args) != 1 { + return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_info ") + } + + symbol, err := normalizeStockSymbol(args[0]) + if err != nil { + if errors.Is(err, ErrUnknownTicker) { + return chathelper.Reply(ctx, b, update.Message, "Unknown stock ticker \""+strings.ToUpper(args[0])+"\".") + } + return chathelper.Reply(ctx, b, update.Message, "Could not parse that ticker. Try again later.") + } + + fetchCtx, cancel := chathelper.FetchContext(ctx) + defer cancel() + quote, err := s.prices.fetchSSIQuote(fetchCtx, symbol) + if err != nil { + if errors.Is(err, ErrNoPrice) { + return chathelper.Reply(ctx, b, update.Message, "No stock information available for "+symbol+".") + } + log.Error("stock_fetch_info", "ticker", symbol, "err", err) + return chathelper.Reply(ctx, b, update.Message, "Could not fetch stock information for "+symbol+". Try again later.") + } + return chathelper.Reply(ctx, b, update.Message, formatStockInfo(symbol, quote)) +} + +func formatStockInfo(symbol string, quote ssiStockQuoteDetail) string { + title := symbol + if company := stockInfoCompanyName(quote); company != "" { + title += " — " + company + } + + reply := strings.Join([]string{ + title, + "Exchange: " + stockInfoExchange(quote.Exchange), + "Current: " + stockInfoPrice(quote.MatchedPrice), + "Since open: " + stockInfoChange(quote.MatchedPrice, quote.OpenPrice), + "Vs reference: " + stockInfoChange(quote.MatchedPrice, quote.RefPrice), + "Open: " + stockInfoPrice(quote.OpenPrice), + "High: " + stockInfoPrice(quote.Highest), + "Low: " + stockInfoPrice(quote.Lowest), + "Volume: " + stockInfoVolume(quote.NMTotalTradedQty), + }, "\n") + return truncateRunes(reply, stockInfoReplyLimit) +} + +func stockInfoCompanyName(quote ssiStockQuoteDetail) string { + if name := strings.TrimSpace(quote.CompanyNameVi); name != "" { + return truncateRunes(name, stockInfoCompanyLimit) + } + return truncateRunes(strings.TrimSpace(quote.CompanyNameEn), stockInfoCompanyLimit) +} + +func stockInfoExchange(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "N/A" + } + return truncateRunes(value, stockInfoExchangeLimit) +} + +func stockInfoPrice(value float64) string { + if !isPositiveFiniteCost(value) { + return "N/A" + } + return FormatVND(value) +} + +func stockInfoVolume(value *float64) string { + if value == nil || *value < 0 || math.IsNaN(*value) || math.IsInf(*value, 0) { + return "N/A" + } + return formatVNDNumber(*value) +} + +func stockInfoChange(current, baseline float64) string { + if !isPositiveFiniteCost(current) || !isPositiveFiniteCost(baseline) { + return "N/A" + } + diff := current - baseline + percentage := diff / baseline * 100 + if math.IsNaN(diff) || math.IsInf(diff, 0) || math.IsNaN(percentage) || math.IsInf(percentage, 0) { + return "N/A" + } + if diff == 0 { + return "0 VND (0.00%)" + } + + amount := FormatVND(diff) + percentageText := strconv.FormatFloat(percentage, 'f', 2, 64) + "%" + if diff > 0 { + amount = "+" + amount + percentageText = "+" + percentageText + } + return amount + " (" + percentageText + ")" +} diff --git a/internal/modules/stock/stock_info_test.go b/internal/modules/stock/stock_info_test.go new file mode 100644 index 0000000..e17d744 --- /dev/null +++ b/internal/modules/stock/stock_info_test.go @@ -0,0 +1,294 @@ +package stock + +import ( + "context" + "encoding/json" + "math" + "net/http" + "net/http/httptest" + "strings" + "testing" + "unicode/utf8" + + "github.com/tiennm99/miti99bot/internal/testutil" +) + +func newStockInfoTestState(t *testing.T, ssiHandler http.HandlerFunc) (*state, *int, *int) { + t.Helper() + + ssiRequests := 0 + ssiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ssiRequests++ + ssiHandler(w, r) + })) + t.Cleanup(ssiServer.Close) + + fallbackRequests := 0 + fallbackServer := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + fallbackRequests++ + })) + t.Cleanup(fallbackServer.Close) + + s := &state{prices: &PriceClient{ + HTTP: ssiServer.Client(), + URL: ssiServer.URL, + KBSURL: fallbackServer.URL, + VCIURL: fallbackServer.URL, + }} + return s, &ssiRequests, &fallbackRequests +} + +func TestStockInfoCommandRegistration(t *testing.T) { + mod := New(modDepsForTest()) + for _, command := range mod.Commands { + if command.Name != "stock_info" { + continue + } + if command.Parameters != "" || command.Description != "Show detailed SSI quote for a VN stock" { + t.Fatalf("stock_info metadata = params %q description %q", command.Parameters, command.Description) + } + return + } + t.Fatal("stock_info command is not registered") +} + +func TestHandleStockInfoOneSSIRequestAndSenderless(t *testing.T) { + s, ssiRequests, fallbackRequests := newStockInfoTestState(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/stock/TCB" { + t.Errorf("request = %s %s, want GET /stock/TCB", r.Method, r.URL.Path) + } + if r.Header.Get("Origin") != "https://iboard.ssi.com.vn" { + t.Errorf("Origin = %q", r.Header.Get("Origin")) + } + _, _ = w.Write([]byte(`{"data":{ + "stockSymbol":"TCB", + "companyNameVi":"Ngân hàng Kỹ Thương", + "companyNameEn":"Techcombank", + "exchange":"HOSE", + "refPrice":30000, + "openPrice":29000, + "highest":31500, + "lowest":28500, + "matchedPrice":30500, + "nmTotalTradedQty":1234567 + }}`)) + }) + rb := testutil.NewRecordingBot(t) + if err := s.handleStockInfo(context.Background(), rb.Bot, testutil.NewChannelMessage(-100, "/stock_info tcb")); err != nil { + t.Fatalf("handleStockInfo: %v", err) + } + if *ssiRequests != 1 { + t.Fatalf("SSI requests = %d, want 1", *ssiRequests) + } + if *fallbackRequests != 0 { + t.Fatalf("fallback requests = %d, want 0", *fallbackRequests) + } + want := strings.Join([]string{ + "TCB — Ngân hàng Kỹ Thương", + "Exchange: HOSE", + "Current: 30.500 VND", + "Since open: +1.500 VND (+5.17%)", + "Vs reference: +500 VND (+1.67%)", + "Open: 29.000 VND", + "High: 31.500 VND", + "Low: 28.500 VND", + "Volume: 1.234.567", + }, "\n") + if got := rb.LastSent().Text(); got != want { + t.Fatalf("reply:\n%q\nwant:\n%q", got, want) + } +} + +func TestFormatStockInfoNegativeChanges(t *testing.T) { + volume := 100.0 + got := formatStockInfo("FPT", ssiStockQuoteDetail{ + CompanyNameVi: "FPT", + Exchange: "HOSE", + RefPrice: 125000, + OpenPrice: 124000, + Highest: 126000, + Lowest: 120000, + MatchedPrice: 121000, + NMTotalTradedQty: &volume, + }) + if !strings.Contains(got, "Since open: -3.000 VND (-2.42%)") { + t.Errorf("missing negative since-open change: %q", got) + } + if !strings.Contains(got, "Vs reference: -4.000 VND (-3.20%)") { + t.Errorf("missing negative reference change: %q", got) + } +} + +func TestFormatStockInfoOptionalFieldsAndCompanyFallback(t *testing.T) { + quote := ssiStockQuoteDetail{ + CompanyNameEn: "Techcombank", + MatchedPrice: 30000, + } + got := formatStockInfo("TCB", quote) + want := strings.Join([]string{ + "TCB — Techcombank", + "Exchange: N/A", + "Current: 30.000 VND", + "Since open: N/A", + "Vs reference: N/A", + "Open: N/A", + "High: N/A", + "Low: N/A", + "Volume: N/A", + }, "\n") + if got != want { + t.Fatalf("reply:\n%q\nwant:\n%q", got, want) + } + + quote.CompanyNameEn = "" + if got := formatStockInfo("TCB", quote); !strings.HasPrefix(got, "TCB\n") { + t.Fatalf("missing-company title = %q, want ticker only", got) + } +} + +func TestFormatStockInfoChangeBoundaries(t *testing.T) { + if got := stockInfoChange(30000, 30000); got != "0 VND (0.00%)" { + t.Fatalf("zero change = %q, want neutral zero", got) + } + if got := stockInfoChange(math.MaxFloat64, 1); got != "N/A" { + t.Fatalf("overflowing percentage = %q, want N/A", got) + } +} + +func TestFormatStockInfoVolumeBoundaries(t *testing.T) { + zero := 0.0 + negative := -1.0 + infinite := math.Inf(1) + for _, tc := range []struct { + name string + value *float64 + want string + }{ + {name: "missing", value: nil, want: "N/A"}, + {name: "zero", value: &zero, want: "0"}, + {name: "negative", value: &negative, want: "N/A"}, + {name: "nonfinite", value: &infinite, want: "N/A"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := stockInfoVolume(tc.value); got != tc.want { + t.Fatalf("stockInfoVolume = %q, want %q", got, tc.want) + } + }) + } + if got := formatStockInfo("TCB", ssiStockQuoteDetail{MatchedPrice: 30000, NMTotalTradedQty: &zero}); !strings.Contains(got, "Volume: 0") { + t.Fatalf("explicit-zero reply = %q, want Volume: 0", got) + } +} + +func TestHandleStockInfoBoundsOversizedUpstreamText(t *testing.T) { + s, ssiRequests, fallbackRequests := newStockInfoTestState(t, func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ + "stockSymbol": "TCB", + "companyNameVi": strings.Repeat("ổ", 10000), + "exchange": strings.Repeat("X", 10000), + "matchedPrice": 30000, + }}) + }) + rb := testutil.NewRecordingBot(t) + if err := s.handleStockInfo(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_info TCB")); err != nil { + t.Fatalf("handleStockInfo: %v", err) + } + reply := rb.LastSent().Text() + if got := utf8.RuneCountInString(reply); got >= 4000 { + t.Fatalf("reply rune count = %d, want below 4000", got) + } + lines := strings.Split(reply, "\n") + if got := utf8.RuneCountInString(lines[0]); got > utf8.RuneCountInString("TCB — ")+stockInfoCompanyLimit { + t.Fatalf("title rune count = %d, company limit not applied", got) + } + if got := utf8.RuneCountInString(strings.TrimPrefix(lines[1], "Exchange: ")); got > stockInfoExchangeLimit { + t.Fatalf("exchange rune count = %d, limit not applied", got) + } + if *ssiRequests != 1 || *fallbackRequests != 0 { + t.Fatalf("requests: SSI=%d fallback=%d", *ssiRequests, *fallbackRequests) + } +} + +func TestHandleStockInfoValidationDoesNotRequestSSI(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + requests++ + })) + t.Cleanup(server.Close) + s := &state{prices: &PriceClient{HTTP: server.Client(), URL: server.URL}} + rb := testutil.NewRecordingBot(t) + + for _, tc := range []struct { + command string + want string + }{ + {"/stock_info", "Usage: /stock_info "}, + {"/stock_info TCB extra", "Usage: /stock_info "}, + {"/stock_info $$$", "Unknown stock ticker \"$$$\"."}, + } { + rb.Reset() + if err := s.handleStockInfo(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, tc.command)); err != nil { + t.Fatalf("%q: %v", tc.command, err) + } + rb.AssertSentText(t, tc.want) + } + if requests != 0 { + t.Fatalf("SSI requests = %d, want 0", requests) + } +} + +func TestHandleStockInfoFailureUsesOneSSIRequestAndNoFallback(t *testing.T) { + for _, tc := range []struct { + name string + status int + location string + body string + want string + }{ + { + name: "no matched price", + body: `{"data":{"stockSymbol":"TCB","matchedPrice":0}}`, + want: "No stock information available for TCB.", + }, + { + name: "malformed response", + body: `{"data":`, + want: "Could not fetch stock information for TCB. Try again later.", + }, + { + name: "upstream error", + status: http.StatusBadGateway, + body: "upstream unavailable", + want: "Could not fetch stock information for TCB. Try again later.", + }, + { + name: "redirect", + status: http.StatusFound, + location: "/redirect-target", + want: "Could not fetch stock information for TCB. Try again later.", + }, + } { + t.Run(tc.name, func(t *testing.T) { + s, ssiRequests, fallbackRequests := newStockInfoTestState(t, func(w http.ResponseWriter, _ *http.Request) { + if tc.location != "" { + w.Header().Set("Location", tc.location) + } + if tc.status != 0 { + w.WriteHeader(tc.status) + } + _, _ = w.Write([]byte(tc.body)) + }) + rb := testutil.NewRecordingBot(t) + if err := s.handleStockInfo(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_info TCB")); err != nil { + t.Fatalf("handleStockInfo: %v", err) + } + rb.AssertSentText(t, tc.want) + if *ssiRequests != 1 { + t.Fatalf("SSI requests = %d, want 1", *ssiRequests) + } + if *fallbackRequests != 0 { + t.Fatalf("fallback requests = %d, want 0", *fallbackRequests) + } + }) + } +}