changes_test.go
Rama main · réplica de lectura del repositorio original.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
)
func TestOldTelegramCopiesReplaceStaleContentsWhenDeletionIsForbidden(t *testing.T) {
for _, kind := range []mirrorKind{mirrorText, mirrorCaption, mirrorMedia} {
t.Run(string(kind), func(t *testing.T) {
var calls []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
method := strings.TrimPrefix(req.URL.Path, "/bottoken/")
calls = append(calls, method)
if method == "deleteMessage" {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"ok":false,"error_code":400,"description":"Bad Request: message can't be deleted for everyone"}`)
return
}
var payload map[string]json.RawMessage
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
t.Error(err)
}
field := "text"
if kind != mirrorText {
field = "caption"
}
if string(payload[field]) != `"Содержимое удалено."` {
t.Errorf("payload=%s", payload[field])
}
fmt.Fprint(w, `{"ok":true,"result":true}`)
}))
t.Cleanup(server.Close)
api := telegramAPI{baseURL: server.URL, token: "token", http: server.Client()}
if err := api.deleteMirror(t.Context(), -100, mirrorPart{id: "123", kind: kind}); err != nil {
t.Fatal(err)
}
edit := "editMessageText"
if kind != mirrorText {
edit = "editMessageCaption"
}
if !slices.Equal(calls, []string{"deleteMessage", edit}) {
t.Fatalf("calls=%v", calls)
}
})
}
}
func TestMissingTelegramCopyDoesNotCreateTombstone(t *testing.T) {
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
calls++
if !strings.HasSuffix(req.URL.Path, "/deleteMessage") {
t.Error("unexpected edit")
}
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"ok":false,"error_code":400,"description":"Bad Request: message to delete not found"}`)
}))
t.Cleanup(server.Close)
api := telegramAPI{baseURL: server.URL, token: "token", http: server.Client()}
if err := api.deleteMirror(t.Context(), -100, mirrorPart{id: "123", kind: mirrorText}); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("calls %d", calls)
}
}
func TestEditingMissingTelegramCopyDoesNotRepublishOrAlert(t *testing.T) {
for _, kind := range []mirrorKind{mirrorText, mirrorCaption} {
t.Run(string(kind), func(t *testing.T) {
db := testStore(t)
src, dst := channelKey{maxPlatform, -42}, channelKey{telegramPlatform, -100}
seedMirrors(t, db, src, dst, "message:original", []string{"123"}, kind)
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
calls++
if !strings.Contains(req.URL.Path, "/editMessage") {
t.Errorf("unexpected request: %s", req.URL.Path)
}
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"ok":false,"error_code":400,"description":"Bad Request: message to edit not found"}`)
}))
t.Cleanup(server.Close)
api := telegramAPI{baseURL: server.URL, token: "token", http: server.Client()}
book := &deliveryBook{store: db, source: src, post: "change:edit", root: "message:original"}
// A longer revision must not recreate a removed copy as extra chunks.
for range 2 {
err := syncMirrorText(t.Context(), book, dst, func(bool) (telegramText, []telegramText) {
return telegramText{text: "caption"}, []telegramText{{text: "edited"}, {text: "extra"}}
}, func(part mirrorPart, text telegramText) error {
return api.editText(t.Context(), dst.id, part, text)
}, func(mirrorPart) error {
t.Error("must not delete other parts")
return nil
}, func(telegramText, publish) error {
t.Error("must not publish a replacement or a new tail")
return nil
})
if err != nil || book.err != nil {
t.Fatalf("missing copy should be skipped: %v, storage: %v", err, book.err)
}
}
if calls != 2 {
t.Fatalf("calls=%d, want one per revision", calls)
}
parts, err := book.mirrors(t.Context(), dst)
if err != nil || len(parts) != 1 || parts[0].id != "123" {
t.Fatalf("keep the known copy to prevent resurrection: %v, %v", parts, err)
}
})
}
}
func TestTelegramEditPermissionErrorIsNotMissingCopy(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"ok":false,"error_code":400,"description":"Bad Request: message can't be edited"}`)
}))
t.Cleanup(server.Close)
api := telegramAPI{baseURL: server.URL, token: "token", http: server.Client()}
if err := api.editText(t.Context(), -100, mirrorPart{id: "123", kind: mirrorText}, telegramText{text: "edit"}); err == nil {
t.Fatal("permission errors must remain visible")
}
}
func submitHook(t *testing.T, hook http.Handler, header, secret, body string, want int) {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/webhook", strings.NewReader(body))
req.Header.Set(header, secret)
res := httptest.NewRecorder()
hook.ServeHTTP(res, req)
if res.Code != want {
t.Fatalf("webhook status=%d, want %d", res.Code, want)
}
}
func nextChange(t *testing.T, db *store, source channelKey) (pendingPost, *deliveryBook) {
t.Helper()
p, err := db.next(t.Context(), source, time.Now().Add(albumWait))
if err != nil || !strings.HasPrefix(p.key, "change:") {
t.Fatalf("expected a queued change, got %+v, %v", p, err)
}
return p, &deliveryBook{store: db, source: source, post: p.key}
}
func seedMirrors(t *testing.T, db *store, source, destination channelKey, root string, ids []string, kinds ...mirrorKind) {
t.Helper()
book := &deliveryBook{store: db, source: source, post: root}
if err := book.set(t.Context(), destination, "done", ids, kinds...); err != nil {
t.Fatal(err)
}
}
func TestInterruptedRevisionAppendsOnlyItsMissingTail(t *testing.T) {
db := testStore(t)
src, dst := channelKey{telegramPlatform, -100}, channelKey{maxPlatform, -42}
seedMirrors(t, db, src, dst, "message:8", []string{"original"}, mirrorText)
book := &deliveryBook{store: db, source: src, post: "change:8", root: "message:8"}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
var appended []string
syncText := func(ctx context.Context, stop bool) error {
return syncMirrorText(ctx, book, dst, func(bool) (string, []string) {
return "", []string{"first", "second", "third"}
}, func(mirrorPart, string) error { return nil }, func(mirrorPart) error {
t.Error("no part should be removed")
return nil
}, func(text string, publish publish) error {
return publish(ctx, func() ([]string, error) {
appended = append(appended, text)
if stop {
cancel()
}
return []string{text}, nil
}, mirrorText)
})
}
if err := syncText(ctx, true); !errors.Is(err, context.Canceled) {
t.Fatalf("revision was not interrupted between parts: %v", err)
}
if err := syncText(t.Context(), false); err != nil {
t.Fatal(err)
}
if !slices.Equal(appended, []string{"second", "third"}) {
t.Fatalf("revision appended %v", appended)
}
}
func TestTelegramWebhookEditsSplitCopyInPlaceOnlyForEnabledPair(t *testing.T) {
ctx := t.Context()
db := testStore(t)
sourceKey := channelKey{telegramPlatform, -100}
maxOne, maxTwo := channelKey{maxPlatform, -42}, channelKey{maxPlatform, -43}
src := &source{
channel: &models.ChatFullInfo{ID: -100, Title: "source"},
to: []maxChat{{id: -42}, {id: -43}},
changes: map[int64]syncOptions{-42: {edits: true}},
}
hook := telegramWebhook("secret", db, map[int64]*source{-100: src})
header := "X-Telegram-Bot-Api-Secret-Token"
submitHook(t, hook, header, "wrong", "{}", http.StatusUnauthorized)
postJSON := `{"update_id":21,"channel_post":{"message_id":8,"chat":{"id":-100,"type":"channel"},"text":"original"}}`
for range 2 {
submitHook(t, hook, header, "secret", postJSON, http.StatusOK)
}
p, err := db.next(ctx, sourceKey, time.Now())
if err != nil || p.key != "message:8" || len(p.payloads) != 1 {
t.Fatalf("original not saved exactly once: %+v %v", p, err)
}
if err := db.complete(ctx, sourceKey, p); err != nil {
t.Fatal(err)
}
seedMirrors(t, db, sourceKey, maxOne, p.key, []string{"mid.first"}, mirrorText)
seedMirrors(t, db, sourceKey, maxTwo, p.key, []string{"mid.disabled"}, mirrorText)
var calls []string
var mu sync.Mutex
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
mu.Lock()
defer mu.Unlock()
id := req.URL.Query().Get("message_id")
switch req.Method {
case http.MethodPut:
calls = append(calls, "edit:"+id)
var payload map[string]json.RawMessage
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil || payload["text"] == nil || payload["attachments"] != nil {
t.Errorf("editing the body should keep existing media: %+v, %v", payload, err)
}
fmt.Fprint(w, `{"success":true}`)
case http.MethodPost:
var payload struct {
Notify *bool `json:"notify"`
}
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
t.Error(err)
}
if payload.Notify != nil && !*payload.Notify {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"code":"proto.payload","message":"errors.send-message.channel-notify"}`)
return
}
calls = append(calls, "add:"+req.URL.Query().Get("chat_id"))
fmt.Fprint(w, `{"message":{"body":{"mid":"mid.extra"}}}`)
case http.MethodDelete:
calls = append(calls, "delete:"+id)
fmt.Fprint(w, `{"success":true}`)
}
}))
defer server.Close()
r := &relay{max: &maxClient{baseURL: server.URL, publish: server.Client()}}
for _, revision := range []struct {
seq, stamp int
text string
}{
{22, 100, strings.Repeat("word & ", 500)},
{23, 101, "short again"},
{24, 99, "stale"},
} {
payload := fmt.Sprintf(`{"update_id":%d,"edited_channel_post":{"message_id":8,"edit_date":%d,"chat":{"id":-100,"type":"channel"},"text":%q}}`,
revision.seq, revision.stamp, revision.text)
for range 2 {
submitHook(t, hook, header, "secret", payload, http.StatusOK)
}
p, book := nextChange(t, db, sourceKey)
r.editFromTelegram(ctx, src, p, book)
if book.err != nil {
t.Fatal(book.err)
}
if err := db.complete(ctx, sourceKey, p); err != nil {
t.Fatal(err)
}
if p, err := db.next(ctx, sourceKey, time.Now()); err != nil || p.row != 0 {
t.Fatalf("unexpected duplicate change: %+v, %v", p, err)
}
}
mu.Lock()
defer mu.Unlock()
want := []string{"edit:mid.first", "add:-42", "edit:mid.first", "delete:mid.extra"}
if !slices.Equal(calls, want) {
t.Fatalf("calls=%v, want %v", calls, want)
}
}
func TestMaxWebhookEditsCaptionAndDeletesOnlyEnabledMirrors(t *testing.T) {
ctx := t.Context()
db := testStore(t)
sourceKey := channelKey{maxPlatform, -42}
one, two := channelKey{telegramPlatform, -100}, channelKey{telegramPlatform, -101}
src := &maxSource{
channel: maxChat{id: -42},
to: []*models.ChatFullInfo{{ID: -100}, {ID: -101}},
changes: map[int64]syncOptions{-100: {edits: true, deletes: true}, -101: {edits: true}},
}
seedMirrors(t, db, sourceKey, one, "message:mid.source", []string{"11", "12", "13"}, mirrorCaption, mirrorMedia, mirrorText)
seedMirrors(t, db, sourceKey, two, "message:mid.source", []string{"21"}, mirrorCaption)
hook := maxWebhook("secret", db, map[int64]*maxSource{-42: src})
var calls []string
var mu sync.Mutex
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
var body struct {
ID int `json:"message_id"`
Caption string `json:"caption"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
t.Error(err)
}
method := strings.TrimPrefix(req.URL.Path, "/bottoken/")
mu.Lock()
calls = append(calls, fmt.Sprintf("%s:%d", method, body.ID))
mu.Unlock()
if method == "editMessageCaption" && body.Caption != "edited" {
t.Errorf("caption=%q", body.Caption)
}
fmt.Fprint(w, `{"ok":true,"result":true}`)
}))
defer server.Close()
r := &relay{tgAPI: telegramAPI{baseURL: server.URL, token: "token", http: server.Client()}}
for _, payload := range []string{
`{"update_type":"message_edited","timestamp":10,"message":{"recipient":{"chat_type":"channel","chat_id":-42},"body":{"mid":"mid.source","text":"edited"}}}`,
`{"update_type":"message_removed","timestamp":11,"chat_id":-42,"message_id":"mid.source"}`,
} {
submitHook(t, hook, "X-Max-Bot-Api-Secret", "secret", payload, http.StatusOK)
p, book := nextChange(t, db, sourceKey)
r.changeFromMax(ctx, src, p, book)
if book.err != nil {
t.Fatal(book.err)
}
if err := db.complete(ctx, sourceKey, p); err != nil {
t.Fatal(err)
}
}
mu.Lock()
defer mu.Unlock()
want := []string{"editMessageCaption:11", "deleteMessage:13", "editMessageCaption:21", "deleteMessage:11", "deleteMessage:12"}
if !slices.Equal(calls, want) {
t.Fatalf("calls=%v, want %v", calls, want)
}
}
func TestTelegramWebhookRegistrationKeepsPendingUpdatesAndRefusesAnotherURL(t *testing.T) {
ctx := t.Context()
address := "https://example.test/perepost/telegram"
var oldURL string
var configured map[string]string
var mu sync.Mutex
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
mu.Lock()
defer mu.Unlock()
switch req.URL.Path {
case "/bottoken/getWebhookInfo":
fmt.Fprintf(w, `{"ok":true,"result":{"url":%q}}`, oldURL)
case "/bottoken/setWebhook":
if err := req.ParseMultipartForm(1024); err != nil {
t.Error(err)
}
configured = map[string]string{}
for _, key := range []string{"url", "allowed_updates", "drop_pending_updates", "secret_token", "max_connections"} {
configured[key] = req.FormValue(key)
}
fmt.Fprint(w, `{"ok":true,"result":true}`)
}
}))
defer server.Close()
tg, err := bot.New("token", bot.WithSkipGetMe(), bot.WithServerURL(server.URL))
if err != nil {
t.Fatal(err)
}
events := []string{"channel_post", telegramEdit}
if err := configureTelegramWebhook(ctx, tg, address, "secret", events, true); err != nil {
t.Fatal(err)
}
mu.Lock()
want := map[string]string{"url": address, "allowed_updates": `["channel_post","edited_channel_post"]`, "drop_pending_updates": "", "secret_token": "secret", "max_connections": "1"}
if !reflect.DeepEqual(configured, want) {
t.Errorf("registration params=%v, want %v", configured, want)
}
oldURL, configured = "https://example.test/foreign", nil
mu.Unlock()
if err := configureTelegramWebhook(ctx, tg, address, "secret", events, true); err == nil {
t.Fatal("another webhook should be preserved")
}
mu.Lock()
defer mu.Unlock()
if configured != nil {
t.Fatalf("foreign webhook was modified: %v", configured)
}
}