feat(stock): add stock events lookup

This commit is contained in:
2026-07-23 10:03:41 +07:00
parent e59da33241
commit 7123ea77c3
9 changed files with 550 additions and 25 deletions
+9
View File
@@ -37,6 +37,15 @@ Future commands must follow the
[command parameter conventions](docs/command-parameter-conventions.md). Keep
command metadata, handler usage text, tests, and documentation aligned.
### Stock corporate events
`/stock_events <ticker> [days]` lists SSI iBoard corporate actions for a VN
stock without reading or changing a portfolio. The lookback defaults to 30
days; `days` must be a whole number from 1 to 90. Results are returned in
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 dividend commands
Stock dividends are manual portfolio adjustments:
+1
View File
@@ -69,6 +69,7 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
"loldle": "[champion]",
"random": "<option,...>",
"stats": "[users | user <username> | cmd <command_name>]",
"stock_events": "<ticker> [days]",
"stock_price": "<ticker>",
"stock_topup": "<vnd_amount>",
"stock_buy": "<quantity> <ticker>",
+122 -23
View File
@@ -111,30 +111,100 @@ func (p *SSIDividendProvider) endpoint() string {
// described share dividends. SSI is queried with a one-calendar-day overlap in
// Asia/Saigon, then results are filtered by publication time to (after, through].
func (p *SSIDividendProvider) FetchDividendEvents(ctx context.Context, symbol string, after, through time.Time) ([]DividendEvent, error) {
symbol = strings.ToUpper(strings.TrimSpace(symbol))
if symbol == "" {
return nil, errors.New("stock: dividend symbol is empty")
}
if after.IsZero() || through.IsZero() || !through.After(after) {
return nil, errors.New("stock: invalid dividend event range")
symbol, from, to, err := prepareSSIEventFetch(
symbol,
after,
through,
"stock: dividend symbol is empty",
"stock: invalid dividend event range",
)
if err != nil {
return nil, err
}
from := after.In(saigonLocation).AddDate(0, 0, -1)
to := through.In(saigonLocation)
rawEvents, err := p.fetchUniqueCorporateActions(ctx, symbol, from, to, "dividend")
if err != nil {
return nil, err
}
lowerBound := startOfSaigonDay(after).AddDate(0, 0, -1)
seen := make(map[string]struct{})
events := make([]DividendEvent, 0)
for _, raw := range rawEvents {
event, ok := p.normalizeEvent(raw, symbol)
if !ok || event.PublishedAt.Before(lowerBound) || event.PublishedAt.After(through) {
continue
}
events = append(events, event)
}
sort.Slice(events, func(i, j int) bool {
if events[i].PublishedAt.Equal(events[j].PublishedAt) {
return events[i].ProviderID < events[j].ProviderID
}
return events[i].PublishedAt.Before(events[j].PublishedAt)
})
return events, nil
}
// FetchStockEvents returns all displayable SSI corporate actions. It is
// intentionally separate from FetchDividendEvents so read-only event listing
// cannot relax portfolio dividend validation.
func (p *SSIDividendProvider) FetchStockEvents(ctx context.Context, symbol string, after, through time.Time) ([]SSIStockEvent, error) {
symbol, from, to, err := prepareSSIEventFetch(
symbol,
after,
through,
"stock: event symbol is empty",
"stock: invalid stock event range",
)
if err != nil {
return nil, err
}
rawEvents, err := p.fetchUniqueCorporateActions(ctx, symbol, from, to, "event")
if err != nil {
return nil, err
}
events := make([]SSIStockEvent, 0)
for _, raw := range rawEvents {
event, ok := p.copyStockEvent(raw, symbol)
if !ok || !event.cursorAt.After(after) || event.cursorAt.After(through) {
continue
}
events = append(events, event)
}
sort.Slice(events, func(i, j int) bool {
if events[i].cursorAt.Equal(events[j].cursorAt) {
return events[i].CorID < events[j].CorID
}
return events[i].cursorAt.Before(events[j].cursorAt)
})
return events, nil
}
func prepareSSIEventFetch(symbol string, after, through time.Time, emptyErr, rangeErr string) (normalized string, from, to time.Time, err error) {
normalized = strings.ToUpper(strings.TrimSpace(symbol))
if normalized == "" {
return "", time.Time{}, time.Time{}, errors.New(emptyErr)
}
if after.IsZero() || through.IsZero() || !through.After(after) {
return "", time.Time{}, time.Time{}, errors.New(rangeErr)
}
return normalized, after.In(saigonLocation).AddDate(0, 0, -1), through.In(saigonLocation), nil
}
func (p *SSIDividendProvider) fetchUniqueCorporateActions(ctx context.Context, symbol string, from, to time.Time, kind string) ([]ssiDividendEvent, error) {
seen := make(map[string]struct{})
events := make([]ssiDividendEvent, 0)
totalPages := 1
for page := 1; page <= totalPages; page++ {
if page > ssiDividendMaxPages {
return nil, fmt.Errorf("stock: SSI dividend response exceeds %d pages", ssiDividendMaxPages)
return nil, fmt.Errorf("stock: SSI %s response exceeds %d pages", kind, ssiDividendMaxPages)
}
body, err := p.fetchPage(ctx, symbol, from, to, page)
if err != nil {
return nil, fmt.Errorf("stock: fetch SSI dividend page %d: %w", page, err)
return nil, fmt.Errorf("stock: fetch SSI %s page %d: %w", kind, page, err)
}
if body.Paging.TotalPage > ssiDividendMaxPages {
return nil, fmt.Errorf("stock: SSI dividend response has %d pages, maximum is %d", body.Paging.TotalPage, ssiDividendMaxPages)
return nil, fmt.Errorf("stock: SSI %s response has %d pages, maximum is %d", kind, body.Paging.TotalPage, ssiDividendMaxPages)
}
if body.Paging.TotalPage > totalPages {
totalPages = body.Paging.TotalPage
@@ -148,22 +218,51 @@ func (p *SSIDividendProvider) FetchDividendEvents(ctx context.Context, symbol st
continue
}
seen[id] = struct{}{}
event, ok := p.normalizeEvent(raw, symbol)
if !ok || event.PublishedAt.Before(lowerBound) || event.PublishedAt.After(through) {
continue
}
events = append(events, event)
events = append(events, raw)
}
}
sort.Slice(events, func(i, j int) bool {
if events[i].PublishedAt.Equal(events[j].PublishedAt) {
return events[i].ProviderID < events[j].ProviderID
}
return events[i].PublishedAt.Before(events[j].PublishedAt)
})
return events, nil
}
func (p *SSIDividendProvider) copyStockEvent(raw ssiDividendEvent, requestedSymbol string) (SSIStockEvent, bool) {
symbol := strings.ToUpper(strings.TrimSpace(raw.Symbol))
if symbol != requestedSymbol {
return SSIStockEvent{}, false
}
cursorAt, ok := ssiStockEventCursor(raw)
if !ok {
return SSIStockEvent{}, false
}
return SSIStockEvent{
CorID: strings.TrimSpace(raw.CorID),
Symbol: symbol,
EventListCode: strings.TrimSpace(raw.EventListCode),
EventName: strings.TrimSpace(raw.EventName),
EventTitle: strings.TrimSpace(raw.EventTitle),
EventDescription: strings.TrimSpace(raw.EventDescription),
PublicDate: strings.TrimSpace(raw.PublicDate),
ExrightDate: strings.TrimSpace(raw.ExrightDate),
RecordDate: strings.TrimSpace(raw.RecordDate),
IssueDate: strings.TrimSpace(raw.IssueDate),
Value: strings.TrimSpace(string(raw.Value)),
Ratio: strings.TrimSpace(string(raw.Ratio)),
SourceURL: p.eventSourceURL(symbol, raw.CorID),
cursorAt: cursorAt,
}, true
}
func ssiStockEventCursor(raw ssiDividendEvent) (time.Time, bool) {
if strings.TrimSpace(raw.PublicDate) != "" {
return parseSSIDate(raw.PublicDate)
}
for _, candidate := range []string{raw.ExrightDate, raw.RecordDate, raw.IssueDate} {
if parsed, ok := parseSSIDate(candidate); ok {
return parsed, true
}
}
return time.Time{}, false
}
func startOfSaigonDay(value time.Time) time.Time {
local := value.In(saigonLocation)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, saigonLocation)
@@ -79,6 +79,90 @@ func TestSSIDividendProviderPaginatesDeduplicatesFiltersAndNormalizes(t *testing
}
}
func TestSSIStockEventProviderIncludesAllTypesPaginatesDeduplicatesFiltersAndOrders(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
w.Header().Set("Content-Type", "application/json")
switch page {
case 1:
_, _ = fmt.Fprint(w, `{"code":"SUCCESS","status":"ok","paging":{"totalPage":2,"page":1},"data":[
{"CorId":"meeting","symbol":"TCB","eventListCode":"AGM","eventName":"Annual meeting","eventTitle":"Meeting title","eventDescription":"Meeting description","publicDate":"11/06/2026 12:00:00","recordDate":"malformed-raw-date","value":"123.45","ratio":"0.25"},
{"CorId":"tie-b","symbol":"TCB","eventListCode":"OTHER","eventTitle":"Second by ID","publicDate":"12/06/2026 00:00:00"},
{"CorId":"at-after","symbol":"TCB","eventListCode":"OTHER","publicDate":"10/06/2026 12:00:00"},
{"CorId":"overlap-only","symbol":"TCB","eventListCode":"OTHER","publicDate":"10/06/2026 00:00:00"},
{"CorId":"too-old","symbol":"TCB","eventListCode":"OTHER","publicDate":"08/06/2026 23:59:59"},
{"CorId":"wrong-symbol","symbol":"ACB","eventListCode":"AGM","publicDate":"11/06/2026"}
]}`)
case 2:
_, _ = fmt.Fprint(w, `{"code":"SUCCESS","status":"ok","paging":{"totalPage":2,"page":2},"data":[
{"CorId":"meeting","symbol":"TCB","eventListCode":"AGM","eventTitle":"duplicate","publicDate":"11/06/2026 12:00:00"},
{"CorId":"tie-a","symbol":"TCB","eventListCode":"ISS","eventName":"Rights issue","eventTitle":"First by ID","publicDate":"12/06/2026 00:00:00","exrightDate":"20/06/2026","issueDate":"30/06/2026"},
{"CorId":"after-through","symbol":"TCB","eventListCode":"OTHER","publicDate":"12/06/2026 00:00:01"},
{"CorId":"bad-date","symbol":"TCB","eventListCode":"OTHER","publicDate":"not-a-date","exrightDate":"11/06/2026"},
{"CorId":"fallback-date","symbol":"TCB","eventListCode":"FALLBACK","eventDescription":"raw fallback event","exrightDate":"bad-optional","recordDate":"11/06/2026","issueDate":"also-bad"}
]}`)
default:
t.Fatalf("unexpected page %d", page)
}
}))
defer server.Close()
provider := &SSIDividendProvider{HTTP: server.Client(), BaseURL: server.URL}
events, err := provider.FetchStockEvents(context.Background(), " tcb ", saigonTime(t, "10/06/2026 12:00:00"), saigonTime(t, "12/06/2026 00:00:00"))
if err != nil {
t.Fatalf("FetchStockEvents: %v", err)
}
if len(events) != 4 {
t.Fatalf("events = %+v, want fallback, meeting, and two tie events", events)
}
if events[0].CorID != "fallback-date" || events[0].PublicDate != "" || events[0].ExrightDate != "bad-optional" || events[0].RecordDate != "11/06/2026" {
t.Fatalf("fallback cursor/raw fields = %+v", events[0])
}
if events[1].CorID != "meeting" || events[1].EventListCode != "AGM" || events[1].EventName != "Annual meeting" || events[1].EventTitle != "Meeting title" || events[1].EventDescription != "Meeting description" || events[1].RecordDate != "malformed-raw-date" || events[1].Value != "123.45" || events[1].Ratio != "0.25" {
t.Fatalf("generic non-dividend raw event = %+v", events[1])
}
if events[2].CorID != "tie-a" || events[3].CorID != "tie-b" {
t.Fatalf("tie order = %q, %q", events[2].CorID, events[3].CorID)
}
if events[2].ExrightDate != "20/06/2026" || events[2].IssueDate != "30/06/2026" || events[2].SourceURL == "" {
t.Fatalf("raw event dates/source missing: %+v", events[2])
}
for _, event := range events {
if event.CorID == "bad-date" {
t.Fatalf("malformed publicDate was accepted via optional fallback: %+v", event)
}
}
dividends, err := provider.FetchDividendEvents(context.Background(), "TCB", saigonTime(t, "10/06/2026 12:00:00"), saigonTime(t, "12/06/2026 00:00:00"))
if err != nil {
t.Fatalf("FetchDividendEvents: %v", err)
}
if len(dividends) != 0 {
t.Fatalf("generic events leaked into dividend path: %+v", dividends)
}
}
func TestSSIStockEventProviderRequiresEveryPageAndValidRange(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("page") == "2" {
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
return
}
_, _ = fmt.Fprint(w, `{"code":"SUCCESS","status":"ok","paging":{"totalPage":2,"page":1},"data":[]}`)
}))
defer server.Close()
provider := &SSIDividendProvider{HTTP: server.Client(), BaseURL: server.URL}
now := saigonTime(t, "12/06/2026")
if events, err := provider.FetchStockEvents(context.Background(), "TCB", now.Add(-24*time.Hour), now); err == nil || events != nil {
t.Fatalf("partial events, err = %+v, %v", events, err)
}
if _, err := provider.FetchStockEvents(context.Background(), "TCB", now, now); err == nil {
t.Fatal("equal range accepted")
}
}
func TestSSIDividendProviderUsesDayOverlapAndDateFallback(t *testing.T) {
t.Parallel()
server := dividendTestServer(t, http.StatusOK, `{"code":"SUCCESS","status":"ok","paging":{"totalPage":1,"page":1},"data":[
+4 -1
View File
@@ -25,6 +25,7 @@ type state struct {
pending PendingDividendStore
prices *PriceClient
dividends DividendEventProvider
events SSIStockEventProvider
locks keylock.Map
nowFn func() time.Time
newDividendToken func() (string, error)
@@ -39,11 +40,13 @@ func (s *state) now() time.Time {
// newState builds the default state used by the module factory.
func newState(store Store, pending PendingDividendStore) *state {
ssi := &SSIDividendProvider{}
return &state{
store: store,
pending: pending,
prices: &PriceClient{},
dividends: &SSIDividendProvider{},
dividends: ssi,
events: ssi,
}
}
+1
View File
@@ -24,6 +24,7 @@ func TestModuleRegistersExpectedCommands(t *testing.T) {
}
for _, name := range []string{
"stock_price",
"stock_events",
"stock_topup",
"stock_buy",
"stock_sell",
+8 -1
View File
@@ -5,7 +5,7 @@ import (
"github.com/tiennm99/miti99bot/internal/storage"
)
// New is the stock module Factory. Seven user-facing commands.
// New is the stock module Factory. Eight user-facing commands.
func New(deps modules.Deps) modules.Module {
s := newState(
storage.Typed[Portfolio](deps.Store),
@@ -14,6 +14,13 @@ func New(deps modules.Deps) modules.Module {
return modules.Module{
Callbacks: []modules.Callback{{Prefix: dividendCallbackPrefix, Visibility: modules.VisibilityPublic, Handler: s.handleDividendCallback}},
Commands: []modules.Command{
{
Name: "stock_events",
Visibility: modules.VisibilityPublic,
Description: "Show SSI corporate actions for a VN stock",
Parameters: "<ticker> [days]",
Handler: s.handleStockEvents,
},
{
Name: "stock_price",
Visibility: modules.VisibilityPublic,
+176
View File
@@ -0,0 +1,176 @@
package stock
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"unicode/utf8"
"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 (
stockEventsDefaultDays = 30
stockEventsMaxDays = 90
stockEventsReplyLimit = 4000
stockEventTitleLimit = 500
)
// SSIStockEventProvider returns SSI corporate-action fields for display. It is
// separate from DividendEventProvider and does not imply portfolio semantics.
type SSIStockEventProvider interface {
FetchStockEvents(ctx context.Context, symbol string, after, through time.Time) ([]SSIStockEvent, error)
}
// SSIStockEvent preserves SSI's raw corporate-action fields. cursorAt is used
// only for range filtering and deterministic ordering; it is not displayed in
// place of the source strings.
type SSIStockEvent struct {
CorID string
Symbol string
EventListCode string
EventName string
EventTitle string
EventDescription string
PublicDate string
ExrightDate string
RecordDate string
IssueDate string
Value string
Ratio string
SourceURL string
cursorAt time.Time
}
func (s *state) handleStockEvents(ctx context.Context, b *bot.Bot, update *models.Update) error {
args := argsAfterCommand(update.Message.Text)
if len(args) < 1 || len(args) > 2 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_events <ticker> [days]")
}
days := stockEventsDefaultDays
if len(args) == 2 {
parsed, err := strconv.Atoi(args[1])
if err != nil || parsed < 1 || parsed > stockEventsMaxDays {
return chathelper.Reply(ctx, b, update.Message, "Days must be a whole number from 1 to 90.")
}
days = parsed
}
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.")
}
through := s.now()
after := through.Add(-time.Duration(days) * 24 * time.Hour)
fetchCtx, cancel := chathelper.FetchContext(ctx)
defer cancel()
events, err := s.events.FetchStockEvents(fetchCtx, symbol, after, through)
if err != nil {
log.Error("stock_fetch_events", "ticker", symbol, "days", days, "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not fetch stock events for "+symbol+". Try again later.")
}
if len(events) == 0 {
return chathelper.Reply(ctx, b, update.Message, fmt.Sprintf("No stock events found for %s in the last %d days.", symbol, days))
}
blocks := make([]string, 0, len(events))
for _, event := range events {
blocks = append(blocks, formatStockEvent(event))
}
for _, reply := range chunkStockEventReplies(symbol, blocks) {
if err := chathelper.Reply(ctx, b, update.Message, reply); err != nil {
return err
}
}
return nil
}
func formatStockEvent(event SSIStockEvent) string {
code := truncateRunes(strings.TrimSpace(event.EventListCode), 80)
if code == "" {
code = "Corporate action"
}
lines := []string{event.Symbol + " SSI event · " + code}
if name := truncateRunes(strings.TrimSpace(event.EventName), 240); name != "" {
lines = append(lines, "Name: "+name)
}
if title := truncateRunes(strings.TrimSpace(event.EventTitle), stockEventTitleLimit); title != "" {
lines = append(lines, "Title: "+title)
}
if description := truncateRunes(strings.TrimSpace(event.EventDescription), 700); description != "" {
lines = append(lines, "Description: "+description)
}
if value := truncateRunes(strings.TrimSpace(event.Value), 160); value != "" {
lines = append(lines, "Value: "+value)
}
if ratio := truncateRunes(strings.TrimSpace(event.Ratio), 160); ratio != "" {
lines = append(lines, "Ratio: "+ratio)
}
for _, field := range []struct {
label string
value string
}{
{"Published", event.PublicDate},
{"Ex-right", event.ExrightDate},
{"Record", event.RecordDate},
{"Issue/payment", event.IssueDate},
} {
if value := truncateRunes(strings.TrimSpace(field.value), 100); value != "" {
lines = append(lines, field.label+": "+value)
}
}
lines = append(lines, "SSI event: "+event.CorID)
if source := strings.TrimSpace(event.SourceURL); source != "" {
lines = append(lines, "Source: "+source)
}
return strings.Join(lines, "\n")
}
func chunkStockEventReplies(symbol string, blocks []string) []string {
// Reserve enough space for the heading and part numbering so every final
// reply remains below Telegram's 4000-character safety margin.
const bodyLimit = stockEventsReplyLimit - 80
chunks := make([]string, 0, 1)
current := ""
for _, block := range blocks {
if utf8.RuneCountInString(block) > bodyLimit {
block = truncateRunes(block, bodyLimit-16) + "\n…(truncated)"
}
candidate := block
if current != "" {
candidate = current + "\n\n" + block
}
if utf8.RuneCountInString(candidate) > bodyLimit && current != "" {
chunks = append(chunks, current)
current = block
} else {
current = candidate
}
}
if current != "" {
chunks = append(chunks, current)
}
replies := make([]string, len(chunks))
for i, chunk := range chunks {
heading := symbol + " events"
if len(chunks) > 1 {
heading += fmt.Sprintf(" (%d/%d)", i+1, len(chunks))
}
replies[i] = heading + "\n\n" + chunk
}
return replies
}
+145
View File
@@ -0,0 +1,145 @@
package stock
import (
"context"
"errors"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/tiennm99/miti99bot/internal/testutil"
)
type stockEventProviderFunc func(context.Context, string, time.Time, time.Time) ([]SSIStockEvent, error)
func (f stockEventProviderFunc) FetchStockEvents(ctx context.Context, symbol string, after, through time.Time) ([]SSIStockEvent, error) {
return f(ctx, symbol, after, through)
}
func TestStockEventsCommandRegistration(t *testing.T) {
mod := New(modDepsForTest())
for _, command := range mod.Commands {
if command.Name != "stock_events" {
continue
}
if command.Parameters != "<ticker> [days]" || command.Description != "Show SSI corporate actions for a VN stock" {
t.Fatalf("stock_events metadata = params %q description %q", command.Parameters, command.Description)
}
return
}
t.Fatal("stock_events command is not registered")
}
func TestHandleStockEventsDefaultWindowAndSenderless(t *testing.T) {
now := time.Date(2026, 7, 23, 15, 30, 0, 0, saigonLocation)
called := false
s := &state{
nowFn: func() time.Time { return now },
events: stockEventProviderFunc(func(_ context.Context, symbol string, after, through time.Time) ([]SSIStockEvent, error) {
called = true
if symbol != "TCB" || !through.Equal(now) || !after.Equal(now.Add(-30*24*time.Hour)) {
t.Errorf("provider args = %q, %v, %v", symbol, after, through)
}
return []SSIStockEvent{{
CorID: "meeting-1", Symbol: "TCB", EventListCode: "AGM", EventName: "Annual meeting",
EventTitle: "Shareholder meeting", PublicDate: "23/07/2026 14:30:00", SourceURL: "https://ssi.example/event",
}}, nil
}),
}
rb := testutil.NewRecordingBot(t)
if err := s.handleStockEvents(context.Background(), rb.Bot, testutil.NewChannelMessage(-100, "/stock_events tcb")); err != nil {
t.Fatalf("handleStockEvents: %v", err)
}
if !called {
t.Fatal("provider was not called")
}
rb.AssertSentText(t, "TCB SSI event · AGM")
rb.AssertSentText(t, "Name: Annual meeting")
rb.AssertSentText(t, "SSI event: meeting-1")
}
func TestHandleStockEventsExplicitWindowAndValidation(t *testing.T) {
now := time.Date(2026, 7, 23, 15, 30, 0, 0, saigonLocation)
s := &state{
nowFn: func() time.Time { return now },
events: stockEventProviderFunc(func(_ context.Context, _ string, after, through time.Time) ([]SSIStockEvent, error) {
if !after.Equal(now.Add(-7*24*time.Hour)) || !through.Equal(now) {
t.Fatalf("window = %v..%v", after, through)
}
return nil, nil
}),
}
rb := testutil.NewRecordingBot(t)
if err := s.handleStockEvents(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_events TCB 7")); err != nil {
t.Fatal(err)
}
rb.AssertSentText(t, "No stock events found for TCB in the last 7 days.")
for _, tc := range []struct {
command string
want string
}{
{"/stock_events", "Usage: /stock_events <ticker> [days]"},
{"/stock_events TCB 7 extra", "Usage: /stock_events <ticker> [days]"},
{"/stock_events TCB 0", "Days must be a whole number from 1 to 90."},
{"/stock_events TCB 91", "Days must be a whole number from 1 to 90."},
{"/stock_events TCB 1.5", "Days must be a whole number from 1 to 90."},
{"/stock_events $$$", "Unknown stock ticker \"$$$\"."},
} {
rb.Reset()
if err := s.handleStockEvents(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, tc.command)); err != nil {
t.Fatalf("%q: %v", tc.command, err)
}
rb.AssertSentText(t, tc.want)
}
}
func TestHandleStockEventsProviderError(t *testing.T) {
s := &state{
events: stockEventProviderFunc(func(context.Context, string, time.Time, time.Time) ([]SSIStockEvent, error) {
return nil, errors.New("upstream unavailable")
}),
}
rb := testutil.NewRecordingBot(t)
if err := s.handleStockEvents(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_events TCB")); err != nil {
t.Fatal(err)
}
rb.AssertSentText(t, "Could not fetch stock events for TCB. Try again later.")
}
func TestStockEventFormattingAndChunking(t *testing.T) {
unicodeTitle := strings.Repeat("ổ", stockEventTitleLimit+50)
block := formatStockEvent(SSIStockEvent{
CorID: "event-1", Symbol: "TCB", EventListCode: "ISS", EventName: "Issue",
EventTitle: unicodeTitle, EventDescription: "Raw API description", Value: "1500.25", Ratio: "0.125",
PublicDate: "20/07/2026 10:00:00", ExrightDate: "malformed-but-displayed", RecordDate: "22/07/2026",
IssueDate: "30/07/2026", SourceURL: "https://ssi.example/event-1",
})
if !strings.Contains(block, "Title: "+strings.Repeat("ổ", stockEventTitleLimit-1)+"…") {
t.Fatal("title was not truncated rune-safely")
}
for _, expected := range []string{
"Description: Raw API description", "Value: 1500.25", "Ratio: 0.125",
"Published: 20/07/2026 10:00:00", "Ex-right: malformed-but-displayed", "Record: 22/07/2026",
"Issue/payment: 30/07/2026", "Source: https://ssi.example/event-1",
} {
if !strings.Contains(block, expected) {
t.Errorf("block missing %q: %q", expected, block)
}
}
blocks := []string{strings.Repeat("a", 2500), strings.Repeat("b", 2500), "final-event"}
replies := chunkStockEventReplies("TCB", blocks)
if len(replies) != 2 {
t.Fatalf("reply count = %d, want 2", len(replies))
}
for i, reply := range replies {
if utf8.RuneCountInString(reply) >= stockEventsReplyLimit {
t.Errorf("reply %d length = %d", i, utf8.RuneCountInString(reply))
}
}
if !strings.Contains(replies[0], "(1/2)") || !strings.Contains(replies[1], "(2/2)") || !strings.HasSuffix(replies[1], "final-event") {
t.Fatalf("chunk ordering/headings = %#v", replies)
}
}