package main import ( "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/alicebob/miniredis/v2" ) // FakeTelegram is an in-memory stand-in for api.telegram.org. It records all // inbound calls (sendMessage, deleteMessage, setWebhook, getWebhookInfo, // deleteWebhook) and always returns `{"ok":true}`. Tests can assert on the // recorded calls via Sent() / Deleted() / Webhooks(). type FakeTelegram struct { srv *httptest.Server mu sync.Mutex sent []SendMessageParams deleted []DeleteMessageParams webhooks []SetWebhookParams } func NewFakeTelegram(t *testing.T) *FakeTelegram { t.Helper() ft := &FakeTelegram{} ft.srv = httptest.NewServer(http.HandlerFunc(ft.handle)) t.Cleanup(ft.srv.Close) return ft } // URL returns the base URL to plug into TelegramClient.apiBase. func (f *FakeTelegram) URL() string { return f.srv.URL } // Client returns a TelegramClient pointing at this fake. func (f *FakeTelegram) Client() *TelegramClient { return &TelegramClient{ httpClient: &http.Client{Timeout: 5 * time.Second}, apiBase: f.srv.URL, } } // Sent / Deleted / Webhooks return snapshots of the recorded calls. func (f *FakeTelegram) Sent() []SendMessageParams { f.mu.Lock() defer f.mu.Unlock() out := make([]SendMessageParams, len(f.sent)) copy(out, f.sent) return out } func (f *FakeTelegram) Deleted() []DeleteMessageParams { f.mu.Lock() defer f.mu.Unlock() out := make([]DeleteMessageParams, len(f.deleted)) copy(out, f.deleted) return out } func (f *FakeTelegram) Webhooks() []SetWebhookParams { f.mu.Lock() defer f.mu.Unlock() out := make([]SetWebhookParams, len(f.webhooks)) copy(out, f.webhooks) return out } // LastSentTextTo returns the text of the last sendMessage targeting chatID, or "". func (f *FakeTelegram) LastSentTextTo(chatID int64) string { f.mu.Lock() defer f.mu.Unlock() for i := len(f.sent) - 1; i >= 0; i-- { if f.sent[i].ChatID == chatID { return f.sent[i].Text } } return "" } func (f *FakeTelegram) handle(w http.ResponseWriter, r *http.Request) { // Path looks like /botTOKEN/method (ignore TOKEN, dispatch on method) parts := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/"), "/", 2) method := "" if len(parts) == 2 { method = parts[1] } body, _ := io.ReadAll(r.Body) defer r.Body.Close() f.mu.Lock() defer f.mu.Unlock() switch method { case "sendMessage": var p SendMessageParams _ = json.Unmarshal(body, &p) f.sent = append(f.sent, p) case "deleteMessage": var p DeleteMessageParams _ = json.Unmarshal(body, &p) f.deleted = append(f.deleted, p) case "setWebhook": var p SetWebhookParams _ = json.Unmarshal(body, &p) f.webhooks = append(f.webhooks, p) case "getWebhookInfo": w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"ok":true,"result":{"url":"","has_custom_certificate":false,"pending_update_count":0}}`) return case "deleteWebhook": // no-op } w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"ok":true,"result":true}`) } // MakeUpdate builds a Telegram Update with a private-chat message from a given user. func MakeUpdate(updateID, fromID, chatID, msgID int64, text string) Update { return Update{ UpdateID: updateID, Message: &Message{ MessageID: msgID, From: &User{ID: fromID, FirstName: "Test"}, Chat: Chat{ID: chatID, Type: "private"}, Date: 1700000000, Text: text, }, } } // StartMiniRedis spins up an in-process Redis stub for auth tests. func StartMiniRedis(t *testing.T) *miniredis.Miniredis { t.Helper() m, err := miniredis.Run() if err != nil { t.Fatalf("miniredis: %v", err) } t.Cleanup(m.Close) return m } // NewTestAuth returns an Auth wired against the miniredis instance. func NewTestAuth(t *testing.T, m *miniredis.Miniredis, secret string, ttl time.Duration) *Auth { t.Helper() a, err := NewAuth("redis://"+m.Addr(), secret, ttl) if err != nil { t.Fatalf("NewAuth: %v", err) } return a } // PostWebhook simulates Telegram POSTing an Update to /bot/ on the gateway. // `secretToken` is sent in the X-Telegram-Bot-Api-Secret-Token header. func PostWebhook(t *testing.T, srv http.Handler, slug, secretToken string, update Update) (status int, body string) { t.Helper() payload, err := json.Marshal(update) if err != nil { t.Fatalf("marshal update: %v", err) } req := httptest.NewRequest(http.MethodPost, "/bot/"+slug, strings.NewReader(string(payload))) req.Header.Set("Content-Type", "application/json") if secretToken != "" { req.Header.Set("X-Telegram-Bot-Api-Secret-Token", secretToken) } rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) return rr.Code, rr.Body.String() } // boolPtr returns a pointer to b — convenient for BotConfig.RequireAuth. func boolPtr(b bool) *bool { return &b } // MakeRegistry builds a minimal registry from a single bot config, plumbing // in the FakeTelegram and (optional) Auth. Used across handler tests. func MakeRegistry(t *testing.T, ft *FakeTelegram, auth *Auth, slug, token, secret string, bc BotConfig) *Registry { t.Helper() bc.Token = token bc.Secret = secret cfg := &Config{Bots: map[string]BotConfig{slug: bc}} r, err := NewRegistry(cfg, ft.Client(), auth, nil) // queue=nil for sync tests if err != nil { t.Fatalf("NewRegistry: %v", err) } return r } // MakeServer wraps a Registry into a Server with allowlist/auth wiring for tests. func MakeServer(reg *Registry, auth *Auth, allow Allowlist, ft *FakeTelegram) *Server { return NewServer(reg, auth, allow, ft.Client(), nil) } // dummy ctx helper — many handler tests don't care. func bgCtx() context.Context { return context.Background() }