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/ // 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": {"": {"token": "..."}}} // file: {"token": "..."} // video/audio: XML "1" (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("1")) 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) } }