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

345 lines
13 KiB
Go

package lol
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/testutil"
)
// installSchedule wires the lol module to a recording bot, with a
// custom upstream HTTP server returning bodyJSON for every request. nowMs
// fixes the clock so date-based handlers are deterministic.
// Returns the recording bot and the subscriber store for inspection in tests.
func installSchedule(t *testing.T, bodyJSON string, nowMs int64) (*testutil.RecordingBot, SubscriberStore) {
t.Helper()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(bodyJSON))
}))
t.Cleanup(upstream.Close)
rb := testutil.NewRecordingBot(t)
col := storage.NewMemoryProvider().Collection("lol")
s := &state{
subscribers: storage.Typed[subscribersDoc](col),
pushDate: storage.Typed[lastPushDoc](col),
cache: storage.Typed[cacheRecord](col),
client: &Client{HTTP: upstream.Client(), URL: upstream.URL},
nowFn: func() time.Time { return time.UnixMilli(nowMs).UTC() },
}
mod := modules.Module{
Name: "lol",
Commands: []modules.Command{
{Name: "lol", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSchedule},
{Name: "lol_tomorrow", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleTomorrow},
{Name: "lol_this_week", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleWeek},
{Name: "lol_next_week", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleNextWeek},
{Name: "lol_subscribe", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSubscribe},
{Name: "lol_unsubscribe", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleUnsubscribe},
},
}
reg := &modules.Registry{
Modules: []modules.Module{mod},
AllCommands: map[string]modules.Command{},
}
for _, c := range mod.Commands {
reg.AllCommands[c.Name] = c
}
modules.Install(rb.Bot, reg, modules.Auth{})
return rb, s.subscribers
}
// 2026-05-09 12:00 UTC = 19:00 ICT (still May 9 ICT day). Used as the fake
// "now" in handler tests.
const fakeNowMs int64 = 1778328000000 // 2026-05-09T12:00:00Z
const todayBody = `{
"data": {
"schedule": {
"events": [
{
"startTime": "2026-05-09T05:00:00Z",
"state": "unstarted",
"league": {"slug": "lck", "name": "LCK"},
"match": {"teams": [{"code":"T1"},{"code":"GEN"}], "strategy":{"count":3}}
}
],
"pages": {"newer": null}
}
}
}`
const futureBody = `{
"data": {
"schedule": {
"events": [
{
"startTime": "2026-05-10T05:00:00Z",
"state": "unstarted",
"league": {"slug": "lck", "name": "LCK"},
"match": {"teams": [{"code":"DK"},{"code":"KT"}], "strategy":{"count":3}}
},
{
"startTime": "2026-05-12T08:00:00Z",
"state": "unstarted",
"league": {"slug": "lpl", "name": "LPL"},
"match": {"teams": [{"code":"JDG"},{"code":"BLG"}], "strategy":{"count":5}}
}
],
"pages": {"newer": null}
}
}
}`
func TestHandleSchedule_DefaultsToToday(t *testing.T) {
rb, _ := installSchedule(t, todayBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol"))
got := rb.LastSent()
if got.Method != "sendMessage" {
t.Errorf("method = %q, want sendMessage", got.Method)
}
if got.Form["parse_mode"] != "HTML" {
t.Errorf("parse_mode = %q, want HTML", got.Form["parse_mode"])
}
for _, want := range []string{"<b>LoL —", "(ICT)", "<b>LCK</b>", "T1 vs GEN"} {
if !strings.Contains(got.Text(), want) {
t.Errorf("missing %q in:\n%s", want, got.Text())
}
}
}
func TestHandleSchedule_BadDateInput(t *testing.T) {
rb, _ := installSchedule(t, todayBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol notadate"))
got := rb.LastSent().Text()
if !strings.Contains(got, "Invalid date") {
t.Errorf("expected parse error reply; got %q", got)
}
}
func TestHandleWeek_RendersWeek(t *testing.T) {
rb, _ := installSchedule(t, todayBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol_this_week"))
got := rb.LastSent().Text()
if !strings.Contains(got, "→") {
t.Errorf("week header missing arrow: %q", got)
}
// fakeNowMs is Sat 2026-05-09 ICT → calendar week is Mon May 4 → Sun May 10.
for _, want := range []string{"Mon May 4", "Sun May 10"} {
if !strings.Contains(got, want) {
t.Errorf("week header missing %q in:\n%s", want, got)
}
}
}
func TestHandleTomorrow_RendersTomorrow(t *testing.T) {
rb, _ := installSchedule(t, futureBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol_tomorrow"))
got := rb.LastSent().Text()
for _, want := range []string{"Sun May 10", "<b>LCK</b>", "DK vs KT"} {
if !strings.Contains(got, want) {
t.Errorf("tomorrow reply missing %q in:\n%s", want, got)
}
}
if strings.Contains(got, "JDG vs BLG") {
t.Errorf("tomorrow reply included next-week match:\n%s", got)
}
}
func TestHandleNextWeek_RendersNextWeek(t *testing.T) {
rb, _ := installSchedule(t, futureBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol_next_week"))
got := rb.LastSent().Text()
for _, want := range []string{"Mon May 11", "Sun May 17", "<b>LPL</b>", "JDG vs BLG"} {
if !strings.Contains(got, want) {
t.Errorf("next-week reply missing %q in:\n%s", want, got)
}
}
if strings.Contains(got, "DK vs KT") {
t.Errorf("next-week reply included tomorrow match:\n%s", got)
}
}
func TestNewRegistersScheduleShortcuts(t *testing.T) {
mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection("lol")})
got := map[string]bool{}
for _, cmd := range mod.Commands {
got[cmd.Name] = true
}
for _, want := range []string{"lol", "lol_tomorrow", "lol_this_week", "lol_next_week"} {
if !got[want] {
t.Errorf("New() missing command %s", want)
}
}
}
func TestHandleSubscribe_AddsAndIsIdempotent(t *testing.T) {
rb, subsStore := installSchedule(t, todayBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_subscribe"))
if got := rb.LastSent().Text(); !strings.Contains(got, "Subscribed this chat") || !strings.Contains(got, "daily LoL schedule") {
t.Errorf("first subscribe should confirm; got %q", got)
}
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_subscribe"))
if got := rb.LastSent().Text(); !strings.Contains(got, "Already subscribed") {
t.Errorf("duplicate subscribe should report Already; got %q", got)
}
ids, _ := listSubscribers(context.Background(), subsStore)
if len(ids) != 1 || ids[0] != (Subscriber{ChatID: 7}) {
t.Errorf("subscribers = %v, want [{7 0}]", ids)
}
}
// TestHandleSubscribe_ForumTopic_CapturesThreadID locks in the forum-topic
// fix at the handler boundary: subscribing from a non-zero MessageThreadID
// records that thread on the Subscriber so the daily push lands in the
// originating topic instead of General.
func TestHandleSubscribe_ForumTopic_CapturesThreadID(t *testing.T) {
rb, subsStore := installSchedule(t, todayBody, fakeNowMs)
upd := testutil.NewSupergroupMessage(555, 999, "/lol_subscribe")
upd.Message.MessageThreadID = 42
rb.Bot.ProcessUpdate(context.Background(), upd)
if got := rb.LastSent().Text(); !strings.Contains(got, "Subscribed this topic") {
t.Errorf("topic subscribe reply = %q", got)
}
subs, _ := listSubscribers(context.Background(), subsStore)
want := Subscriber{ChatID: 555, ThreadID: 42}
if len(subs) != 1 || subs[0] != want {
t.Errorf("subscribers = %v, want [%v]", subs, want)
}
}
// TestHandleUnsubscribe_ForumTopic_RemovesOnlyThatTopic verifies that
// unsubscribing from one topic does not affect sister-topic subscriptions
// in the same chat.
func TestHandleUnsubscribe_ForumTopic_RemovesOnlyThatTopic(t *testing.T) {
rb, subsStore := installSchedule(t, todayBody, fakeNowMs)
// Subscribe in two topics of the same supergroup.
for _, tid := range []int{42, 99} {
upd := testutil.NewSupergroupMessage(555, 999, "/lol_subscribe")
upd.Message.MessageThreadID = tid
rb.Bot.ProcessUpdate(context.Background(), upd)
}
// Unsubscribe in topic 42 only.
upd := testutil.NewSupergroupMessage(555, 999, "/lol_unsubscribe")
upd.Message.MessageThreadID = 42
rb.Bot.ProcessUpdate(context.Background(), upd)
subs, _ := listSubscribers(context.Background(), subsStore)
want := Subscriber{ChatID: 555, ThreadID: 99}
if len(subs) != 1 || subs[0] != want {
t.Errorf("subscribers = %v, want [%v]", subs, want)
}
}
func TestHandleUnsubscribe(t *testing.T) {
rb, _ := installSchedule(t, todayBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_subscribe"))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_unsubscribe"))
if got := rb.LastSent().Text(); got != "Unsubscribed this chat." {
t.Errorf("unsubscribe reply = %q, want 'Unsubscribed this chat.'", got)
}
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_unsubscribe"))
if got := rb.LastSent().Text(); !strings.Contains(got, "This chat wasn't subscribed") {
t.Errorf("idempotent unsubscribe reply = %q", got)
}
}
func TestHandleSchedule_UpstreamFailureGivesFriendlyError(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer upstream.Close()
rb := testutil.NewRecordingBot(t)
col := storage.NewMemoryProvider().Collection("lol")
s := &state{
subscribers: storage.Typed[subscribersDoc](col),
pushDate: storage.Typed[lastPushDoc](col),
cache: storage.Typed[cacheRecord](col),
client: &Client{HTTP: upstream.Client(), URL: upstream.URL},
nowFn: func() time.Time { return time.UnixMilli(fakeNowMs).UTC() },
}
cmd := modules.Command{Name: "lol", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSchedule}
reg := &modules.Registry{
Modules: []modules.Module{{Name: "lol", Commands: []modules.Command{cmd}}},
AllCommands: map[string]modules.Command{cmd.Name: cmd},
}
modules.Install(rb.Bot, reg, modules.Auth{})
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol"))
if got := rb.LastSent().Text(); !strings.Contains(got, "Could not fetch") {
t.Errorf("expected friendly fetch-error reply; got %q", got)
}
}
func TestHandleSchedule_CustomDateDoesNotUseFallbackCache(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer upstream.Close()
rb := testutil.NewRecordingBot(t)
col := storage.NewMemoryProvider().Collection("lol")
cache := storage.Typed[cacheRecord](col)
s := &state{
subscribers: storage.Typed[subscribersDoc](col),
pushDate: storage.Typed[lastPushDoc](col),
cache: cache,
client: &Client{HTTP: upstream.Client(), URL: upstream.URL},
nowFn: func() time.Time { return time.UnixMilli(fakeNowMs).UTC() },
}
from := ictDayStartOf(s.now())
to := addDays(from, 1)
if err := cache.Put(context.Background(), cacheKey(from, to), cacheRecord{
Ts: time.Now().UTC().UnixMilli(),
Events: []ScheduleEvent{{
StartTime: "2026-05-09T05:00:00Z",
State: "unstarted",
League: League{Name: "LCK", Slug: "lck"},
Match: Match{
Teams: []Team{{Code: "T1"}, {Code: "GEN"}},
Strategy: Strategy{Count: 3},
},
}},
}); err != nil {
t.Fatalf("seed cache: %v", err)
}
cmd := modules.Command{Name: "lol", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSchedule}
reg := &modules.Registry{
Modules: []modules.Module{{Name: "lol", Commands: []modules.Command{cmd}}},
AllCommands: map[string]modules.Command{cmd.Name: cmd},
}
modules.Install(rb.Bot, reg, modules.Auth{})
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol"))
if got := rb.LastSent().Text(); !strings.Contains(got, "T1 vs GEN") {
t.Fatalf("default /lol should use stale fallback; got %q", got)
}
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol 09-05-2026"))
if got := rb.LastSent().Text(); !strings.Contains(got, "Could not fetch") {
t.Fatalf("custom-date /lol should not use stale fallback; got %q", got)
}
}