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:
parent
6573144b14
commit
41980e4f6f
223
telegram_test.go
223
telegram_test.go
|
|
@ -1,12 +1,87 @@
|
|||
package dyzurbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"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
|
||||
// command, spanning the whole token (slash, name, and any @addressee).
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
Loading…
Reference in New Issue