Files
miti99bot/internal/testutil/update_builders.go
T
tiennm99 b3dea5fe21 test(handlers): integration tests + recording bot + emulator on CI
Phase 5 of the 2026-05-09 review remediation plan. Closes the
handler-layer test gap (5 modules at 0% coverage in the audit) and
ends the storage-package's CI t.Skip on Firestore emulator tests.

- internal/testutil: Update fixture builders (NewPrivateMessage,
  NewGroupMessage, NewSupergroupMessage, NewChannelMessage) plus a
  RecordingBot that wraps the real go-telegram/bot.Bot with an
  httptest server. The bot library hits the test server instead of
  Telegram; multipart form fields are captured per call. Tests assert
  on Sent() / LastSent() / AssertSentText().
- Handler tests added: misc (4), util (7), wordle (10), loldle (9),
  loldleemoji (8). Cover happy paths, error paths, auth gates,
  group-vs-private subject keying, KV side effects.
- Coverage 44.7% -> 69.8% (verified via -coverprofile). All packages
  now report coverage in CI output.
- CI: ci.yml installs cloud-firestore-emulator beta component and
  starts it on localhost:8090 before go test. Sets
  FIRESTORE_EMULATOR_HOST + GOOGLE_CLOUD_PROJECT env so the storage
  package's emulator-gated tests execute instead of skipping.

go test -race -count=1 ./... clean across all 15 packages locally.
2026-05-09 16:19:38 +07:00

92 lines
2.9 KiB
Go

// Package testutil provides shared fixtures for handler-layer integration
// tests: update builders, a recording Bot that captures outbound API calls
// instead of hitting Telegram, and helpers to assert on captured calls.
//
// Tests should construct a real *bot.Bot (via NewRecordingBot), register
// real module handlers against it, dispatch a fixture *models.Update via
// Bot.ProcessUpdate, and then inspect Sent() to verify the reply.
package testutil
import (
"github.com/go-telegram/bot/models"
)
// NewPrivateMessage builds an Update for a 1:1 private DM. userID is both
// the chat id and the From id (private chats use the user as the chat id).
func NewPrivateMessage(userID int64, text string) *models.Update {
return &models.Update{
ID: 1,
Message: &models.Message{
ID: 1,
Text: text,
Chat: models.Chat{ID: userID, Type: models.ChatTypePrivate},
From: &models.User{ID: userID, FirstName: "Test"},
Entities: []models.MessageEntity{
botCommandEntity(text),
},
},
}
}
// NewGroupMessage builds an Update for a group chat where chatID and userID
// are distinct (group rules: subject = chat ID, sender = user ID).
func NewGroupMessage(chatID, userID int64, text string) *models.Update {
return &models.Update{
ID: 1,
Message: &models.Message{
ID: 1,
Text: text,
Chat: models.Chat{ID: chatID, Type: models.ChatTypeGroup, Title: "Test Group"},
From: &models.User{ID: userID, FirstName: "Test"},
Entities: []models.MessageEntity{
botCommandEntity(text),
},
},
}
}
// NewSupergroupMessage is the same shape as NewGroupMessage but with
// supergroup chat type — exercises the second branch in chathelper.SubjectFor.
func NewSupergroupMessage(chatID, userID int64, text string) *models.Update {
u := NewGroupMessage(chatID, userID, text)
u.Message.Chat.Type = models.ChatTypeSupergroup
return u
}
// NewChannelMessage builds an Update for a channel post — no From field on
// most channel posts. Used to exercise the "no usable subject id" path.
func NewChannelMessage(chatID int64, text string) *models.Update {
return &models.Update{
ID: 1,
Message: &models.Message{
ID: 1,
Text: text,
Chat: models.Chat{ID: chatID, Type: models.ChatTypeChannel, Title: "Test Channel"},
Entities: []models.MessageEntity{
botCommandEntity(text),
},
},
}
}
// botCommandEntity is what Telegram attaches when a message starts with `/`.
// The dispatcher's MatchTypeCommand uses it to extract the command name, so
// every fixture command-bearing message must include one.
func botCommandEntity(text string) models.MessageEntity {
if len(text) == 0 || text[0] != '/' {
return models.MessageEntity{Type: models.MessageEntityTypeBotCommand}
}
end := len(text)
for i := 1; i < len(text); i++ {
if text[i] == ' ' || text[i] == '@' {
end = i
break
}
}
return models.MessageEntity{
Type: models.MessageEntityTypeBotCommand,
Offset: 0,
Length: end,
}
}