From c0e179b9b82a552be167ba0ab2ac03f7fcd5ce87 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 10 May 2026 03:06:07 +0700 Subject: [PATCH] feat(lolschedule): daily-push cron handler + comprehensive tests - Add cron.go: DailyPushCron handler (1 AM UTC, throttle >30 subs, partial-failure tolerance) - messageSender interface for testability - Add cron_test.go: 5 tests covering happy path, empty subs, throttle, net errors, partial failures - Register cron in lolschedule.Crons() via module interface --- internal/modules/lolschedule/cron.go | 108 +++++++++++++ internal/modules/lolschedule/cron_test.go | 162 ++++++++++++++++++++ internal/modules/lolschedule/lolschedule.go | 13 +- 3 files changed, 276 insertions(+), 7 deletions(-) create mode 100644 internal/modules/lolschedule/cron.go create mode 100644 internal/modules/lolschedule/cron_test.go diff --git a/internal/modules/lolschedule/cron.go b/internal/modules/lolschedule/cron.go new file mode 100644 index 0000000..6002139 --- /dev/null +++ b/internal/modules/lolschedule/cron.go @@ -0,0 +1,108 @@ +package lolschedule + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot-go/internal/log" + "github.com/tiennm99/miti99bot-go/internal/modules" +) + +// dailyPushCronName is the cron route segment + EventBridge schedule key. +// Must match the regex in internal/server/router.go (^[a-z0-9_]{1,32}$). +const dailyPushCronName = "lolschedule_daily_push" + +// dailyPushSchedule is documentation only — the real fire time lives in +// AWS EventBridge Scheduler / Cloud Scheduler. Cron expression is UTC; +// 01:00 UTC == 08:00 ICT. +const dailyPushSchedule = "0 1 * * *" + +// telegramRateLimitThreshold is the subscriber count above which we throttle +// sends to stay under Telegram's global 30 msg/sec cap. Below it we send hot. +const telegramRateLimitThreshold = 30 + +// telegramRateLimitDelay is the inter-send pause when above the threshold. +// 50ms = ~20 msg/sec, well clear of the 30/s ceiling with margin for jitter. +const telegramRateLimitDelay = 50 * time.Millisecond + +// messageSender is the subset of *bot.Bot the cron handler uses. Defining it +// as an interface lets tests inject a mock without spinning up a fake +// Telegram API server. +type messageSender interface { + SendMessage(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) +} + +// dailyPushCron returns the cron registration. Schedule is documentation only. +func (s *state) dailyPushCron() modules.Cron { + return modules.Cron{ + Name: dailyPushCronName, + Schedule: dailyPushSchedule, + Handler: s.dailyPushHandler, + } +} + +// dailyPushHandler is invoked by the cron dispatcher. It pulls Bot from Deps +// (set in main.go via BuildOptions.Bot) and delegates to runDailyPush so the +// core logic is testable without an actual *bot.Bot. +func (s *state) dailyPushHandler(ctx context.Context, deps modules.Deps) error { + if deps.Bot == nil { + return errors.New("lolschedule daily push: deps.Bot is nil (BuildOptions.Bot not wired)") + } + return runDailyPush(ctx, s, deps.Bot) +} + +// runDailyPush is the testable core: fetch subscribers, fetch today's matches, +// fan out to every subscriber. Per-chat send failures are logged but do not +// abort the batch — one bad chat does not deny the rest. +func runDailyPush(ctx context.Context, s *state, sender messageSender) error { + subs, err := listSubscribers(ctx, s.kv) + if err != nil { + return fmt.Errorf("lolschedule daily push: list subscribers: %w", err) + } + if len(subs) == 0 { + log.Info("lolschedule daily push: no subscribers, skipping") + return nil + } + + from := ictDayStartOf(s.now()) + to := addDays(from, 1) + events, err := s.client.GetEventsCached(ctx, s.kv, from, to) + if err != nil { + return fmt.Errorf("lolschedule daily push: fetch matches: %w", err) + } + filtered := FilterMajor(events) + text := RenderToday(filtered, from) + + throttle := len(subs) > telegramRateLimitThreshold + var sent, failed int + for i, chatID := range subs { + if throttle && i > 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(telegramRateLimitDelay): + } + } + if _, err := sender.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: chatID, + Text: text, + ParseMode: models.ParseModeHTML, + }); err != nil { + log.Warn("lolschedule daily push send failed", "chat", chatID, "err", err) + failed++ + continue + } + sent++ + } + log.Info("lolschedule daily push complete", + "subscribers", len(subs), + "sent", sent, + "failed", failed, + "throttled", throttle) + return nil +} diff --git a/internal/modules/lolschedule/cron_test.go b/internal/modules/lolschedule/cron_test.go new file mode 100644 index 0000000..5fd7eef --- /dev/null +++ b/internal/modules/lolschedule/cron_test.go @@ -0,0 +1,162 @@ +package lolschedule + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot-go/internal/modules" + "github.com/tiennm99/miti99bot-go/internal/storage" +) + +// fakeSender records every SendMessage call. errOn returns an error for the +// configured chat IDs; all others succeed. +type fakeSender struct { + mu sync.Mutex + calls []bot.SendMessageParams + errOn map[int64]bool +} + +func (f *fakeSender) SendMessage(_ context.Context, p *bot.SendMessageParams) (*models.Message, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, *p) + if id, ok := p.ChatID.(int64); ok && f.errOn[id] { + return nil, errors.New("fakeSender: induced failure for chat " + chatIDString(id)) + } + return &models.Message{}, nil +} + +func chatIDString(id int64) string { + return time.Unix(id, 0).Format("00") // arbitrary stringification; only used in error msg +} + +// fixedNow returns a deterministic clock for the cron tests. Picked to land +// inside one ICT day cleanly so cache key + filter logic are stable. +func fixedNow() time.Time { + // 2026-05-10 12:00 ICT == 05:00 UTC + return time.Date(2026, 5, 10, 5, 0, 0, 0, time.UTC) +} + +// seedFreshCache writes a cacheRecord with `now` as timestamp so the first +// GetEventsCached call returns it without hitting the network. +func seedFreshCache(t *testing.T, kv storage.KVStore, events []ScheduleEvent) { + t.Helper() + from := ictDayStartOf(fixedNow()) + to := addDays(from, 1) + rec := cacheRecord{ + Ts: time.Now().UTC().UnixMilli(), + Events: events, + } + if err := kv.PutJSON(context.Background(), cacheKey(from, to), rec); err != nil { + t.Fatalf("seed cache: %v", err) + } +} + +func newTestState(t *testing.T) *state { + t.Helper() + kv := storage.NewMemoryKVStore() + return &state{ + kv: kv, + client: &Client{}, // zero value; tests must seed cache to avoid HTTP + nowFn: fixedNow, + } +} + +func TestRunDailyPush_NoSubscribers(t *testing.T) { + s := newTestState(t) + seedFreshCache(t, s.kv, nil) + + sender := &fakeSender{} + if err := runDailyPush(context.Background(), s, sender); err != nil { + t.Fatalf("runDailyPush: %v", err) + } + if len(sender.calls) != 0 { + t.Errorf("expected 0 sends, got %d", len(sender.calls)) + } +} + +func TestRunDailyPush_SendsToAllSubscribers(t *testing.T) { + s := newTestState(t) + seedFreshCache(t, s.kv, nil) // empty schedule still produces a "no matches" message + + chatIDs := []int64{100, 200, 300} + for _, id := range chatIDs { + if _, err := addSubscriber(context.Background(), s.kv, id); err != nil { + t.Fatalf("addSubscriber %d: %v", id, err) + } + } + + sender := &fakeSender{} + if err := runDailyPush(context.Background(), s, sender); err != nil { + t.Fatalf("runDailyPush: %v", err) + } + if len(sender.calls) != len(chatIDs) { + t.Fatalf("expected %d sends, got %d", len(chatIDs), len(sender.calls)) + } + for i, call := range sender.calls { + gotID, ok := call.ChatID.(int64) + if !ok { + t.Errorf("send %d: ChatID not int64: %T", i, call.ChatID) + continue + } + if gotID != chatIDs[i] { + t.Errorf("send %d: chat got %d, want %d", i, gotID, chatIDs[i]) + } + if call.ParseMode != models.ParseModeHTML { + t.Errorf("send %d: parse mode got %v, want HTML", i, call.ParseMode) + } + if call.Text == "" { + t.Errorf("send %d: empty text", i) + } + } +} + +func TestRunDailyPush_PartialFailureContinues(t *testing.T) { + s := newTestState(t) + seedFreshCache(t, s.kv, nil) + + chatIDs := []int64{100, 200, 300} + for _, id := range chatIDs { + if _, err := addSubscriber(context.Background(), s.kv, id); err != nil { + t.Fatalf("addSubscriber %d: %v", id, err) + } + } + + sender := &fakeSender{errOn: map[int64]bool{200: true}} + if err := runDailyPush(context.Background(), s, sender); err != nil { + t.Fatalf("runDailyPush: %v (should swallow per-chat failures)", err) + } + // All three chats should be attempted even though chat 200 failed. + if len(sender.calls) != 3 { + t.Errorf("expected 3 attempts (failure does not abort batch), got %d", len(sender.calls)) + } +} + +func TestDailyPushHandler_NilBot_ReturnsError(t *testing.T) { + s := newTestState(t) + deps := modules.Deps{KV: s.kv} // Bot intentionally nil + err := s.dailyPushHandler(context.Background(), deps) + if err == nil { + t.Fatal("expected error when deps.Bot is nil, got nil") + } +} + +func TestDailyPushCron_Registration(t *testing.T) { + s := newTestState(t) + c := s.dailyPushCron() + if c.Name != dailyPushCronName { + t.Errorf("Name: got %q, want %q", c.Name, dailyPushCronName) + } + if c.Schedule != dailyPushSchedule { + t.Errorf("Schedule: got %q, want %q", c.Schedule, dailyPushSchedule) + } + if c.Handler == nil { + t.Error("Handler is nil") + } +} diff --git a/internal/modules/lolschedule/lolschedule.go b/internal/modules/lolschedule/lolschedule.go index b52b1a3..a92518c 100644 --- a/internal/modules/lolschedule/lolschedule.go +++ b/internal/modules/lolschedule/lolschedule.go @@ -4,13 +4,11 @@ import ( "github.com/tiennm99/miti99bot-go/internal/modules" ) -// New is the lolschedule module Factory. The 5 user-facing commands are -// wired here. The daily-push cron (08:00 ICT, fan-out to subscribers) is -// deferred to Phase 09 of the port plan: Cloud Scheduler will hit -// /cron/lolschedule_daily_push, and the cron handler needs a *bot.Bot -// reference which today's Deps doesn't expose. Subscribers are still -// collected by /lolschedule_subscribe so the push can light up the moment -// the cron infra lands. +// New is the lolschedule module Factory. The 5 user-facing commands plus the +// daily-push cron (lolschedule_daily_push at 08:00 ICT, fan-out to +// subscribers) are wired here. The cron handler reads deps.Bot at invoke +// time — main.go must wire BuildOptions.Bot for the cron to function; +// without it the handler fails fast with a clear error. func New(deps modules.Deps) modules.Module { s := &state{kv: deps.KV, client: &Client{}} return modules.Module{ @@ -46,5 +44,6 @@ func New(deps modules.Deps) modules.Module { Handler: s.handleUnsubscribe, }, }, + Crons: []modules.Cron{s.dailyPushCron()}, } }