Files
Memoh/internal/channel/adapters/telegram/stream_test.go
T
BBQ c46f284556 fix(telegram): handle stream edit errors and 429 rate limit
- Treat 400 "message is not modified" as success to avoid user-facing error
- On 429: sleep retry_after and retry once in editTelegramMessageText;
  stream backs off lastEditedAt and returns nil
- Require error code 400 for message-not-modified check; add production
  error string to unit tests
- Lower base throttle to 250ms; add test hooks and tests for 429 retry
  and stream backoff
2026-02-13 19:52:22 +08:00

235 lines
6.2 KiB
Go

package telegram
import (
"context"
"strings"
"testing"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/memohai/memoh/internal/channel"
)
func TestTelegramOutboundStream_CloseNil(t *testing.T) {
t.Parallel()
var s *telegramOutboundStream
ctx := context.Background()
if err := s.Close(ctx); err != nil {
t.Fatalf("Close on nil stream should return nil: %v", err)
}
}
func TestTelegramOutboundStream_PushClosed(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{adapter: adapter}
s.closed.Store(true)
ctx := context.Background()
err := s.Push(ctx, channel.StreamEvent{Type: channel.StreamEventDelta, Delta: "x"})
if err == nil {
t.Fatal("Push on closed stream should return error")
}
if !strings.Contains(err.Error(), "closed") {
t.Fatalf("expected closed error: %v", err)
}
}
func TestTelegramOutboundStream_PushStatusNoOp(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{adapter: adapter}
ctx := context.Background()
err := s.Push(ctx, channel.StreamEvent{Type: channel.StreamEventStatus})
if err != nil {
t.Fatalf("StreamEventStatus should be no-op: %v", err)
}
}
func TestTelegramOutboundStream_PushNilAdapter(t *testing.T) {
t.Parallel()
s := &telegramOutboundStream{adapter: nil}
ctx := context.Background()
err := s.Push(ctx, channel.StreamEvent{Type: channel.StreamEventDelta, Delta: "x"})
if err == nil {
t.Fatal("Push with nil adapter should return error")
}
if !strings.Contains(err.Error(), "not configured") {
t.Fatalf("expected not configured error: %v", err)
}
}
func TestTelegramOutboundStream_PushUnsupportedEventType(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{adapter: adapter}
ctx := context.Background()
err := s.Push(ctx, channel.StreamEvent{Type: channel.StreamEventType("unknown")})
if err == nil {
t.Fatal("Push with unknown event type should return error")
}
if !strings.Contains(err.Error(), "unsupported") {
t.Fatalf("expected unsupported error: %v", err)
}
}
func TestTelegramOutboundStream_PushEmptyDeltaNoOp(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{adapter: adapter}
ctx := context.Background()
err := s.Push(ctx, channel.StreamEvent{Type: channel.StreamEventDelta, Delta: ""})
if err != nil {
t.Fatalf("empty delta should be no-op: %v", err)
}
}
func TestTelegramOutboundStream_PushErrorEventEmptyNoOp(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{adapter: adapter}
ctx := context.Background()
err := s.Push(ctx, channel.StreamEvent{Type: channel.StreamEventError, Error: ""})
if err != nil {
t.Fatalf("empty error event should be no-op: %v", err)
}
}
func TestTelegramOutboundStream_CloseContextCanceled(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{adapter: adapter}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := s.Close(ctx)
if err != context.Canceled {
t.Fatalf("Close with canceled context should return context.Canceled: %v", err)
}
}
// Test editStreamMessage dedup: no API call when content equals lastEdited (avoids Telegram "message is not modified" error).
func TestEditStreamMessage_NoEditWhenSameContent(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{
adapter: adapter,
streamChatID: 1,
streamMsgID: 1,
lastEdited: "hello",
lastEditedAt: time.Now().Add(-time.Minute),
}
ctx := context.Background()
tests := []struct {
name string
text string
}{
{"exact same", "hello"},
{"trimmed same", " hello "},
{"leading space", " hello"},
{"trailing space", "hello "},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := s.editStreamMessage(ctx, tt.text)
if err != nil {
t.Fatalf("editStreamMessage(same content) should return nil to avoid duplicate edit API call: %v", err)
}
})
}
}
func TestEditStreamMessage_NoEditWhenMessageNotSent(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{adapter: adapter, streamMsgID: 0}
ctx := context.Background()
err := s.editStreamMessage(ctx, "any")
if err != nil {
t.Fatalf("editStreamMessage when streamMsgID==0 should return nil: %v", err)
}
}
func TestEditStreamMessage_NoEditWhenThrottled(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{
adapter: adapter,
streamChatID: 1,
streamMsgID: 1,
lastEdited: "a",
lastEditedAt: time.Now(), // just now, within 350ms
}
ctx := context.Background()
err := s.editStreamMessage(ctx, "ab")
if err != nil {
t.Fatalf("editStreamMessage within throttle window and no newline should skip edit and return nil: %v", err)
}
}
func TestEditStreamMessage_429SetsBackoffAndReturnsNil(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
before := time.Now().Add(-time.Minute)
s := &telegramOutboundStream{
adapter: adapter,
cfg: channel.ChannelConfig{ID: "test", Credentials: map[string]any{"bot_token": "fake"}},
streamChatID: 1,
streamMsgID: 1,
lastEdited: "a",
lastEditedAt: before,
}
ctx := context.Background()
origGetBot := getOrCreateBotForTest
origEdit := testEditFunc
getOrCreateBotForTest = func(_ *TelegramAdapter, _, _ string) (*tgbotapi.BotAPI, error) {
return &tgbotapi.BotAPI{Token: "fake"}, nil
}
testEditFunc = func(*tgbotapi.BotAPI, int64, int, string, string) error {
return tgbotapi.Error{
Code: 429,
Message: "Too Many Requests",
ResponseParameters: tgbotapi.ResponseParameters{RetryAfter: 2},
}
}
defer func() {
getOrCreateBotForTest = origGetBot
testEditFunc = origEdit
}()
err := s.editStreamMessage(ctx, "b")
if err != nil {
t.Fatalf("editStreamMessage on 429 should return nil (backoff): %v", err)
}
s.mu.Lock()
lastEdited := s.lastEdited
lastEditedAt := s.lastEditedAt
s.mu.Unlock()
if lastEdited != "a" {
t.Fatalf("on 429 lastEdited should remain unchanged: got %q", lastEdited)
}
if !lastEditedAt.After(before) {
t.Fatalf("on 429 lastEditedAt should be pushed forward for backoff: got %v", lastEditedAt)
}
}