feat(stock): add detailed stock info command

This commit is contained in:
2026-07-23 12:27:12 +07:00
parent 34ce93aa69
commit 26b969b5ef
8 changed files with 657 additions and 2 deletions
+10
View File
@@ -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 <ticker>` 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:
+1
View File
@@ -70,6 +70,7 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
"random": "<option,...>",
"stats": "[users | user <username> | cmd <command_name>]",
"stock_events": "<ticker> [days]",
"stock_info": "<ticker>",
"stock_price": "<ticker>",
"stock_topup": "<vnd_amount>",
"stock_buy": "<quantity> <ticker>",
+1
View File
@@ -24,6 +24,7 @@ func TestModuleRegistersExpectedCommands(t *testing.T) {
}
for _, name := range []string{
"stock_price",
"stock_info",
"stock_events",
"stock_topup",
"stock_buy",
+51 -1
View File
@@ -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)
}
+173
View File
@@ -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 {
+8 -1
View File
@@ -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: "<ticker>",
Handler: s.handlePrice,
},
{
Name: "stock_info",
Visibility: modules.VisibilityPublic,
Description: "Show detailed SSI quote for a VN stock",
Parameters: "<ticker>",
Handler: s.handleStockInfo,
},
{
Name: "stock_topup",
Visibility: modules.VisibilityPublic,
+119
View File
@@ -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 <ticker>")
}
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 + ")"
}
+294
View File
@@ -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 != "<ticker>" || 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 <ticker>"},
{"/stock_info TCB extra", "Usage: /stock_info <ticker>"},
{"/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)
}
})
}
}