mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-20 02:21:39 +00:00
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
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user