Files
2026-06-02 23:14:41 +08:00

912 lines
27 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package max
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
)
func TestSplitMessage(t *testing.T) {
cases := []struct {
name string
in string
limit int
chunks int
firstLen int // expected len of first chunk (0 = skip)
}{
{"short stays whole", "hello", 10, 1, 5},
{"exact limit stays whole", "abcdefghij", 10, 1, 10},
{"over limit splits", strings.Repeat("a", 25), 10, 3, 10},
{"newline aware over threshold", strings.Repeat("a", 150) + "\n" + strings.Repeat("b", 150), 200, 2, 150},
// Cyrillic each rune = 2 bytes UTF-8: rune-based split must count runes.
{"cyrillic stays whole under rune limit", strings.Repeat("а", 100), 200, 1, 100},
{"cyrillic splits at rune limit", strings.Repeat("а", 250), 100, 3, 100},
{"cyrillic prefers paragraph break", strings.Repeat("а", 50) + "\n\n" + strings.Repeat("б", 50), 80, 2, 50},
{"cyrillic prefers space over rune cut", strings.Repeat("а ", 50) + strings.Repeat("б", 50), 120, 2, 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := splitMessage(c.in, c.limit)
if len(got) != c.chunks {
t.Fatalf("chunks: got %d, want %d (%q)", len(got), c.chunks, got)
}
if c.firstLen > 0 {
gotRunes := len([]rune(got[0]))
if gotRunes != c.firstLen {
t.Errorf("first chunk rune len: got %d, want %d (%q)", gotRunes, c.firstLen, got[0])
}
}
// Each chunk must be valid UTF-8 (no mid-codepoint cuts)
for i, ch := range got {
if !utf8.ValidString(ch) {
t.Errorf("chunk %d invalid UTF-8: %q", i, ch)
}
}
// Joined chunks should preserve content modulo break separators
// (newlines and word-boundary spaces are consumed on cut).
normalize := func(s string) string {
s = strings.ReplaceAll(s, "\n", "")
s = strings.ReplaceAll(s, " ", "")
return s
}
joined := strings.Join(got, "")
if normalize(joined) != normalize(c.in) {
t.Errorf("joined chunks lost data: %q vs %q", joined, c.in)
}
})
}
}
func TestSniffImageMime(t *testing.T) {
cases := []struct {
name string
in []byte
want string
}{
{"png", []byte{0x89, 'P', 'N', 'G', 0, 0, 0, 0}, "image/png"},
{"jpeg", []byte{0xFF, 0xD8, 0, 0}, "image/jpeg"},
{"gif", []byte("GIF89a"), "image/gif"},
{"webp", []byte("RIFF\x00\x00\x00\x00WEBP....."), "image/webp"},
{"unknown", []byte{0, 1, 2, 3, 4, 5, 6, 7}, "application/octet-stream"},
{"empty", []byte{}, "application/octet-stream"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := sniffImageMime(c.in); got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}
func TestIsAttachmentNotReady(t *testing.T) {
cases := map[string]bool{
`{"code":"attachment.not.ready","message":"retry"}`: true,
`{"error":"not.ready"}`: true,
`{"code":"rate.limit"}`: false,
`{"ok":true}`: false,
``: false,
}
for body, want := range cases {
if got := isAttachmentNotReady([]byte(body)); got != want {
t.Errorf("isAttachmentNotReady(%q) = %v, want %v", body, got, want)
}
}
}
func TestDefaultFilename(t *testing.T) {
cases := map[string]string{
"image": "image.png",
"video": "video.mp4",
"audio": "audio.mp3",
"file": "file.bin",
"xyz": "file.bin",
}
for in, want := range cases {
if got := defaultFilename(in); got != want {
t.Errorf("defaultFilename(%q) = %q, want %q", in, got, want)
}
}
}
func TestReconstructReplyCtx(t *testing.T) {
p := &Platform{}
cases := []struct {
key string
chatID string
wantErr bool
}{
{"max:12345", "12345", false},
{"max:12345:99", "12345", false},
{"telegram:12345", "", true},
{"max:", "", true},
{"", "", true},
}
for _, c := range cases {
t.Run(c.key, func(t *testing.T) {
got, err := p.ReconstructReplyCtx(c.key)
if (err != nil) != c.wantErr {
t.Fatalf("err=%v, wantErr=%v", err, c.wantErr)
}
if err != nil {
return
}
rc, ok := got.(replyContext)
if !ok {
t.Fatalf("wrong type %T", got)
}
if rc.chatID != c.chatID {
t.Errorf("chatID=%q, want %q", rc.chatID, c.chatID)
}
})
}
}
// --- Integration tests against a mock MAX API ---
type mockAPI struct {
server *httptest.Server
cdnServer *httptest.Server
messageCalls int32
uploadCalls int32
cdnCalls int32
editCalls int32
// capture last POST /messages body for inspection
mu sync.Mutex
lastMsgBody maxSendBody
lastMsgQuery string
lastEditBody maxSendBody
lastEditMID string
// attachmentReadyAfter: return attachment.not.ready this many times before 200
attachmentReadyAfter int32
}
func newMockAPI(t *testing.T) *mockAPI {
t.Helper()
m := &mockAPI{}
m.cdnServer = httptest.NewServer(http.HandlerFunc(m.handleCDN))
mux := http.NewServeMux()
mux.HandleFunc("/me", m.handleMe)
mux.HandleFunc("/updates", m.handleUpdates)
mux.HandleFunc("/messages", m.handleMessages)
mux.HandleFunc("/uploads", m.handleUploads)
mux.HandleFunc("/audios/", m.handleMediaResolve)
mux.HandleFunc("/videos/", m.handleMediaResolve)
mux.HandleFunc("/blob/", m.handleBlob)
m.server = httptest.NewServer(mux)
return m
}
// handleMediaResolve replies with a JSON pointing to our own /blob/<token>
// endpoint, letting tests simulate the MAX /audios/{token} → URL → download
// round-trip without depending on a real CDN.
func (m *mockAPI) handleMediaResolve(w http.ResponseWriter, r *http.Request) {
token := strings.TrimPrefix(r.URL.Path, "/audios/")
token = strings.TrimPrefix(token, "/videos/")
_ = json.NewEncoder(w).Encode(map[string]any{
"url": m.server.URL + "/blob/" + token,
"filename": "voice.ogg",
})
}
func (m *mockAPI) handleBlob(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "audio/ogg")
_, _ = w.Write([]byte("OggS\x00\x00fake-audio-bytes"))
}
func (m *mockAPI) close() {
m.server.Close()
m.cdnServer.Close()
}
func (m *mockAPI) handleMe(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"name": "test-bot", "user_id": 42})
}
func (m *mockAPI) handleUpdates(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done() // block until caller cancels
w.WriteHeader(http.StatusNoContent)
}
func (m *mockAPI) handleMessages(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
atomic.AddInt32(&m.messageCalls, 1)
var body maxSendBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// attachment.not.ready simulation
remaining := atomic.LoadInt32(&m.attachmentReadyAfter)
if remaining > 0 {
atomic.AddInt32(&m.attachmentReadyAfter, -1)
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":"attachment.not.ready"}`))
return
}
m.mu.Lock()
m.lastMsgBody = body
m.lastMsgQuery = r.URL.RawQuery
m.mu.Unlock()
_, _ = w.Write([]byte(`{"message_id":"test-mid"}`))
case http.MethodPut:
atomic.AddInt32(&m.editCalls, 1)
var body maxSendBody
_ = json.NewDecoder(r.Body).Decode(&body)
m.mu.Lock()
m.lastEditBody = body
m.lastEditMID = r.URL.Query().Get("message_id")
m.mu.Unlock()
_, _ = w.Write([]byte(`{"ok":true}`))
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (m *mockAPI) handleUploads(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&m.uploadCalls, 1)
kind := r.URL.Query().Get("type")
if kind == "" {
http.Error(w, "missing type", http.StatusBadRequest)
return
}
resp := map[string]any{"url": m.cdnServer.URL + "/upload?kind=" + kind}
// video/audio carry the real token in the /uploads response itself
if kind == "video" || kind == "audio" {
resp["token"] = "urltok-" + kind
}
_ = json.NewEncoder(w).Encode(resp)
}
// handleCDN mimics per-kind MAX CDN response shapes:
//
// image: {"photos": {"<id>": {"token": "..."}}}
// file: {"token": "..."}
// video/audio: XML "<retval>1</retval>" (token comes from /uploads instead)
func (m *mockAPI) handleCDN(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&m.cdnCalls, 1)
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
f, _, err := r.FormFile("data")
if err != nil {
http.Error(w, "missing data field: "+err.Error(), http.StatusBadRequest)
return
}
defer f.Close()
if _, err := io.Copy(io.Discard, f); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
kind := r.URL.Query().Get("kind")
switch kind {
case "image":
_ = json.NewEncoder(w).Encode(map[string]any{
"photos": map[string]any{
"photo-id-1": map[string]any{"token": "cdntok-image"},
},
})
case "video", "audio":
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte("<retval>1</retval>"))
default:
_ = json.NewEncoder(w).Encode(map[string]any{"token": "cdntok-" + kind})
}
}
func newTestPlatform(t *testing.T, apiBase string) *Platform {
t.Helper()
p, err := New(map[string]any{
"token": "test-token",
"api_base": apiBase,
})
if err != nil {
t.Fatalf("New: %v", err)
}
return p.(*Platform)
}
func TestSendText(t *testing.T) {
m := newMockAPI(t)
defer m.close()
p := newTestPlatform(t, m.server.URL)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := p.Send(ctx, replyContext{chatID: "111"}, "hello world"); err != nil {
t.Fatalf("Send: %v", err)
}
if atomic.LoadInt32(&m.messageCalls) != 1 {
t.Fatalf("want 1 message call, got %d", m.messageCalls)
}
m.mu.Lock()
body := m.lastMsgBody
query := m.lastMsgQuery
m.mu.Unlock()
if body.Text != "hello world" {
t.Errorf("text: got %q", body.Text)
}
if body.Format != "markdown" {
t.Errorf("format: got %q", body.Format)
}
if !strings.Contains(query, "chat_id=111") {
t.Errorf("chat_id missing from query: %q", query)
}
}
func TestSendTextSplitsLong(t *testing.T) {
m := newMockAPI(t)
defer m.close()
p := newTestPlatform(t, m.server.URL)
long := strings.Repeat("a", 8500)
ctx := context.Background()
if err := p.Send(ctx, replyContext{chatID: "111"}, long); err != nil {
t.Fatalf("Send: %v", err)
}
// Stay flexible re: the exact chunk-size constant: assert the message was
// split into more than one chunk and reassembling the chunks reproduces
// the input.
expected := int32(len(splitMessage(long, 1500)))
if got := atomic.LoadInt32(&m.messageCalls); got != expected {
t.Errorf("want %d chunk messages, got %d", expected, got)
}
}
func TestSendWithButtons(t *testing.T) {
m := newMockAPI(t)
defer m.close()
p := newTestPlatform(t, m.server.URL)
ctx := context.Background()
buttons := [][]core.ButtonOption{{{Text: "OK", Data: "ok"}, {Text: "Cancel", Data: "cancel"}}}
if err := p.SendWithButtons(ctx, replyContext{chatID: "111"}, "pick", buttons); err != nil {
t.Fatalf("SendWithButtons: %v", err)
}
m.mu.Lock()
body := m.lastMsgBody
m.mu.Unlock()
if len(body.Attachments) != 1 {
t.Fatalf("want 1 attachment, got %d", len(body.Attachments))
}
if body.Attachments[0].Type != "inline_keyboard" {
t.Errorf("attachment type: got %q", body.Attachments[0].Type)
}
}
func TestSendImage(t *testing.T) {
m := newMockAPI(t)
defer m.close()
p := newTestPlatform(t, m.server.URL)
ctx := context.Background()
img := core.ImageAttachment{MimeType: "image/png", Data: []byte{0x89, 'P', 'N', 'G', 0, 0, 0, 0}, FileName: "chart.png"}
if err := p.SendImage(ctx, replyContext{chatID: "111"}, img); err != nil {
t.Fatalf("SendImage: %v", err)
}
if got := atomic.LoadInt32(&m.uploadCalls); got != 1 {
t.Errorf("uploads: got %d", got)
}
if got := atomic.LoadInt32(&m.cdnCalls); got != 1 {
t.Errorf("cdn: got %d", got)
}
m.mu.Lock()
body := m.lastMsgBody
m.mu.Unlock()
if len(body.Attachments) != 1 || body.Attachments[0].Type != "image" {
t.Fatalf("want image attachment, got %+v", body.Attachments)
}
}
func TestSendFileRoutesImageByMime(t *testing.T) {
m := newMockAPI(t)
defer m.close()
p := newTestPlatform(t, m.server.URL)
ctx := context.Background()
file := core.FileAttachment{MimeType: "image/jpeg", Data: []byte{0xFF, 0xD8, 0, 0}, FileName: "photo.jpg"}
if err := p.SendFile(ctx, replyContext{chatID: "111"}, file); err != nil {
t.Fatalf("SendFile: %v", err)
}
m.mu.Lock()
body := m.lastMsgBody
m.mu.Unlock()
if len(body.Attachments) != 1 || body.Attachments[0].Type != "image" {
t.Fatalf("image/* mime should route to type=image, got %+v", body.Attachments)
}
}
func TestSendFileGeneric(t *testing.T) {
m := newMockAPI(t)
defer m.close()
p := newTestPlatform(t, m.server.URL)
ctx := context.Background()
file := core.FileAttachment{MimeType: "application/pdf", Data: []byte("%PDF-1.4"), FileName: "report.pdf"}
if err := p.SendFile(ctx, replyContext{chatID: "111"}, file); err != nil {
t.Fatalf("SendFile: %v", err)
}
m.mu.Lock()
body := m.lastMsgBody
m.mu.Unlock()
if len(body.Attachments) != 1 || body.Attachments[0].Type != "file" {
t.Fatalf("pdf should be type=file, got %+v", body.Attachments)
}
}
func TestAttachmentNotReadyRetry(t *testing.T) {
m := newMockAPI(t)
defer m.close()
// First two POST /messages return attachment.not.ready, third succeeds
atomic.StoreInt32(&m.attachmentReadyAfter, 2)
p := newTestPlatform(t, m.server.URL)
ctx := context.Background()
img := core.ImageAttachment{MimeType: "image/png", Data: []byte{0x89, 'P', 'N', 'G', 0, 0, 0, 0}}
if err := p.SendImage(ctx, replyContext{chatID: "111"}, img); err != nil {
t.Fatalf("SendImage: %v", err)
}
// 1 upload + 1 cdn + 3 message attempts
if got := atomic.LoadInt32(&m.messageCalls); got != 3 {
t.Errorf("message attempts: got %d, want 3", got)
}
}
func TestUpdateMessage(t *testing.T) {
m := newMockAPI(t)
defer m.close()
p := newTestPlatform(t, m.server.URL)
ctx := context.Background()
if err := p.UpdateMessage(ctx, replyContext{chatID: "111", messageID: "mid-42"}, "edited"); err != nil {
t.Fatalf("UpdateMessage: %v", err)
}
if got := atomic.LoadInt32(&m.editCalls); got != 1 {
t.Errorf("edit calls: got %d, want 1", got)
}
m.mu.Lock()
body := m.lastEditBody
mid := m.lastEditMID
m.mu.Unlock()
if body.Text != "edited" {
t.Errorf("edit text: got %q", body.Text)
}
if mid != "mid-42" {
t.Errorf("edit mid: got %q", mid)
}
}
func TestUpdateMessageWithoutMID(t *testing.T) {
m := newMockAPI(t)
defer m.close()
p := newTestPlatform(t, m.server.URL)
err := p.UpdateMessage(context.Background(), replyContext{chatID: "111"}, "noop")
if err == nil {
t.Fatal("expected error when messageID is empty")
}
if !strings.Contains(err.Error(), "message id") {
t.Errorf("error should mention missing message id: %v", err)
}
}
func TestNewRequiresToken(t *testing.T) {
_, err := New(map[string]any{})
if err == nil {
t.Fatal("expected error when token missing")
}
}
func TestPollLoopStopsOnCtxCancel(t *testing.T) {
m := newMockAPI(t)
defer m.close()
p := newTestPlatform(t, m.server.URL)
handlerCalled := false
err := p.Start(func(_ core.Platform, _ *core.Message) { handlerCalled = true })
if err != nil {
t.Fatalf("Start: %v", err)
}
// give the loop a moment to hit /updates
time.Sleep(100 * time.Millisecond)
if err := p.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
if handlerCalled {
t.Error("handler should not be called for empty /updates")
}
}
// sanity: make sure the /uploads handler sees the expected type query param
func TestUploadKindPropagation(t *testing.T) {
var seenKinds []string
var mu sync.Mutex
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
seenKinds = append(seenKinds, r.URL.Query().Get("type"))
mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{"url": r.URL.Scheme + "://" + r.Host + "/invalid"})
}))
defer server.Close()
p := newTestPlatform(t, server.URL)
// The CDN request will fail, but we only care about the /uploads kind param.
_, _ = p.uploadAttachment(context.Background(), "audio", []byte("x"), "a.mp3")
mu.Lock()
got := append([]string(nil), seenKinds...)
mu.Unlock()
if len(got) != 1 || got[0] != "audio" {
t.Errorf("uploads kinds: got %v", got)
}
}
func TestAudioFormatFromMime(t *testing.T) {
cases := []struct{ mime, filename, want string }{
{"audio/ogg", "", "ogg"},
{"audio/mpeg", "", "mp3"},
{"audio/mp4", "", "m4a"},
{"audio/x-m4a", "", "m4a"},
{"audio/wav", "", "wav"},
{"audio/webm", "voice.webm", "webm"},
{"", "clip.m4a", "m4a"},
{"", "weird_no_ext", "ogg"},
{"application/octet-stream", "", "octet-stream"},
}
for _, c := range cases {
if got := audioFormatFromMime(c.mime, c.filename); got != c.want {
t.Errorf("audioFormatFromMime(%q,%q) = %q, want %q", c.mime, c.filename, got, c.want)
}
}
}
func TestFetchAttachmentsRoutesAudio(t *testing.T) {
m := newMockAPI(t)
defer m.close()
p := newTestPlatform(t, m.server.URL)
atts := []maxAttachmentRaw{
{Type: "audio", Payload: maxAttachmentPayld{Token: "vm-abc"}},
}
images, files, audio := p.fetchAttachments(context.Background(), atts)
if len(images) != 0 || len(files) != 0 {
t.Errorf("audio must not leak into images/files (img=%d, f=%d)", len(images), len(files))
}
if audio == nil {
t.Fatal("audio attachment missing")
}
if audio.Format != "ogg" {
t.Errorf("format: got %q, want ogg", audio.Format)
}
if len(audio.Data) == 0 {
t.Error("audio data is empty")
}
}
func TestFetchAttachmentsFileWithAudioMimeRoutesToAudio(t *testing.T) {
// MAX delivers audio files attached via the paperclip menu as type="file"
// with audio/* mime. Ensure those also route to Audio so transcription kicks in.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "audio/mpeg")
_, _ = w.Write([]byte("fake-mp3-data"))
}))
defer server.Close()
p := newTestPlatform(t, "http://unused")
atts := []maxAttachmentRaw{
{Type: "file", Filename: "song.mp3", Payload: maxAttachmentPayld{URL: server.URL + "/song.mp3"}},
}
_, files, audio := p.fetchAttachments(context.Background(), atts)
if len(files) != 0 {
t.Errorf("audio/* mime should not be in files, got %+v", files)
}
if audio == nil {
t.Fatal("audio attachment missing")
}
if audio.Format != "mp3" {
t.Errorf("format: got %q, want mp3", audio.Format)
}
}
func TestHandleMessageDedupsByID(t *testing.T) {
p := newTestPlatform(t, "http://unused")
delivered := 0
_ = p.Start(func(_ core.Platform, _ *core.Message) { delivered++ })
defer func() { _ = p.Stop() }()
msg := &maxMessage{
Sender: maxUser{UserID: 1, Name: "u"},
Recipient: maxRecipient{ChatID: 42},
Timestamp: time.Now().UnixMilli(),
Body: maxBody{Mid: "mid-1", Text: "hi"},
}
ctx := context.Background()
p.handleMessage(ctx, msg)
p.handleMessage(ctx, msg) // duplicate mid → should be dropped
if delivered != 1 {
t.Errorf("handler fired %d times, want 1 (dedup failed)", delivered)
}
}
func TestSendAudio(t *testing.T) {
m := newMockAPI(t)
defer m.close()
p := newTestPlatform(t, m.server.URL)
ctx := context.Background()
if err := p.SendAudio(ctx, replyContext{chatID: "111"}, []byte("fake-audio"), "mp3"); err != nil {
t.Fatalf("SendAudio: %v", err)
}
if got := atomic.LoadInt32(&m.uploadCalls); got != 1 {
t.Errorf("uploads: got %d, want 1", got)
}
if got := atomic.LoadInt32(&m.cdnCalls); got != 1 {
t.Errorf("cdn: got %d, want 1", got)
}
m.mu.Lock()
body := m.lastMsgBody
m.mu.Unlock()
if len(body.Attachments) != 1 || body.Attachments[0].Type != "audio" {
t.Fatalf("want audio attachment, got %+v", body.Attachments)
}
}
func TestNormalizeLineBreaks(t *testing.T) {
cases := []struct {
name, in, want string
}{
{"empty", "", ""},
{"no newline", "single line", "single line"},
{"single break", "line1\nline2", "line1 \nline2"},
{"paragraph break preserved", "line1\n\nline2", "line1\n\nline2"},
{"triple newline preserved", "a\n\n\nb", "a\n\n\nb"},
{"already hard break", "line1 \nline2", "line1 \nline2"},
{"mixed", "p1\np1c\n\np2\np2c", "p1 \np1c\n\np2 \np2c"},
{"trailing newline", "x\n", "x\n"},
{"leading newline", "\nx", "\nx"},
{"code block untouched", "text\n```\ncode\nmore\n```\nafter", "text \n```\ncode\nmore\n```\nafter"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := normalizeLineBreaks(tc.in)
if got != tc.want {
t.Errorf("normalizeLineBreaks(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestForwardedMessageMergesAttachments(t *testing.T) {
// Forwarded message: link.type=forward, attachments inside link.message.
// handleMessage should pull those into the agent-visible payload.
msg := &maxMessage{
Sender: maxUser{UserID: 1, Name: "u"},
Recipient: maxRecipient{ChatID: 100},
Timestamp: time.Now().UnixMilli(),
Body: maxBody{
Mid: "fwd-1",
Text: "", // empty user text
},
Link: &maxLink{
Type: "forward",
Message: maxBody{
Text: "original message",
Attachments: []maxAttachmentRaw{
{Type: "file", Filename: "firmware.bin",
Payload: maxAttachmentPayld{URL: "https://example.com/fw.bin"}},
},
},
},
}
// Sanity: presence of link.message.attachments (the bug was treating empty body as no input).
if len(msg.Link.Message.Attachments) != 1 {
t.Fatalf("expected 1 forwarded attachment, got %d", len(msg.Link.Message.Attachments))
}
// Manually replicate the merge logic to assert behavior.
text := msg.Body.Text
atts := msg.Body.Attachments
if msg.Link != nil && msg.Link.Type == "forward" {
if text == "" {
text = msg.Link.Message.Text
}
atts = append(atts, msg.Link.Message.Attachments...)
}
if text != "original message" {
t.Errorf("text after forward merge: got %q, want %q", text, "original message")
}
if len(atts) != 1 || atts[0].Filename != "firmware.bin" {
t.Errorf("atts not merged: %+v", atts)
}
}
func TestReplyMessagePreservesUserPayload(t *testing.T) {
// Reply (link.type=reply) is just quote context — user's own text/atts
// must remain untouched.
msg := &maxMessage{
Body: maxBody{
Text: "user's question",
},
Link: &maxLink{
Type: "reply",
Message: maxBody{
Text: "earlier message being quoted",
Attachments: []maxAttachmentRaw{
{Type: "file", Filename: "should-not-merge.txt"},
},
},
},
}
text := msg.Body.Text
atts := msg.Body.Attachments
if msg.Link != nil && msg.Link.Type == "forward" {
if text == "" {
text = msg.Link.Message.Text
}
atts = append(atts, msg.Link.Message.Attachments...)
}
if text != "user's question" {
t.Errorf("reply should not change text: got %q", text)
}
if len(atts) != 0 {
t.Errorf("reply should not merge attachments, got %d", len(atts))
}
}
func TestNewWebhookPathDefaults(t *testing.T) {
cases := []struct {
in, want string
}{
{"", "/webhook"},
{"/webhook", "/webhook"},
{"webhook", "/webhook"},
{"/bot1", "/bot1"},
{"bot1/inbox", "/bot1/inbox"},
}
for _, c := range cases {
p, err := New(map[string]any{"token": "t", "webhook_path": c.in})
if err != nil {
t.Fatalf("New(%q): %v", c.in, err)
}
if got := p.(*Platform).webhookPath; got != c.want {
t.Errorf("webhook_path=%q: got %q, want %q", c.in, got, c.want)
}
}
}
func TestWebhookHandlerNoSecret(t *testing.T) {
p, _ := New(map[string]any{"token": "t"})
pl := p.(*Platform)
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(`{"update_type":"unknown"}`))
rec := httptest.NewRecorder()
pl.webhookHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
}
func TestWebhookHandlerSecretViaHeader(t *testing.T) {
p, _ := New(map[string]any{"token": "t", "webhook_secret": "s3cret"})
pl := p.(*Platform)
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(`{"update_type":"unknown"}`))
req.Header.Set("X-Max-Bot-Api-Secret", "s3cret")
rec := httptest.NewRecorder()
pl.webhookHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
}
func TestWebhookHandlerSecretViaQuery(t *testing.T) {
p, _ := New(map[string]any{"token": "t", "webhook_secret": "s3cret"})
pl := p.(*Platform)
req := httptest.NewRequest(http.MethodPost, "/webhook?s=s3cret", strings.NewReader(`{"update_type":"unknown"}`))
rec := httptest.NewRecorder()
pl.webhookHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
}
func TestWebhookHandlerSecretMismatch(t *testing.T) {
p, _ := New(map[string]any{"token": "t", "webhook_secret": "s3cret"})
pl := p.(*Platform)
req := httptest.NewRequest(http.MethodPost, "/webhook?s=wrong", strings.NewReader(`{}`))
rec := httptest.NewRecorder()
pl.webhookHandler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("got %d, want 401", rec.Code)
}
}
func TestWebhookHandlerSecretMissing(t *testing.T) {
p, _ := New(map[string]any{"token": "t", "webhook_secret": "s3cret"})
pl := p.(*Platform)
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(`{}`))
rec := httptest.NewRecorder()
pl.webhookHandler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("got %d, want 401", rec.Code)
}
}
// TestWebhookCtx_FallsBackToBackgroundBeforeStart pins the contract that
// webhookHandler can be exercised in unit tests before Start has run.
func TestWebhookCtx_FallsBackToBackgroundBeforeStart(t *testing.T) {
p, _ := New(map[string]any{"token": "t"})
pl := p.(*Platform)
ctx := pl.webhookCtx()
if ctx == nil {
t.Fatal("webhookCtx returned nil")
}
if _, ok := ctx.Deadline(); ok {
t.Fatal("expected no deadline on Background fallback")
}
if err := ctx.Err(); err != nil {
t.Fatalf("ctx.Err = %v, want nil", err)
}
}
// TestWebhookCtx_CanceledByStop pins the bug where the async webhook handler
// goroutine derived a brand-new WithCancel from context.Background() and
// discarded the cancel func, leaving in-flight handlers running after Stop().
// With the fix, webhookCtx returns the same context that Stop()/p.cancel
// cancels, so handlers short-circuit on shutdown.
func TestWebhookCtx_CanceledByStop(t *testing.T) {
m := newMockAPI(t)
defer m.close()
p := newTestPlatform(t, m.server.URL)
if err := p.Start(func(_ core.Platform, _ *core.Message) {}); err != nil {
t.Fatalf("Start: %v", err)
}
ctx := p.webhookCtx()
if ctx == nil {
t.Fatal("webhookCtx returned nil after Start")
}
select {
case <-ctx.Done():
t.Fatal("webhookCtx already canceled before Stop")
default:
}
if err := p.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
select {
case <-ctx.Done():
// expected: Stop() must propagate to in-flight webhook handlers.
case <-time.After(2 * time.Second):
t.Fatal("webhookCtx was not canceled after Stop")
}
}
func TestWebhookHandlerWrongMethod(t *testing.T) {
p, _ := New(map[string]any{"token": "t"})
pl := p.(*Platform)
req := httptest.NewRequest(http.MethodGet, "/webhook", nil)
rec := httptest.NewRecorder()
pl.webhookHandler(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("got %d, want 405", rec.Code)
}
}