912 lines
27 KiB
Go
912 lines
27 KiB
Go
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)
|
||
}
|
||
}
|