test: cover Telegram command handlers via httptest bot

Add tests for the previously-untested handlers (ktoSprzata, sync, unknown,
matchCommand, BotSendFunc, PublishCommands) using a real *bot.Bot pointed at
an httptest server that captures sendMessage replies. Covers the /sync
success, throttled, and failure paths plus the non-message fallback.
This commit is contained in:
Kamil 'Kamaś' Bruchal 2026-06-19 19:04:04 +02:00
parent 6573144b14
commit 41980e4f6f
1 changed files with 223 additions and 0 deletions

View File

@ -1,12 +1,87 @@
package dyzurbot package dyzurbot
import ( import (
"context"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing" "testing"
"time" "time"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
) )
// newTestBot builds a real *bot.Bot pointed at an httptest server that records
// the text of every sendMessage call, so handler replies can be asserted
// without touching the network. WithSkipGetMe avoids the token-validating
// getMe call the real constructor makes. The returned func yields the captured
// reply texts in order. Per-path responses matter: sendMessage must decode into
// a models.Message and setMyCommands into a bool, so a single generic body would
// make one of them fail to unmarshal (and the handler would log a send error).
func newTestBot(t *testing.T) (*bot.Bot, func() []string) {
t.Helper()
var mu sync.Mutex
var sent []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/sendMessage") {
_ = r.ParseMultipartForm(1 << 20)
mu.Lock()
sent = append(sent, r.FormValue("text"))
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"ok":true,"result":true}`)
}))
t.Cleanup(srv.Close)
b, err := bot.New("xxx", bot.WithServerURL(srv.URL), bot.WithSkipGetMe())
if err != nil {
t.Fatalf("bot.New: %v", err)
}
return b, func() []string {
mu.Lock()
defer mu.Unlock()
out := make([]string, len(sent))
copy(out, sent)
return out
}
}
// testService builds a Service backed by a temp-file Store (optionally seeded
// with rota) and a Syncer using the given fetch. fetch may be nil for handlers
// that don't sync.
func testService(t *testing.T, rota Rota, fetch FetchFunc) *Service {
t.Helper()
store := NewStore(filepath.Join(t.TempDir(), "rota.json"))
if len(rota.Weeks) > 0 || len(rota.People) > 0 {
if err := store.Replace(rota, time.Date(2026, 6, 17, 0, 0, 0, 0, time.UTC)); err != nil {
t.Fatalf("seed store: %v", err)
}
}
return &Service{Store: store, Syncer: NewSyncer(store, fetch)}
}
// updateMsg builds a minimal text-message Update addressed to chatID.
func updateMsg(chatID int64, text string) *models.Update {
return &models.Update{Message: &models.Message{
Text: text,
Chat: models.Chat{ID: chatID},
}}
}
// fixedNow returns a clock pinned to a Wednesday inside the 2026-06-15 week.
func fixedNow() func() time.Time {
now := time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC)
return func() time.Time { return now }
}
// cmdEntity builds the bot_command entity Telegram attaches to a leading // cmdEntity builds the bot_command entity Telegram attaches to a leading
// command, spanning the whole token (slash, name, and any @addressee). // command, spanning the whole token (slash, name, and any @addressee).
func cmdEntity(text string) []models.MessageEntity { func cmdEntity(text string) []models.MessageEntity {
@ -138,6 +213,154 @@ func TestCurrentDutyLocalMidnight(t *testing.T) {
} }
} }
// TestKtoSprzataReply checks the /kto_sprzata handler reads the cached rota and
// replies with this week's duty rendered by formatDuty.
func TestKtoSprzataReply(t *testing.T) {
rota := Rota{Weeks: []DutyWeek{week(t, "2026-06-15", "Ala", "Bartek")}}
svc := testService(t, rota, nil)
b, sent := newTestBot(t)
h := &botHandlers{svc: svc, now: fixedNow()}
h.ktoSprzata(context.Background(), b, updateMsg(123, "/kto_sprzata"))
got := sent()
want := "Dyżur w tym tygodniu (od 2026-06-15): Ala i Bartek"
if len(got) != 1 || got[0] != want {
t.Fatalf("reply = %v, want [%q]", got, want)
}
}
// TestSyncReplySuccess checks /sync triggers a fetch and reports the resulting
// week/person counts.
func TestSyncReplySuccess(t *testing.T) {
fetched := Rota{
Weeks: []DutyWeek{week(t, "2026-06-15", "Ala", "Bartek")},
People: []Person{{Name: "Ala"}, {Name: "Bartek"}, {Name: "Celina"}},
}
fetch := func(context.Context) (Rota, error) { return fetched, nil }
svc := testService(t, Rota{}, fetch)
b, sent := newTestBot(t)
h := &botHandlers{svc: svc, now: fixedNow()}
h.sync(context.Background(), b, updateMsg(123, "/sync"))
got := sent()
want := "Zsynchronizowano: 1 tygodni, 3 osób."
if len(got) != 1 || got[0] != want {
t.Fatalf("reply = %v, want [%q]", got, want)
}
}
// TestSyncReplyThrottled checks a /sync inside the cooldown window is rejected
// with the remaining-wait message and does not trigger a fetch.
func TestSyncReplyThrottled(t *testing.T) {
now := fixedNow()()
fetch := func(context.Context) (Rota, error) {
t.Fatal("fetch must not run while throttled")
return Rota{}, nil
}
svc := testService(t, Rota{}, fetch)
b, sent := newTestBot(t)
h := &botHandlers{svc: svc, now: func() time.Time { return now }}
h.lastSync = now.Add(-30 * time.Second) // 30s into a 60s cooldown
h.sync(context.Background(), b, updateMsg(123, "/sync"))
got := sent()
want := "Synchronizowano niedawno. Spróbuj ponownie za 31s."
if len(got) != 1 || got[0] != want {
t.Fatalf("reply = %v, want [%q]", got, want)
}
}
// TestSyncReplyFailure checks a failed fetch yields the generic failure message
// (internal error details are logged, not shown). The slog.Error this emits is
// expected, not a harness fault.
func TestSyncReplyFailure(t *testing.T) {
fetch := func(context.Context) (Rota, error) {
return Rota{}, context.DeadlineExceeded
}
svc := testService(t, Rota{}, fetch)
b, sent := newTestBot(t)
h := &botHandlers{svc: svc, now: fixedNow()}
h.sync(context.Background(), b, updateMsg(123, "/sync"))
got := sent()
want := "Synchronizacja nie powiodła się. Spróbuj ponownie później."
if len(got) != 1 || got[0] != want {
t.Fatalf("reply = %v, want [%q]", got, want)
}
}
// TestUnknownReply checks the fallback handler answers unrecognized text.
func TestUnknownReply(t *testing.T) {
svc := testService(t, Rota{}, nil)
b, sent := newTestBot(t)
h := &botHandlers{svc: svc, now: fixedNow()}
h.unknown(context.Background(), b, updateMsg(123, "siema"))
got := sent()
want := "Nieznana komenda. Użyj /kto_sprzata lub /sync."
if len(got) != 1 || got[0] != want {
t.Fatalf("reply = %v, want [%q]", got, want)
}
}
// TestUnknownNonMessageIgnored checks a non-message update (e.g. an edited
// message or callback) produces no reply.
func TestUnknownNonMessageIgnored(t *testing.T) {
svc := testService(t, Rota{}, nil)
b, sent := newTestBot(t)
h := &botHandlers{svc: svc, now: fixedNow()}
h.unknown(context.Background(), b, &models.Update{})
if got := sent(); len(got) != 0 {
t.Fatalf("non-message update should not reply, got %v", got)
}
}
// TestMatchCommand checks the MatchFunc wrapper fires for the matching command
// and tolerates a nil Message (non-message updates).
func TestMatchCommand(t *testing.T) {
h := &botHandlers{}
match := h.matchCommand(cmdSync)
hit := &models.Update{Message: &models.Message{Text: "/sync", Entities: cmdEntity("/sync")}}
if !match(hit) {
t.Error("matchCommand(cmdSync) should fire on /sync")
}
if match(&models.Update{}) {
t.Error("matchCommand must not fire on a nil-message update")
}
}
// TestBotSendFunc checks the reminder-facing SendFunc posts its text to the
// fixed chat.
func TestBotSendFunc(t *testing.T) {
b, sent := newTestBot(t)
send := BotSendFunc(b, 999)
if err := send(context.Background(), "przypomnienie"); err != nil {
t.Fatalf("send: %v", err)
}
got := sent()
if len(got) != 1 || got[0] != "przypomnienie" {
t.Fatalf("sent = %v, want [%q]", got, "przypomnienie")
}
}
// TestPublishCommands checks the command list is registered without error
// against a server that accepts setMyCommands.
func TestPublishCommands(t *testing.T) {
b, _ := newTestBot(t)
if err := PublishCommands(context.Background(), b); err != nil {
t.Fatalf("PublishCommands: %v", err)
}
}
func TestFormatDuty(t *testing.T) { func TestFormatDuty(t *testing.T) {
tests := []struct { tests := []struct {
name string name string