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
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue