package main import ( "context" "encoding/json" "errors" "fmt" "io" "io/fs" "net/http" "net/http/httptest" "os" "path/filepath" "slices" "strings" "sync" "sync/atomic" "testing" "time" "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" maxbot "github.com/max-messenger/max-bot-api-client-go/v2" ) // sent is a message that reached the fake MAX. type sent struct { chatID string body struct { Text string `json:"text"` Format string `json:"format"` Notify *bool `json:"notify"` Attachments []struct { Type string `json:"type"` Payload struct { Token string `json:"token"` } `json:"payload"` } `json:"attachments"` } } // fake is a fake Telegram and MAX with a relay and a source wired to them: // posts of channel -100 go to MAX channel -42. type fake struct { ctx context.Context relay *relay source *source tgAdmin, maxAdmin, maxRejects atomic.Bool uploads atomic.Int32 tgWebhook atomic.Pointer[string] mu sync.Mutex sent []sent alerts []string } func (f *fake) messages() []sent { f.mu.Lock() defer f.mu.Unlock() return slices.Clone(f.sent) } func (f *fake) alerted() []string { f.mu.Lock() defer f.mu.Unlock() return slices.Clone(f.alerts) } func (f *fake) forward(msgs ...*models.Message) { f.relay.forward(f.ctx, f.source, &post{messages: msgs}, nil) } func fakes(t *testing.T) *fake { t.Helper() f := &fake{ctx: t.Context()} f.tgAdmin.Store(true) f.maxAdmin.Store(true) tgServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = r.ParseMultipartForm(1 << 20) switch { case strings.HasSuffix(r.URL.Path, "/getWebhookInfo"): address := "" if webhook := f.tgWebhook.Load(); webhook != nil { address = *webhook } fmt.Fprintf(w, `{"ok":true,"result":{"url":%q}}`, address) case strings.HasSuffix(r.URL.Path, "/getFile"): // A local Bot API server answers with an absolute path on the shared volume path := "files/" + r.FormValue("file_id") if strings.HasPrefix(r.FormValue("file_id"), "/") { path = r.FormValue("file_id") } fmt.Fprintf(w, `{"ok":true,"result":{"file_id":%q,"file_path":%q}}`, r.FormValue("file_id"), path) case strings.Contains(r.URL.Path, "/file/bot"): fmt.Fprint(w, "bytes of "+r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]) case strings.HasSuffix(r.URL.Path, "/getChatMember"): status := "left" if f.tgAdmin.Load() { status = "administrator" } fmt.Fprintf(w, `{"ok":true,"result":{"status":%q,"user":{"id":123,"is_bot":true,"first_name":"b"}}}`, status) case strings.HasSuffix(r.URL.Path, "/sendMessage"): if r.FormValue("chat_id") != "777" { t.Errorf("alert sent to %q", r.FormValue("chat_id")) } f.mu.Lock() f.alerts = append(f.alerts, r.FormValue("text")) f.mu.Unlock() fmt.Fprint(w, `{"ok":true,"result":{"message_id":1,"date":0,"chat":{"id":777,"type":"private"}}}`) default: http.NotFound(w, r) } })) t.Cleanup(tgServer.Close) var maxServer *httptest.Server maxServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { kind := r.URL.Query().Get("type") switch { case r.URL.Path == "/uploads": fmt.Fprintf(w, `{"url":%q,"token":"%s-token"}`, maxServer.URL+"/upload/"+kind, kind) case strings.HasPrefix(r.URL.Path, "/upload/"): f.uploads.Add(1) _, _ = io.Copy(io.Discard, r.Body) switch r.URL.Path { case "/upload/image": fmt.Fprint(w, `{"photos":{"a":{"token":"image-token"}}}`) case "/upload/file": fmt.Fprint(w, `{"token":"file-token"}`) } case strings.HasSuffix(r.URL.Path, "/members/me"): fmt.Fprintf(w, `{"user_id":1,"is_admin":%t,"permissions":["write"]}`, f.maxAdmin.Load()) case r.URL.Path == "/messages": if f.maxRejects.Load() { w.WriteHeader(http.StatusBadRequest) fmt.Fprint(w, `{"code":"proto.payload","message":"text is too long"}`) return } m := sent{chatID: r.URL.Query().Get("chat_id")} if err := json.NewDecoder(r.Body).Decode(&m.body); err != nil { t.Errorf("message body: %v", err) } // MAX channels reject silent messages, including continuation parts. if m.body.Notify != nil && !*m.body.Notify { w.WriteHeader(http.StatusBadRequest) fmt.Fprint(w, `{"code":"proto.payload","message":"errors.send-message.channel-notify"}`) return } f.mu.Lock() f.sent = append(f.sent, m) f.mu.Unlock() fmt.Fprint(w, `{"message":{"body":{"mid":"mid.test"}}}`) default: http.NotFound(w, r) } })) t.Cleanup(maxServer.Close) tg, err := bot.New("123:secret", bot.WithSkipGetMe(), bot.WithServerURL(tgServer.URL)) if err != nil { t.Fatal(err) } api, err := maxbot.NewApi("max-token", maxbot.WithBaseURL(maxServer.URL)) if err != nil { t.Fatal(err) } f.relay = &relay{ tg: tg, max: &maxClient{api: api, http: maxServer.Client(), publish: maxServer.Client(), baseURL: maxServer.URL, token: "max-token"}, http: tgServer.Client(), adminID: 777, fileLimit: tgDownloadLimit, } f.source = &source{ channel: &models.ChatFullInfo{ID: -100, Title: "Канал", Username: "channel"}, to: []maxChat{{id: -42, title: "Канал MAX"}}, } return f } func TestCancelledForwardAndChangesDoNotAlertAdmin(t *testing.T) { f := fakes(t) ctx, cancel := context.WithCancel(t.Context()) cancel() f.relay.forward(ctx, f.source, &post{messages: []*models.Message{{ID: 9, Text: "post"}}}, nil) f.relay.changeError(ctx, &deliveryBook{source: channelKey{telegramPlatform, -100}, root: "message:9"}, "MAX", context.Canceled) if err := f.relay.alert(ctx, "cancelled"); !errors.Is(err, context.Canceled) { t.Fatalf("alert did not preserve cancellation: %v", err) } if alerts := f.alerted(); len(alerts) != 0 { t.Fatalf("shutdown sent false alerts: %v", alerts) } } func TestAlertStopsWithItsParentContext(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) defer cancel() started := make(chan struct{}) tg, err := bot.New("123:secret", bot.WithSkipGetMe(), bot.WithHTTPClient(time.Minute, &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { close(started) <-req.Context().Done() return nil, req.Context().Err() }), })) if err != nil { t.Fatal(err) } r := &relay{tg: tg, adminID: 777} done := make(chan error, 1) go func() { done <- r.alert(ctx, "test") }() <-started cancel() select { case err := <-done: if !errors.Is(err, context.Canceled) { t.Fatalf("alert did not stop on cancellation: %v", err) } case <-time.After(time.Second): t.Fatal("alert outlived its parent context") } } func TestFinishedDeliveryDoesNotDownloadMediaAgain(t *testing.T) { db := testStore(t) book := &deliveryBook{store: db, source: channelKey{telegramPlatform, -100}, post: "album:a"} dst := channelKey{maxPlatform, -42} if err := book.set(t.Context(), dst, "done", nil); err != nil { t.Fatal(err) } // No clients are configured: reaching the media code would panic. r := &relay{} r.forward(t.Context(), &source{channel: &models.ChatFullInfo{ID: -100}, to: []maxChat{{id: dst.id}}}, &post{messages: []*models.Message{{ID: 1, Photo: []models.PhotoSize{{FileID: "photo"}}}}}, book) if book.err != nil { t.Fatal(book.err) } } func TestAlbumGoesAsOneMessageWithCaption(t *testing.T) { f := fakes(t) f.forward( &models.Message{ID: 11, MediaGroupID: "g", Video: &models.Video{FileID: "v1"}}, &models.Message{ID: 10, MediaGroupID: "g", Photo: []models.PhotoSize{{FileID: "small"}, {FileID: "big"}}, Caption: "Афиша недели", CaptionEntities: []models.MessageEntity{entity(models.MessageEntityTypeBold, 0, 5)}}, ) got := f.messages() if len(got) != 1 { t.Fatalf("want 1 message, got %d", len(got)) } m := got[0] if m.body.Text != "Афиша недели" || m.body.Format != "html" || m.chatID != "-42" { t.Fatalf("text %q format %q chat %q", m.body.Text, m.body.Format, m.chatID) } var kinds []string for _, a := range m.body.Attachments { kinds = append(kinds, a.Type+":"+a.Payload.Token) } if strings.Join(kinds, ",") != "image:image-token,video:video-token" { t.Fatalf("attachments %v", kinds) } } func TestFilesGoSeparatelyWithChannelNotifications(t *testing.T) { f := fakes(t) f.forward( &models.Message{ID: 1, MediaGroupID: "d", Document: &models.Document{FileID: "d1", FileName: "a.pdf"}, Caption: "Программа"}, &models.Message{ID: 2, MediaGroupID: "d", Document: &models.Document{FileID: "d2", FileName: "b.pdf"}}, ) got := f.messages() if len(got) != 2 { t.Fatalf("want 2 messages, got %d", len(got)) } if got[0].body.Text != "Программа" || len(got[0].body.Attachments) != 1 || got[0].body.Notify != nil { t.Fatalf("first message: %+v", got[0].body) } if got[1].body.Text != "" || len(got[1].body.Attachments) != 1 || got[1].body.Notify != nil { t.Fatalf("second message: %+v", got[1].body) } } func TestLongPostSendsEveryPartWithChannelNotifications(t *testing.T) { f := fakes(t) paragraph := strings.Repeat("я", 2000) f.forward(&models.Message{ID: 1, Text: strings.Join([]string{paragraph, paragraph, paragraph}, "\n\n")}) got := f.messages() if len(got) != 3 { t.Fatalf("want 3 messages, got %d; alerts: %v", len(got), f.alerted()) } for i, msg := range got { if msg.body.Text != paragraph || msg.body.Notify != nil { t.Fatalf("part %d: %+v", i+1, msg.body) } } if alerts := f.alerted(); len(alerts) != 0 { t.Fatalf("alerts: %v", alerts) } } func TestPostGoesToEveryDestination(t *testing.T) { f := fakes(t) f.source.to = append(f.source.to, maxChat{id: -43, title: "Второй канал MAX"}) f.forward(&models.Message{ID: 5, Caption: "Фото", Photo: []models.PhotoSize{{FileID: "p"}}}) got := f.messages() if len(got) != 2 || got[0].chatID != "-42" || got[1].chatID != "-43" { t.Fatalf("got %+v", got) } // Downloaded once, uploaded for each destination if n := f.uploads.Load(); n != 2 { t.Fatalf("uploads %d", n) } } func TestUnsupportedContentLeavesLinkToPost(t *testing.T) { f := fakes(t) f.forward(&models.Message{ID: 7, Poll: &models.Poll{Question: "Куда идём?"}}) got := f.messages() want := `Открыть пост в Telegram` if len(got) != 1 || got[0].body.Text != want { t.Fatalf("got %+v", got) } } func TestServiceMessageIsSkipped(t *testing.T) { f := fakes(t) f.forward(&models.Message{ID: 3, NewChatTitle: "Новое название"}) if got := f.messages(); len(got) != 0 { t.Fatalf("got %+v", got) } } func TestRepostSaysWhereItCameFrom(t *testing.T) { f := fakes(t) f.forward(&models.Message{ ID: 8, Text: "Чужая новость", ForwardOrigin: &models.MessageOrigin{ Type: models.MessageOriginTypeChannel, MessageOriginChannel: &models.MessageOriginChannel{ Chat: models.Chat{Title: "Источник", Username: "source"}, MessageID: 5, }, }, }) got := f.messages() want := "Переслано из Источник\n\nЧужая новость" if len(got) != 1 || got[0].body.Text != want { t.Fatalf("got %+v", got) } } func TestFailedPostIsReportedToAdmin(t *testing.T) { f := fakes(t) f.maxRejects.Store(true) f.forward(&models.Message{ID: 9, Text: "Пост"}) alerts := f.alerted() if len(alerts) != 1 || !strings.Contains(alerts[0], "пост https://t.me/channel/9 перенесён с ошибками") || !strings.Contains(alerts[0], "«Канал MAX»") || !strings.Contains(alerts[0], "text is too long") { t.Fatalf("alerts %q", alerts) } } func TestTooBigVideoIsLinkedAndReported(t *testing.T) { f := fakes(t) f.forward(&models.Message{ID: 4, Caption: "Запись лекции", Video: &models.Video{FileID: "v", FileSize: 300 << 20}}) got := f.messages() want := "Запись лекции\n\nОткрыть пост в Telegram" if len(got) != 1 || got[0].body.Text != want { t.Fatalf("got %+v", got) } if alerts := f.alerted(); len(alerts) != 1 || !strings.Contains(alerts[0], "video.mp4") { t.Fatalf("alerts %q", alerts) } } func TestLocalBotAPIFileIsStreamedFromDiskAndRemoved(t *testing.T) { f := fakes(t) f.relay.fileLimit = 0 // own Bot API server: no 20 MB limit path := filepath.Join(t.TempDir(), "bot-token", "videos", "file_0.mp4") if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte("video bytes"), 0o600); err != nil { t.Fatal(err) } f.forward(&models.Message{ID: 12, Caption: "Запись", Video: &models.Video{FileID: path, FileSize: 200 << 20}}) got := f.messages() if len(got) != 1 || got[0].body.Text != "Запись" || len(got[0].body.Attachments) != 1 || got[0].body.Attachments[0].Type != "video" { t.Fatalf("got %+v", got) } if alerts := f.alerted(); len(alerts) != 0 { t.Fatalf("alerts %q", alerts) } if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) { t.Fatalf("downloaded file is still on the volume: %v", err) } } func TestMissingLocalFileErrorHidesThePath(t *testing.T) { f := fakes(t) f.relay.fileLimit = 0 path := filepath.Join(t.TempDir(), "123:secret-token", "videos", "gone.mp4") f.forward(&models.Message{ID: 13, Caption: "Запись", Video: &models.Video{FileID: path}}) alerts := f.alerted() if len(alerts) != 1 || strings.Contains(alerts[0], "secret-token") || !strings.Contains(alerts[0], "общем томе") { t.Fatalf("alerts %q", alerts) } }