Files
miti99bot/internal/modules/lol/cron_test.go
T

463 lines
15 KiB
Go

package lol
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/storage"
)
// fakeSender records every SendMessage call. errOn returns a transient
// failure for the configured chat IDs; terminalErrOn returns a chat-wide
// permanent-failure message string; topicTerminalErrOn returns a
// topic-only permanent-failure string. All others succeed.
type fakeSender struct {
mu sync.Mutex
calls []bot.SendMessageParams
errOn map[int64]bool
terminalErrOn map[int64]bool
topicTerminalErrOn 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)
id, ok := p.ChatID.(int64)
if ok && f.terminalErrOn[id] {
// String shape that classifyTerminal matches; verifies the marker
// list works against a realistic Telegram error message.
return nil, errors.New("Forbidden: bot was blocked by the user")
}
if ok && f.topicTerminalErrOn[id] {
return nil, errors.New("Bad Request: have no rights to send a message")
}
if 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)
}
// newTestStore builds a fresh set of typed stores over a shared in-memory
// collection, matching what the factory wires in production.
func newTestStore(t *testing.T) (SubscriberStore, PushDateStore, CacheStore) {
t.Helper()
col := storage.NewMemoryProvider().Collection("lol")
return storage.Typed[subscribersDoc](col),
storage.Typed[lastPushDoc](col),
storage.Typed[cacheRecord](col)
}
// seedFreshCache writes a cacheRecord with `now` as timestamp so
// GetEventsWithFallback can serve it when the test upstream fails.
func seedFreshCache(t *testing.T, cache CacheStore, events []ScheduleEvent) {
t.Helper()
from := ictDayStartOf(fixedNow())
to := addDays(from, 1)
rec := cacheRecord{
Ts: time.Now().UTC().UnixMilli(),
Events: events,
}
if err := cache.Put(context.Background(), cacheKey(from, to), rec); err != nil {
t.Fatalf("seed cache: %v", err)
}
}
func newTestState(t *testing.T) *state {
t.Helper()
subs, pd, cache := newTestStore(t)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(upstream.Close)
return &state{
subscribers: subs,
pushDate: pd,
cache: cache,
client: &Client{HTTP: upstream.Client(), URL: upstream.URL},
nowFn: fixedNow,
}
}
func TestRunDailyPush_NoSubscribers(t *testing.T) {
s := newTestState(t)
seedFreshCache(t, s.cache, 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_SendsEmptyScheduleSilently(t *testing.T) {
s := newTestState(t)
seedFreshCache(t, s.cache, nil) // empty schedule still produces a "no matches" message
chatIDs := []int64{100, 200, 300}
for _, id := range chatIDs {
if _, err := addSubscriber(context.Background(), s.subscribers, id, 0); 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)
}
if call.MessageThreadID != 0 {
t.Errorf("send %d: thread got %d, want 0 (no topic)", i, call.MessageThreadID)
}
if !call.DisableNotification {
t.Errorf("send %d: DisableNotification got false, want true for empty schedule", i)
}
}
}
func TestRunDailyPush_SendsMatchScheduleWithNotification(t *testing.T) {
s := newTestState(t)
seedFreshCache(t, s.cache, []ScheduleEvent{{
StartTime: "2026-05-10T06:00:00Z",
State: "unstarted",
League: League{Name: "LCK", Slug: "lck"},
Match: Match{
Teams: []Team{{Code: "T1"}, {Code: "GEN"}},
Strategy: Strategy{Count: 3},
},
}})
if _, err := addSubscriber(context.Background(), s.subscribers, 100, 0); err != nil {
t.Fatalf("addSubscriber: %v", err)
}
sender := &fakeSender{}
if err := runDailyPush(context.Background(), s, sender); err != nil {
t.Fatalf("runDailyPush: %v", err)
}
if len(sender.calls) != 1 {
t.Fatalf("expected 1 send, got %d", len(sender.calls))
}
if sender.calls[0].DisableNotification {
t.Fatalf("DisableNotification got true, want false when today's match list is non-empty")
}
}
// TestRunDailyPush_ForwardsMessageThreadID locks in the forum-topic fix:
// subscribers stored with a non-zero ThreadID receive the daily push in that
// topic, not in General.
func TestRunDailyPush_ForwardsMessageThreadID(t *testing.T) {
s := newTestState(t)
seedFreshCache(t, s.cache, nil)
subs := []Subscriber{
{ChatID: 100, ThreadID: 0},
{ChatID: 100, ThreadID: 7},
{ChatID: 200, ThreadID: 42},
}
for _, sub := range subs {
if _, err := addSubscriber(context.Background(), s.subscribers, sub.ChatID, sub.ThreadID); err != nil {
t.Fatal(err)
}
}
sender := &fakeSender{}
if err := runDailyPush(context.Background(), s, sender); err != nil {
t.Fatalf("runDailyPush: %v", err)
}
if len(sender.calls) != len(subs) {
t.Fatalf("expected %d sends, got %d", len(subs), len(sender.calls))
}
for i, call := range sender.calls {
gotID, _ := call.ChatID.(int64)
if gotID != subs[i].ChatID || call.MessageThreadID != subs[i].ThreadID {
t.Errorf("send %d: got (chat=%d, thread=%d), want (chat=%d, thread=%d)",
i, gotID, call.MessageThreadID, subs[i].ChatID, subs[i].ThreadID)
}
}
}
// TestRunDailyPush_IdempotentPerICTDay locks in the double-fire guard: invoking
// the handler twice on the same ICT schedule day sends each subscriber exactly
// one digest. Defends against rolling-deploy overlap and operator
// misconfiguration (all double-fire windows the daily push must survive).
func TestRunDailyPush_IdempotentPerICTDay(t *testing.T) {
s := newTestState(t)
seedFreshCache(t, s.cache, nil)
chatIDs := []int64{100, 200, 300}
for _, id := range chatIDs {
if _, err := addSubscriber(context.Background(), s.subscribers, id, 0); err != nil {
t.Fatalf("addSubscriber %d: %v", id, err)
}
}
sender := &fakeSender{}
for i := 0; i < 2; i++ {
if err := runDailyPush(context.Background(), s, sender); err != nil {
t.Fatalf("runDailyPush call %d: %v", i+1, err)
}
}
if len(sender.calls) != len(chatIDs) {
t.Errorf("two same-ICT-day pushes sent %d messages, want %d (one per subscriber)",
len(sender.calls), len(chatIDs))
}
}
func TestRunDailyPush_ClaimsICTDayAtMidnight(t *testing.T) {
s := newTestState(t)
s.nowFn = func() time.Time {
return time.Date(2026, 5, 9, 17, 0, 0, 0, time.UTC) // 2026-05-10 00:00 ICT
}
seedFreshCache(t, s.cache, nil)
if _, err := addSubscriber(context.Background(), s.subscribers, 100, 0); err != nil {
t.Fatal(err)
}
if err := s.pushDate.Put(context.Background(), lastPushDateKey, lastPushDoc{Date: "2026-05-09"}); err != nil {
t.Fatal(err)
}
sender := &fakeSender{}
if err := runDailyPush(context.Background(), s, sender); err != nil {
t.Fatal(err)
}
if len(sender.calls) != 1 {
t.Fatalf("calls = %d, want 1 midnight ICT push", len(sender.calls))
}
doc, _, err := s.pushDate.Get(context.Background(), lastPushDateKey)
if err != nil {
t.Fatal(err)
}
if doc.Date != "2026-05-10" {
t.Fatalf("last push date = %q, want ICT day 2026-05-10", doc.Date)
}
}
func TestRunDailyPush_PartialFailureContinues(t *testing.T) {
s := newTestState(t)
seedFreshCache(t, s.cache, nil)
chatIDs := []int64{100, 200, 300}
for _, id := range chatIDs {
if _, err := addSubscriber(context.Background(), s.subscribers, id, 0); 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)
}
if len(sender.calls) != 3 {
t.Errorf("expected 3 attempts (failure does not abort batch), got %d", len(sender.calls))
}
}
// TestRunDailyPush_PrunesDeadSubscribers locks in the auto-cleanup of chats
// that have permanently blocked the bot. Recoverable (transient) errors
// MUST NOT trigger removal — only terminal Telegram errors do. A chat-wide
// terminal error removes every topic subscription for that chat.
func TestRunDailyPush_PrunesDeadSubscribers(t *testing.T) {
s := newTestState(t)
seedFreshCache(t, s.cache, nil)
// Chat 400 has two topic subs; both should be pruned when the chat
// returns a chat-wide terminal error.
seedSubs := []Subscriber{
{ChatID: 100, ThreadID: 0},
{ChatID: 200, ThreadID: 0},
{ChatID: 300, ThreadID: 0},
{ChatID: 400, ThreadID: 0},
{ChatID: 400, ThreadID: 9},
}
for _, sub := range seedSubs {
if _, err := addSubscriber(context.Background(), s.subscribers, sub.ChatID, sub.ThreadID); err != nil {
t.Fatalf("addSubscriber %v: %v", sub, err)
}
}
sender := &fakeSender{
errOn: map[int64]bool{200: true}, // transient → keep
terminalErrOn: map[int64]bool{400: true}, // chat-wide → wipe all 400 entries
}
if err := runDailyPush(context.Background(), s, sender); err != nil {
t.Fatalf("runDailyPush: %v", err)
}
remaining, err := listSubscribers(context.Background(), s.subscribers)
if err != nil {
t.Fatalf("listSubscribers: %v", err)
}
want := []Subscriber{{ChatID: 100}, {ChatID: 200}, {ChatID: 300}}
if len(remaining) != len(want) {
t.Fatalf("subscribers after prune: got %v, want %v", remaining, want)
}
for i, s := range want {
if remaining[i] != s {
t.Errorf("subscriber[%d]: got %v, want %v", i, remaining[i], s)
}
}
}
// TestRunDailyPush_TopicOnlyTerminalPrunesOneTopic verifies that a
// "have no rights to send" failure removes only the failing
// (ChatID, ThreadID) entry — sister topics in the same chat stay subscribed.
func TestRunDailyPush_TopicOnlyTerminalPrunesOneTopic(t *testing.T) {
s := newTestState(t)
seedFreshCache(t, s.cache, nil)
seedSubs := []Subscriber{
{ChatID: 500, ThreadID: 0},
{ChatID: 500, ThreadID: 11},
{ChatID: 500, ThreadID: 22},
}
for _, sub := range seedSubs {
if _, err := addSubscriber(context.Background(), s.subscribers, sub.ChatID, sub.ThreadID); err != nil {
t.Fatal(err)
}
}
// Every send to chat 500 returns the topic-only terminal — but only the
// matching (ChatID, ThreadID) should be pruned per call. With the marker
// applying to all three sends we expect all three entries to drop.
sender := &fakeSender{topicTerminalErrOn: map[int64]bool{500: true}}
if err := runDailyPush(context.Background(), s, sender); err != nil {
t.Fatalf("runDailyPush: %v", err)
}
remaining, _ := listSubscribers(context.Background(), s.subscribers)
if len(remaining) != 0 {
t.Errorf("expected all topic-only entries pruned, got %v", remaining)
}
}
// TestRunDailyPush_TopicOnlyTerminalKeepsOtherTopics: only one topic in a
// chat goes bad; the other topics in the same chat must stay.
func TestRunDailyPush_TopicOnlyTerminalKeepsOtherTopics(t *testing.T) {
s := newTestState(t)
seedFreshCache(t, s.cache, nil)
// Two distinct chats, each with multiple topic subs. Only chat 600 hits
// the topic-terminal error; chat 700 sends cleanly.
seedSubs := []Subscriber{
{ChatID: 600, ThreadID: 1},
{ChatID: 600, ThreadID: 2},
{ChatID: 700, ThreadID: 3},
}
for _, sub := range seedSubs {
if _, err := addSubscriber(context.Background(), s.subscribers, sub.ChatID, sub.ThreadID); err != nil {
t.Fatal(err)
}
}
// Custom sender: chat 600 always fails topic-terminal, chat 700 succeeds.
sender := &fakeSender{topicTerminalErrOn: map[int64]bool{600: true}}
if err := runDailyPush(context.Background(), s, sender); err != nil {
t.Fatalf("runDailyPush: %v", err)
}
remaining, _ := listSubscribers(context.Background(), s.subscribers)
if len(remaining) != 1 || remaining[0] != (Subscriber{ChatID: 700, ThreadID: 3}) {
t.Errorf("after topic-terminal prune of chat 600: got %v, want [{700 3}]", remaining)
}
}
func TestClassifyTerminal(t *testing.T) {
chatWide := []string{
"Forbidden: bot was blocked by the user",
"Forbidden: user is deactivated",
"Bad Request: chat not found",
"Bad Request: group chat was upgraded to a supergroup chat",
}
for _, msg := range chatWide {
if got := classifyTerminal(errors.New(msg)); got != terminalChatWide {
t.Errorf("classifyTerminal(%q) = %v, want terminalChatWide", msg, got)
}
}
topicOnly := []string{
"Bad Request: have no rights to send a message",
}
for _, msg := range topicOnly {
if got := classifyTerminal(errors.New(msg)); got != terminalTopicOnly {
t.Errorf("classifyTerminal(%q) = %v, want terminalTopicOnly", msg, got)
}
}
transients := []string{
"connection reset by peer",
"Too Many Requests: retry after 30",
"context deadline exceeded",
}
for _, msg := range transients {
if got := classifyTerminal(errors.New(msg)); got != terminalNone {
t.Errorf("classifyTerminal(%q) = %v, want terminalNone (transient)", msg, got)
}
}
if got := classifyTerminal(nil); got != terminalNone {
t.Errorf("classifyTerminal(nil) = %v, want terminalNone", got)
}
}
func TestDailyPushHandler_NilBot_ReturnsError(t *testing.T) {
s := newTestState(t)
deps := modules.Deps{Store: storage.NewMemoryProvider().Collection("lol")}
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")
}
}