mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-04 16:17:18 +00:00
feat(stock): add detailed stock info command
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -24,6 +24,7 @@ func TestModuleRegistersExpectedCommands(t *testing.T) {
|
||||
}
|
||||
for _, name := range []string{
|
||||
"stock_price",
|
||||
"stock_info",
|
||||
"stock_events",
|
||||
"stock_topup",
|
||||
"stock_buy",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 + ")"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user