yao/integrations/telegram/webhook_e2e_test.go
Max ea9e070f29 Enhance robot integration with Telegram and improve event handling
- Add Telegram integration support by introducing a dispatcher for handling Telegram events and messages.
- Implement event notifications for robot configuration changes (creation, update, deletion) to facilitate integration with external services.
- Refactor the robot initialization process to load robots into cache and start the dispatcher, improving the overall system setup.
- Update the delivery event structure to include additional metadata for better context during message handling.
- Enhance logging capabilities for better observability during robot execution and event processing.
2026-03-01 22:03:25 +08:00

280 lines
7.5 KiB
Go

package telegram
import (
"context"
"fmt"
"net"
"net/http"
"os"
"sync"
"testing"
"time"
)
// TestE2E_Webhook requires a local telegram-bot-api server (TELEGRAM_TEST_HOST)
// because the official Telegram API only accepts HTTPS webhooks on public IPs.
// A local Bot API server can deliver webhooks to http://127.0.0.1.
//
// Flow:
// 1. Start a local HTTP server on a random port
// 2. SetWebhook to http://127.0.0.1:{port}/webhook with a secret token
// 3. Seed a message via MTProto
// 4. Wait for the webhook to deliver the Update
// 5. Verify: ParseWebhookPayload succeeds, secret header correct, Update fields valid
// 6. DeleteWebhook to restore polling mode
func TestE2E_Webhook(t *testing.T) {
skipIfNoToken(t)
host := os.Getenv("TELEGRAM_TEST_HOST")
if host == "" {
t.Skip("TELEGRAM_TEST_HOST not set — need local bot-api server for webhook test")
}
const secret = "e2e-webhook-test-secret"
b := testBot(WithAPIBase(host))
bWithSecret := NewBot(testBotToken, secret, WithAPIBase(host))
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// --- 1. Start local webhook receiver ---
var (
mu sync.Mutex
received []webhookHit
wrongSecret int
)
mux := http.NewServeMux()
mux.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) {
headerSec := r.Header.Get("X-Telegram-Bot-Api-Secret-Token")
cm, err := bWithSecret.ParseWebhookPayload(r.Context(), r, nil)
mu.Lock()
defer mu.Unlock()
if err != nil {
wrongSecret++
t.Logf("webhook: rejected request (secret=%q err=%v)", headerSec, err)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
received = append(received, webhookHit{
secret: headerSec,
cm: cm,
})
if cm != nil {
t.Logf("webhook: accepted update_id=%d secret=%q", cm.UpdateID, headerSec)
} else {
t.Logf("webhook: accepted (no processable message) secret=%q", headerSec)
}
w.WriteHeader(http.StatusOK)
})
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
port := listener.Addr().(*net.TCPAddr).Port
server := &http.Server{Handler: mux}
go server.Serve(listener)
defer server.Close()
t.Logf("webhook server listening on 127.0.0.1:%d", port)
// --- 2. SetWebhook ---
webhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", port)
if err := bWithSecret.SetWebhook(ctx, webhookURL, []string{"message"}); err != nil {
t.Fatalf("SetWebhook: %v", err)
}
t.Logf("SetWebhook -> %s", webhookURL)
defer func() {
cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanCancel()
if err := b.DeleteWebhook(cleanCtx, true); err != nil {
t.Logf("warning: DeleteWebhook failed: %v", err)
} else {
t.Log("DeleteWebhook -> ok (polling restored)")
}
}()
// --- 3. Seed a message ---
seedBotMessages(t)
// --- 4. Wait for webhook delivery ---
deadline := time.After(30 * time.Second)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-deadline:
mu.Lock()
count := len(received)
mu.Unlock()
if count == 0 {
t.Fatal("timeout: no webhook updates received within 30s")
}
goto verify
case <-ticker.C:
mu.Lock()
count := len(received)
mu.Unlock()
if count >= 2 {
time.Sleep(2 * time.Second)
goto verify
}
}
}
verify:
mu.Lock()
defer mu.Unlock()
if len(received) == 0 {
t.Fatal("no webhook updates received")
}
t.Logf("total webhook hits: %d (rejected: %d)", len(received), wrongSecret)
// --- 5. Validate received updates ---
for i, hit := range received {
t.Run(fmt.Sprintf("update_%d", i), func(t *testing.T) {
if hit.secret != secret {
t.Errorf("secret header = %q, want %q", hit.secret, secret)
}
cm := hit.cm
if cm == nil {
t.Log("webhook hit has no processable message (cm nil)")
return
}
if cm.UpdateID == 0 {
t.Error("ConvertedMessage.UpdateID should not be 0")
}
if cm.MessageID == 0 {
t.Error("ConvertedMessage.MessageID should not be 0")
}
if cm.ChatID == 0 {
t.Error("ConvertedMessage.ChatID should not be 0")
}
if cm.Date == 0 {
t.Error("ConvertedMessage.Date should not be 0")
}
if cm.SenderID == 0 {
t.Error("ConvertedMessage.SenderID should not be 0")
}
if cm.SenderName == "" {
t.Error("ConvertedMessage.SenderName should not be empty")
}
if !cm.HasText() && !cm.HasMedia() {
t.Error("message has neither text nor media")
}
for j, mi := range cm.MediaItems {
if mi.FileID == "" {
t.Errorf("media[%d].FileID should not be empty", j)
}
if mi.FileUniqueID == "" {
t.Errorf("media[%d].FileUniqueID should not be empty", j)
}
if mi.MimeType == "" {
t.Errorf("media[%d].MimeType should not be empty", j)
}
if mi.Type == "" {
t.Errorf("media[%d].Type should not be empty", j)
}
}
t.Logf("webhook update[%d] id=%d msg=%d chat=%d sender=%q text=%q media=%d",
i, cm.UpdateID, cm.MessageID, cm.ChatID, cm.SenderName,
truncate(cm.Text, 40), len(cm.MediaItems))
})
}
// --- 6. Compare with GetUpdates ---
// Delete webhook first, then seed + poll to verify ConvertUpdate consistency.
t.Run("convert_consistency", func(t *testing.T) {
delCtx, delCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer delCancel()
if err := b.DeleteWebhook(delCtx, true); err != nil {
t.Fatalf("DeleteWebhook for polling: %v", err)
}
time.Sleep(time.Second)
seedBotMessages(t)
time.Sleep(2 * time.Second)
pollCtx, pollCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer pollCancel()
polled := fetchUpdates(t, b, pollCtx)
if len(polled) == 0 {
t.Skip("no polled updates to compare")
}
polledCM := polled[0]
if polledCM == nil {
t.Skip("polled update has no message")
}
var webhookCM *ConvertedMessage
for _, hit := range received {
if hit.cm != nil {
webhookCM = hit.cm
break
}
}
if webhookCM == nil {
t.Skip("no webhook update with message")
}
if webhookCM.ChatID == 0 || polledCM.ChatID == 0 {
t.Error("both sources should have non-zero ChatID")
}
if webhookCM.SenderID == 0 || polledCM.SenderID == 0 {
t.Error("both sources should have non-zero SenderID")
}
if webhookCM.Date == 0 || polledCM.Date == 0 {
t.Error("both sources should have non-zero Date")
}
if webhookCM.ChatType != polledCM.ChatType {
t.Errorf("ChatType mismatch: webhook=%q polled=%q", webhookCM.ChatType, polledCM.ChatType)
}
webhookHasContent := webhookCM.HasText() || webhookCM.HasMedia()
polledHasContent := polledCM.HasText() || polledCM.HasMedia()
if !webhookHasContent {
t.Error("webhook ConvertUpdate produced no content")
}
if !polledHasContent {
t.Error("polled ConvertUpdate produced no content")
}
t.Logf("consistency OK: webhook(text=%v media=%d) polled(text=%v media=%d) chat_type=%s",
webhookCM.HasText(), len(webhookCM.MediaItems),
polledCM.HasText(), len(polledCM.MediaItems),
polledCM.ChatType)
})
// Verify ParseWebhookPayload rejects wrong secrets
t.Run("wrong_secret_rejected", func(t *testing.T) {
fakeReq, _ := http.NewRequest("POST", "/webhook", nil)
fakeReq.Header.Set("X-Telegram-Bot-Api-Secret-Token", "wrong-secret")
_, err := bWithSecret.ParseWebhookPayload(fakeReq.Context(), fakeReq, nil)
if err == nil {
t.Error("expected error for wrong secret, got nil")
}
})
}
type webhookHit struct {
secret string
cm *ConvertedMessage
}
func min(a, b int) int {
if a < b {
return a
}
return b
}