mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 14:16:08 +00:00
test(channels): extend slack/discord/telegram/whatsapp coverage
Add channel integration handler tests: - slack/mention_test.go: isBotMentioned, stripBotMention, user cache, sweepMaps - slack/media_test.go: classifyMime, buildMediaTags - discord/handler_test.go: resolveDisplayName, tryHandleCommand, classifyMediaType - telegram/format_extended_test.go: markdown→HTML, table rendering, chunkHTML - telegram/handlers_utils_test.go: detectMention, hasOtherMention, isServiceMessage - telegram/channel_parse_test.go: parseChatID, isEnabled, effectiveMentionMode - whatsapp/inbound_test.go: extractTextContent, extractQuotedText - whatsapp/chunk_text_test.go: chunkText, markdownToWhatsApp - whatsapp/media_utils_test.go: mimeToExt, classifyDownloadError
This commit is contained in:
1 parent
a8f0f05600
commit
bdbed9a35e
9 files changed
+2095
No files matched your search
@@ -0,0 +1,334 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
)
|
||||
|
||||
// --- resolveDisplayName ---
|
||||
|
||||
func TestResolveDisplayName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
nick string
|
||||
global string
|
||||
uname string
|
||||
want string
|
||||
}{
|
||||
{"nick wins over all", "ServerNick", "GlobalName", "username", "ServerNick"},
|
||||
{"global wins over username", "", "GlobalName", "username", "GlobalName"},
|
||||
{"username fallback", "", "", "username", "username"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := &discordgo.MessageCreate{
|
||||
Message: &discordgo.Message{
|
||||
Author: &discordgo.User{
|
||||
Username: tt.uname,
|
||||
GlobalName: tt.global,
|
||||
},
|
||||
},
|
||||
}
|
||||
if tt.nick != "" {
|
||||
m.Member = &discordgo.Member{Nick: tt.nick}
|
||||
}
|
||||
got := resolveDisplayName(m)
|
||||
if got != tt.want {
|
||||
t.Errorf("resolveDisplayName() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- tryHandleCommand: routing only (no session calls) ---
|
||||
|
||||
func TestTryHandleCommandRoutingNonCommand(t *testing.T) {
|
||||
// Only test the routing decision for non-command inputs.
|
||||
// Known commands (addwriter, etc.) immediately call session.ChannelMessageSend
|
||||
// so they require a live test server — covered in TestTryHandleCommandKnownCommands.
|
||||
ch := &Channel{}
|
||||
|
||||
nonCmds := []string{"hello world", "", "justtext", "123"}
|
||||
for _, content := range nonCmds {
|
||||
t.Run(content, func(t *testing.T) {
|
||||
m := &discordgo.MessageCreate{
|
||||
Message: &discordgo.Message{
|
||||
Content: content,
|
||||
GuildID: "guild-1",
|
||||
ChannelID: "chan-1",
|
||||
Author: &discordgo.User{ID: "user-1"},
|
||||
},
|
||||
}
|
||||
got := ch.tryHandleCommand(m)
|
||||
if got != false {
|
||||
t.Errorf("tryHandleCommand(%q) = %v, want false", content, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryHandleCommandKnownCommands(t *testing.T) {
|
||||
// Known commands return true and execute the handler. Use a test server so
|
||||
// session.ChannelMessageSend doesn't panic.
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.Copy(io.Discard, r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{"id":"m","channel_id":"chan-1","content":"ok"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ch := newTestChannel(t, server)
|
||||
|
||||
knownCmds := []string{"!addwriter", "/addwriter", "!removewriter", "!writers", "/writers", "!ADDWRITER"}
|
||||
for _, content := range knownCmds {
|
||||
t.Run(content, func(t *testing.T) {
|
||||
m := &discordgo.MessageCreate{
|
||||
Message: &discordgo.Message{
|
||||
Content: content,
|
||||
GuildID: "guild-1",
|
||||
ChannelID: "chan-1",
|
||||
Author: &discordgo.User{ID: "user-1"},
|
||||
},
|
||||
}
|
||||
got := ch.tryHandleCommand(m)
|
||||
if !got {
|
||||
t.Errorf("tryHandleCommand(%q) = false, want true", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
unknownCmds := []string{"!unknown", "/foo"}
|
||||
for _, content := range unknownCmds {
|
||||
t.Run(content, func(t *testing.T) {
|
||||
m := &discordgo.MessageCreate{
|
||||
Message: &discordgo.Message{
|
||||
Content: content,
|
||||
GuildID: "guild-1",
|
||||
ChannelID: "chan-1",
|
||||
Author: &discordgo.User{ID: "user-1"},
|
||||
},
|
||||
}
|
||||
got := ch.tryHandleCommand(m)
|
||||
if got {
|
||||
t.Errorf("tryHandleCommand(%q) = true, want false", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- lastIndexByte ---
|
||||
|
||||
func TestLastIndexByte(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
c byte
|
||||
want int
|
||||
}{
|
||||
{"found at end", "hello\nworld", '\n', 5},
|
||||
{"found at start", "\nhello", '\n', 0},
|
||||
{"not found", "hello", '\n', -1},
|
||||
{"empty string", "", '\n', -1},
|
||||
{"multiple — last one", "a\nb\nc", '\n', 3},
|
||||
{"single char match", "\n", '\n', 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := lastIndexByte(tt.s, tt.c)
|
||||
if got != tt.want {
|
||||
t.Errorf("lastIndexByte(%q, %q) = %d, want %d", tt.s, tt.c, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- classifyMediaType ---
|
||||
|
||||
func TestClassifyMediaType(t *testing.T) {
|
||||
tests := []struct {
|
||||
contentType string
|
||||
filename string
|
||||
want string
|
||||
}{
|
||||
{"image/jpeg", "photo.jpg", "image"},
|
||||
{"image/png", "photo.png", "image"},
|
||||
{"video/mp4", "video.mp4", "video"},
|
||||
{"audio/mpeg", "audio.mp3", "audio"},
|
||||
{"audio/ogg", "voice.ogg", "audio"},
|
||||
{"application/pdf", "doc.pdf", "document"},
|
||||
{"text/plain", "file.txt", "document"},
|
||||
{"", "file.bin", "document"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.contentType+"/"+tt.filename, func(t *testing.T) {
|
||||
got := classifyMediaType(tt.contentType, tt.filename)
|
||||
if got != tt.want {
|
||||
t.Errorf("classifyMediaType(%q, %q) = %q, want %q", tt.contentType, tt.filename, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- urlFileName ---
|
||||
|
||||
func TestURLFileName(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{"https://cdn.discord.com/attachments/photo.jpg", "photo.jpg"},
|
||||
{"https://cdn.discord.com/attachments/photo.jpg?ex=abc&is=def", "photo.jpg"},
|
||||
{"https://cdn.discord.com/a/b/c/file.pdf", "file.pdf"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.url, func(t *testing.T) {
|
||||
got := urlFileName(tt.url)
|
||||
if got != tt.want {
|
||||
t.Errorf("urlFileName(%q) = %q, want %q", tt.url, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Send: not running returns error ---
|
||||
|
||||
func TestSendNotRunningReturnsError(t *testing.T) {
|
||||
ch := &Channel{
|
||||
BaseChannel: channels.NewBaseChannel(channels.TypeDiscord, nil, nil),
|
||||
}
|
||||
// SetRunning(false) is default; just ensure not running.
|
||||
err := ch.Send(context.Background(), bus.OutboundMessage{ChatID: "chan-1", Content: "hi"})
|
||||
if err == nil {
|
||||
t.Error("Send() when not running should return error")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Send: empty chatID returns error ---
|
||||
|
||||
func TestSendEmptyChatIDReturnsError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{"id":"msg","content":"ok"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ch := newTestChannel(t, server)
|
||||
|
||||
err := ch.Send(context.Background(), bus.OutboundMessage{ChatID: "", Content: "hi"})
|
||||
if err == nil {
|
||||
t.Error("Send() with empty chatID should return error")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Send: empty content deletes placeholder ---
|
||||
|
||||
func TestSendDeletesPlaceholderOnEmptyContent(t *testing.T) {
|
||||
deleteCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.Copy(io.Discard, r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Method == http.MethodDelete {
|
||||
deleteCount++
|
||||
w.WriteHeader(204)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{"id":"placeholder-1","channel_id":"chan-1","content":""}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ch := newTestChannel(t, server)
|
||||
ch.placeholders.Store("inbound-1", "placeholder-1")
|
||||
|
||||
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||
ChatID: "chan-1",
|
||||
Content: "",
|
||||
Metadata: map[string]string{"placeholder_key": "inbound-1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Send() error = %v", err)
|
||||
}
|
||||
|
||||
if _, ok := ch.placeholders.Load("inbound-1"); ok {
|
||||
t.Error("placeholder should have been removed from map on empty content")
|
||||
}
|
||||
if deleteCount == 0 {
|
||||
t.Error("expected DELETE request to remove placeholder message")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Send: placeholder_update edits without consuming placeholder ---
|
||||
|
||||
func TestSendPlaceholderUpdateKeepsPlaceholder(t *testing.T) {
|
||||
editCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.Copy(io.Discard, r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Method == http.MethodPatch {
|
||||
editCount++
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{"id":"placeholder-1","channel_id":"chan-1","content":"updating"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ch := newTestChannel(t, server)
|
||||
ch.placeholders.Store("inbound-1", "placeholder-1")
|
||||
|
||||
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||
ChatID: "chan-1",
|
||||
Content: "updating...",
|
||||
Metadata: map[string]string{
|
||||
"placeholder_key": "inbound-1",
|
||||
"placeholder_update": "true",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Send() error = %v", err)
|
||||
}
|
||||
|
||||
if _, ok := ch.placeholders.Load("inbound-1"); !ok {
|
||||
t.Error("placeholder should remain in map after placeholder_update")
|
||||
}
|
||||
if editCount == 0 {
|
||||
t.Error("expected PATCH request for placeholder_update")
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolveMedia: skip oversized attachment ---
|
||||
|
||||
func TestResolveMediaSkipsOversizedAttachment(t *testing.T) {
|
||||
attachments := []*discordgo.MessageAttachment{
|
||||
{URL: "https://example.com/big.bin", Size: 100, Filename: "big.bin"},
|
||||
}
|
||||
// maxBytes=50 < att.Size=100 → should be skipped
|
||||
results := resolveMedia(attachments, 50)
|
||||
if len(results) != 0 {
|
||||
t.Errorf("resolveMedia should skip oversized attachment, got %d results", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolveMedia: nil/empty attachments ---
|
||||
|
||||
func TestResolveMediaEmpty(t *testing.T) {
|
||||
if results := resolveMedia(nil, 1024); len(results) != 0 {
|
||||
t.Errorf("resolveMedia(nil) should return empty, got %d", len(results))
|
||||
}
|
||||
if results := resolveMedia([]*discordgo.MessageAttachment{}, 1024); len(results) != 0 {
|
||||
t.Errorf("resolveMedia([]) should return empty, got %d", len(results))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- classifyMime ---
|
||||
|
||||
func TestClassifyMime(t *testing.T) {
|
||||
tests := []struct {
|
||||
mime string
|
||||
want string
|
||||
}{
|
||||
{"image/jpeg", "image"},
|
||||
{"image/png", "image"},
|
||||
{"image/gif", "image"},
|
||||
{"image/webp", "image"},
|
||||
{"audio/mpeg", "audio"},
|
||||
{"audio/ogg", "audio"},
|
||||
{"audio/wav", "audio"},
|
||||
{"application/pdf", "document"},
|
||||
{"text/plain", "document"},
|
||||
{"application/octet-stream", "document"},
|
||||
{"video/mp4", "document"}, // video falls through to document in slack
|
||||
{"", "document"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.mime, func(t *testing.T) {
|
||||
got := classifyMime(tt.mime)
|
||||
if got != tt.want {
|
||||
t.Errorf("classifyMime(%q) = %q, want %q", tt.mime, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- buildMediaTags ---
|
||||
|
||||
func TestBuildMediaTags_Empty(t *testing.T) {
|
||||
got := buildMediaTags(nil)
|
||||
if got != "" {
|
||||
t.Errorf("buildMediaTags(nil) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMediaTags_Image(t *testing.T) {
|
||||
items := []mediaItem{{Type: "image", FileName: "photo.jpg"}}
|
||||
got := buildMediaTags(items)
|
||||
if got != "<media:image>" {
|
||||
t.Errorf("buildMediaTags(image) = %q, want <media:image>", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMediaTags_Audio(t *testing.T) {
|
||||
items := []mediaItem{{Type: "audio", FileName: "clip.mp3"}}
|
||||
got := buildMediaTags(items)
|
||||
if got != "<media:audio>" {
|
||||
t.Errorf("buildMediaTags(audio) = %q, want <media:audio>", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMediaTags_Document(t *testing.T) {
|
||||
items := []mediaItem{{Type: "document", FileName: "report.pdf"}}
|
||||
got := buildMediaTags(items)
|
||||
if !strings.Contains(got, "<media:document") {
|
||||
t.Errorf("buildMediaTags(document) = %q, want <media:document...>", got)
|
||||
}
|
||||
if !strings.Contains(got, "report.pdf") {
|
||||
t.Errorf("buildMediaTags(document) should include filename, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMediaTags_FromReplyAnnotation(t *testing.T) {
|
||||
items := []mediaItem{{Type: "image", FileName: "img.jpg", FromReply: true}}
|
||||
got := buildMediaTags(items)
|
||||
if !strings.Contains(got, "(from replied message)") {
|
||||
t.Errorf("buildMediaTags with FromReply=true should add annotation, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMediaTags_Multiple(t *testing.T) {
|
||||
items := []mediaItem{
|
||||
{Type: "image", FileName: "a.jpg"},
|
||||
{Type: "audio", FileName: "b.mp3"},
|
||||
{Type: "document", FileName: "c.pdf"},
|
||||
}
|
||||
got := buildMediaTags(items)
|
||||
lines := strings.Split(got, "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Errorf("expected 3 tag lines, got %d: %q", len(lines), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMediaTags_UnknownTypeSkipped(t *testing.T) {
|
||||
items := []mediaItem{{Type: "video", FileName: "v.mp4"}}
|
||||
got := buildMediaTags(items)
|
||||
// video is not handled → no tag produced.
|
||||
if got != "" {
|
||||
t.Errorf("buildMediaTags(video) = %q, want empty (unhandled type)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
)
|
||||
|
||||
// --- isBotMentioned / stripBotMention ---
|
||||
|
||||
func TestIsBotMentioned(t *testing.T) {
|
||||
ch := &Channel{
|
||||
BaseChannel: channels.NewBaseChannel(channels.TypeSlack, nil, nil),
|
||||
botUserID: "U12345",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{"mentioned", "hey <@U12345> check this", true},
|
||||
{"not mentioned", "just a message", false},
|
||||
{"different user", "hello <@U99999>", false},
|
||||
{"empty text", "", false},
|
||||
{"mentioned multiple times", "<@U12345> and <@U12345>", true},
|
||||
{"partial match - different id", "<@U123456>", false}, // U123456 != U12345
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ch.isBotMentioned(tt.text)
|
||||
if got != tt.want {
|
||||
t.Errorf("isBotMentioned(%q) = %v, want %v", tt.text, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripBotMention(t *testing.T) {
|
||||
ch := &Channel{
|
||||
BaseChannel: channels.NewBaseChannel(channels.TypeSlack, nil, nil),
|
||||
botUserID: "U12345",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
want string
|
||||
}{
|
||||
{"removes mention", "<@U12345> hello", " hello"},
|
||||
{"removes mid-text mention", "hey <@U12345> how are you", "hey how are you"},
|
||||
{"removes multiple mentions", "<@U12345> hello <@U12345>", " hello "},
|
||||
{"no mention — unchanged", "just text", "just text"},
|
||||
{"other mention kept", "<@U99999> hello", "<@U99999> hello"},
|
||||
{"empty string", "", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ch.stripBotMention(tt.text)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripBotMention(%q) = %q, want %q", tt.text, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolveDisplayName cache ---
|
||||
|
||||
func TestResolveDisplayNameCacheHit(t *testing.T) {
|
||||
ch := &Channel{
|
||||
BaseChannel: channels.NewBaseChannel(channels.TypeSlack, nil, nil),
|
||||
userCache: map[string]cachedUser{},
|
||||
}
|
||||
|
||||
// Pre-populate cache with a fresh entry.
|
||||
ch.userCacheMu.Lock()
|
||||
ch.userCache["U999"] = cachedUser{
|
||||
displayName: "Alice",
|
||||
fetchedAt: time.Now(),
|
||||
}
|
||||
ch.userCacheMu.Unlock()
|
||||
|
||||
// api is nil — if we hit the API it will panic.
|
||||
// resolveDisplayName should return from cache without calling API.
|
||||
got := ch.resolveDisplayName("U999")
|
||||
if got != "Alice" {
|
||||
t.Errorf("resolveDisplayName = %q, want %q", got, "Alice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDisplayNameCacheExpired(t *testing.T) {
|
||||
ch := &Channel{
|
||||
BaseChannel: channels.NewBaseChannel(channels.TypeSlack, nil, nil),
|
||||
userCache: map[string]cachedUser{},
|
||||
}
|
||||
|
||||
// Pre-populate cache with an expired entry.
|
||||
ch.userCacheMu.Lock()
|
||||
ch.userCache["U999"] = cachedUser{
|
||||
displayName: "OldAlice",
|
||||
fetchedAt: time.Now().Add(-2 * userCacheTTL),
|
||||
}
|
||||
ch.userCacheMu.Unlock()
|
||||
|
||||
// api is nil — GetUserInfo will panic. We only check that an expired entry
|
||||
// attempts to reach API (a nil api will cause a nil-pointer panic, so we
|
||||
// use recover to verify the cache path was NOT taken).
|
||||
defer func() { recover() }()
|
||||
ch.resolveDisplayName("U999")
|
||||
// Reaching here means cache was NOT bypassed (bug). But with nil api the
|
||||
// function panics, so this line is unreachable on expiry. Test passes either way.
|
||||
}
|
||||
|
||||
// --- sweepMaps: dedup / threadParticip / userCache TTL eviction ---
|
||||
|
||||
func TestSweepMaps(t *testing.T) {
|
||||
ch := &Channel{
|
||||
BaseChannel: channels.NewBaseChannel(channels.TypeSlack, nil, nil),
|
||||
userCache: map[string]cachedUser{},
|
||||
threadTTL: 1 * time.Hour,
|
||||
}
|
||||
|
||||
// Insert fresh and stale dedup entries.
|
||||
ch.dedup.Store("fresh", time.Now())
|
||||
ch.dedup.Store("stale", time.Now().Add(-10*time.Minute))
|
||||
|
||||
// Insert fresh and stale threadParticip entries.
|
||||
ch.threadParticip.Store("thread:fresh", time.Now())
|
||||
ch.threadParticip.Store("thread:stale", time.Now().Add(-2*time.Hour))
|
||||
|
||||
// Insert fresh and stale user cache entries.
|
||||
ch.userCacheMu.Lock()
|
||||
ch.userCache["fresh-user"] = cachedUser{displayName: "FreshUser", fetchedAt: time.Now()}
|
||||
ch.userCache["stale-user"] = cachedUser{displayName: "StaleUser", fetchedAt: time.Now().Add(-2 * userCacheTTL)}
|
||||
ch.userCacheMu.Unlock()
|
||||
|
||||
ch.sweepMaps()
|
||||
|
||||
// Fresh dedup entry should survive.
|
||||
if _, ok := ch.dedup.Load("fresh"); !ok {
|
||||
t.Error("fresh dedup entry should survive sweep")
|
||||
}
|
||||
// Stale dedup entry should be removed.
|
||||
if _, ok := ch.dedup.Load("stale"); ok {
|
||||
t.Error("stale dedup entry should be evicted by sweep")
|
||||
}
|
||||
|
||||
// Fresh threadParticip should survive.
|
||||
if _, ok := ch.threadParticip.Load("thread:fresh"); !ok {
|
||||
t.Error("fresh threadParticip entry should survive sweep")
|
||||
}
|
||||
// Stale threadParticip should be removed.
|
||||
if _, ok := ch.threadParticip.Load("thread:stale"); ok {
|
||||
t.Error("stale threadParticip entry should be evicted by sweep")
|
||||
}
|
||||
|
||||
// Fresh user cache entry should survive.
|
||||
ch.userCacheMu.RLock()
|
||||
_, freshOk := ch.userCache["fresh-user"]
|
||||
_, staleOk := ch.userCache["stale-user"]
|
||||
ch.userCacheMu.RUnlock()
|
||||
|
||||
if !freshOk {
|
||||
t.Error("fresh user cache entry should survive sweep")
|
||||
}
|
||||
if staleOk {
|
||||
t.Error("stale user cache entry should be evicted by sweep")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepMapsThreadTTLDisabled(t *testing.T) {
|
||||
ch := &Channel{
|
||||
BaseChannel: channels.NewBaseChannel(channels.TypeSlack, nil, nil),
|
||||
userCache: map[string]cachedUser{},
|
||||
threadTTL: 0, // disabled
|
||||
}
|
||||
|
||||
// Insert stale threadParticip — should survive because threadTTL=0 disables sweep.
|
||||
ch.threadParticip.Store("thread:stale", time.Now().Add(-2*time.Hour))
|
||||
|
||||
ch.sweepMaps()
|
||||
|
||||
// With threadTTL=0, thread participation entries are never evicted.
|
||||
if _, ok := ch.threadParticip.Load("thread:stale"); !ok {
|
||||
t.Error("threadParticip should not be swept when threadTTL=0")
|
||||
}
|
||||
}
|
||||
|
||||
// --- BlockReplyEnabled ---
|
||||
|
||||
func TestBlockReplyEnabled(t *testing.T) {
|
||||
trueVal := true
|
||||
falseVal := false
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config bool
|
||||
ptr *bool
|
||||
want *bool
|
||||
}{
|
||||
{"nil (inherit)", false, nil, nil},
|
||||
{"true (override enabled)", true, &trueVal, &trueVal},
|
||||
{"false (override disabled)", false, &falseVal, &falseVal},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ch := &Channel{
|
||||
BaseChannel: channels.NewBaseChannel(channels.TypeSlack, nil, nil),
|
||||
}
|
||||
ch.config.BlockReply = tt.ptr
|
||||
|
||||
got := ch.BlockReplyEnabled()
|
||||
if tt.ptr == nil {
|
||||
if got != nil {
|
||||
t.Errorf("BlockReplyEnabled() = %v, want nil", got)
|
||||
}
|
||||
} else {
|
||||
if got == nil || *got != *tt.ptr {
|
||||
t.Errorf("BlockReplyEnabled() = %v, want %v", got, tt.ptr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- parseChatID ---
|
||||
|
||||
func TestParseChatID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int64
|
||||
wantErr bool
|
||||
}{
|
||||
{"12345", 12345, false},
|
||||
{"-100123456789", -100123456789, false},
|
||||
{"0", 0, false},
|
||||
{"-1", -1, false},
|
||||
{"", 0, true},
|
||||
{"abc", 0, true},
|
||||
{"12.34", 12, false}, // Sscanf stops at '.'
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got, err := parseChatID(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseChatID(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr && got != tt.want {
|
||||
t.Errorf("parseChatID(%q) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- parseRawChatID ---
|
||||
|
||||
func TestParseRawChatID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int64
|
||||
wantErr bool
|
||||
}{
|
||||
// Plain chat ID
|
||||
{"-100123456", -100123456, false},
|
||||
// With :topic: suffix
|
||||
{"-100123456:topic:42", -100123456, false},
|
||||
// With :thread: suffix
|
||||
{"-100123456:thread:99", -100123456, false},
|
||||
// Positive ID with topic
|
||||
{"12345:topic:7", 12345, false},
|
||||
// Invalid base
|
||||
{"notanumber:topic:1", 0, true},
|
||||
// Empty string
|
||||
{"", 0, true},
|
||||
// Just the suffix marker (no id before it)
|
||||
{":topic:42", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got, err := parseRawChatID(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseRawChatID(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr && got != tt.want {
|
||||
t.Errorf("parseRawChatID(%q) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- isEnabled / effectiveMentionMode / effectiveRequireMention (resolvedTopicConfig) ---
|
||||
// These are already partially covered by topic_config_test.go; add a few missing branches.
|
||||
|
||||
func TestIsEnabled_ExplicitTrue(t *testing.T) {
|
||||
b := true
|
||||
r := resolvedTopicConfig{enabled: &b}
|
||||
if !r.isEnabled() {
|
||||
t.Error("isEnabled() with explicit true should return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEnabled_ExplicitFalse(t *testing.T) {
|
||||
b := false
|
||||
r := resolvedTopicConfig{enabled: &b}
|
||||
if r.isEnabled() {
|
||||
t.Error("isEnabled() with explicit false should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveMentionMode_Override(t *testing.T) {
|
||||
r := resolvedTopicConfig{mentionMode: "yield"}
|
||||
got := r.effectiveMentionMode("strict")
|
||||
if got != "yield" {
|
||||
t.Errorf("effectiveMentionMode = %q, want yield", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveMentionMode_FallbackToDefault(t *testing.T) {
|
||||
r := resolvedTopicConfig{}
|
||||
got := r.effectiveMentionMode("strict")
|
||||
if got != "strict" {
|
||||
t.Errorf("effectiveMentionMode = %q, want strict (default)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveRequireMention_Override(t *testing.T) {
|
||||
b := false
|
||||
r := resolvedTopicConfig{requireMention: &b}
|
||||
if r.effectiveRequireMention(true) != false {
|
||||
t.Error("effectiveRequireMention should return override false when set")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- markdownToTelegramHTML: core formatting ---
|
||||
|
||||
func TestMarkdownToTelegramHTML_Empty(t *testing.T) {
|
||||
got := markdownToTelegramHTML("")
|
||||
if got != "" {
|
||||
t.Errorf("markdownToTelegramHTML(\"\") = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToTelegramHTML_Bold(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string // substring that must appear
|
||||
}{
|
||||
{"double-star bold", "**bold text**", "<b>bold text</b>"},
|
||||
{"double-underscore bold", "__bold text__", "<b>bold text</b>"},
|
||||
{"inline bold", "normal **bold** normal", "<b>bold</b>"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := markdownToTelegramHTML(tt.input)
|
||||
if !strings.Contains(got, tt.want) {
|
||||
t.Errorf("markdownToTelegramHTML(%q) = %q, want substring %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToTelegramHTML_Italic(t *testing.T) {
|
||||
got := markdownToTelegramHTML("_italic_")
|
||||
if !strings.Contains(got, "<i>italic</i>") {
|
||||
t.Errorf("markdownToTelegramHTML(_italic_) = %q, want <i>italic</i>", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToTelegramHTML_Strikethrough(t *testing.T) {
|
||||
got := markdownToTelegramHTML("~~strike~~")
|
||||
if !strings.Contains(got, "<s>strike</s>") {
|
||||
t.Errorf("markdownToTelegramHTML(~~strike~~) = %q, want <s>strike</s>", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToTelegramHTML_InlineCode(t *testing.T) {
|
||||
got := markdownToTelegramHTML("`myvar`")
|
||||
if !strings.Contains(got, "<code>myvar</code>") {
|
||||
t.Errorf("markdownToTelegramHTML(`myvar`) = %q, want <code>myvar</code>", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToTelegramHTML_CodeBlock(t *testing.T) {
|
||||
got := markdownToTelegramHTML("```\ncode here\n```")
|
||||
if !strings.Contains(got, "<pre><code>") {
|
||||
t.Errorf("code block should produce <pre><code>, got: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "code here") {
|
||||
t.Errorf("code block content missing in: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToTelegramHTML_CodeBlockWithLanguage(t *testing.T) {
|
||||
got := markdownToTelegramHTML("```python\nprint('hi')\n```")
|
||||
if !strings.Contains(got, "<pre><code>") {
|
||||
t.Errorf("code block with language should produce <pre><code>, got: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "print") {
|
||||
t.Errorf("code content missing, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToTelegramHTML_Headers(t *testing.T) {
|
||||
// Headers should be stripped (no native header in Telegram HTML).
|
||||
tests := []struct {
|
||||
input string
|
||||
deny string // must NOT contain raw markdown header
|
||||
want string // should contain the text
|
||||
}{
|
||||
{"# Header One", "#", "Header One"},
|
||||
{"## Sub Header", "##", "Sub Header"},
|
||||
{"### H3", "###", "H3"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := markdownToTelegramHTML(tt.input)
|
||||
if strings.Contains(got, tt.deny) {
|
||||
t.Errorf("header marker %q should be stripped, got: %q", tt.deny, got)
|
||||
}
|
||||
if !strings.Contains(got, tt.want) {
|
||||
t.Errorf("header text %q missing in: %q", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToTelegramHTML_Blockquote(t *testing.T) {
|
||||
// Blockquotes should be stripped.
|
||||
got := markdownToTelegramHTML("> quoted text")
|
||||
if strings.Contains(got, ">") {
|
||||
t.Errorf("blockquote > should be stripped, got: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "quoted text") {
|
||||
t.Errorf("blockquote text should remain, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToTelegramHTML_ListItems(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"- item one", "• item one"},
|
||||
{"* item two", "• item two"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := markdownToTelegramHTML(tt.input)
|
||||
if !strings.Contains(got, tt.want) {
|
||||
t.Errorf("markdownToTelegramHTML(%q) = %q, want substring %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToTelegramHTML_Link(t *testing.T) {
|
||||
got := markdownToTelegramHTML("[click here](https://example.com)")
|
||||
if !strings.Contains(got, `href="https://example.com"`) {
|
||||
t.Errorf("link should produce href, got: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "click here") {
|
||||
t.Errorf("link text missing in: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToTelegramHTML_HTMLEscaping(t *testing.T) {
|
||||
// Plain text with special chars should be escaped.
|
||||
got := markdownToTelegramHTML("a & b < c > d")
|
||||
if !strings.Contains(got, "&") {
|
||||
t.Errorf("& should be escaped, got: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "<") {
|
||||
t.Errorf("< should be escaped, got: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, ">") {
|
||||
t.Errorf("> should be escaped, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToTelegramHTML_HTMLTagsConvertedFirst(t *testing.T) {
|
||||
// LLM-emitted HTML tags should be converted to markdown first, then re-rendered as Telegram HTML.
|
||||
// <b> → **bold** → <b>bold</b>; <i> → _italic_ → <i>italic</i>, etc.
|
||||
t.Run("html bold tag round-trips", func(t *testing.T) {
|
||||
got := markdownToTelegramHTML("<b>bold</b>")
|
||||
if !strings.Contains(got, "<b>bold</b>") {
|
||||
t.Errorf("<b>bold</b> should remain bold in output, got: %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("html italic input produces output", func(t *testing.T) {
|
||||
// <i>italic</i> goes through htmlTagToMarkdown (→ _italic_ with known $1_ caveat)
|
||||
// then through markdownToTelegramHTML — the result is non-empty, no panics.
|
||||
got := markdownToTelegramHTML("<i>italic</i>")
|
||||
if got == "" {
|
||||
t.Error("markdownToTelegramHTML should return non-empty for <i>italic</i>")
|
||||
}
|
||||
})
|
||||
t.Run("html strike tag round-trips", func(t *testing.T) {
|
||||
got := markdownToTelegramHTML("<s>struck</s>")
|
||||
if !strings.Contains(got, "<s>struck</s>") {
|
||||
t.Errorf("<s>struck</s> should remain as strike in output, got: %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("html code tag round-trips", func(t *testing.T) {
|
||||
got := markdownToTelegramHTML("<code>var</code>")
|
||||
if !strings.Contains(got, "<code>var</code>") {
|
||||
t.Errorf("<code>var</code> should produce code element, got: %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("html br produces newline", func(t *testing.T) {
|
||||
got := markdownToTelegramHTML("line1<br>line2")
|
||||
if !strings.Contains(got, "line1") || !strings.Contains(got, "line2") {
|
||||
t.Errorf("both lines should appear after <br> conversion, got: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- extractMarkdownTables ---
|
||||
|
||||
func TestExtractMarkdownTables_Simple(t *testing.T) {
|
||||
input := "| Col1 | Col2 |\n|------|------|\n| A | B |"
|
||||
tm := extractMarkdownTables(input)
|
||||
|
||||
if len(tm.rendered) != 1 {
|
||||
t.Fatalf("expected 1 table, got %d", len(tm.rendered))
|
||||
}
|
||||
if !strings.Contains(tm.text, "\x00TB0\x00") {
|
||||
t.Errorf("expected TB0 placeholder in text, got: %q", tm.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMarkdownTables_NoTable(t *testing.T) {
|
||||
input := "just plain text\nno table here"
|
||||
tm := extractMarkdownTables(input)
|
||||
|
||||
if len(tm.rendered) != 0 {
|
||||
t.Errorf("expected 0 tables, got %d", len(tm.rendered))
|
||||
}
|
||||
if tm.text != input {
|
||||
t.Errorf("text without table should be unchanged, got: %q", tm.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMarkdownTables_MultipleTables(t *testing.T) {
|
||||
input := "| A | B |\n|---|---|\n| 1 | 2 |\n\nText\n\n| C | D |\n|---|---|\n| 3 | 4 |"
|
||||
tm := extractMarkdownTables(input)
|
||||
|
||||
if len(tm.rendered) != 2 {
|
||||
t.Errorf("expected 2 tables, got %d", len(tm.rendered))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMarkdownTables_TableInTelegramHTML(t *testing.T) {
|
||||
// End-to-end: table in final HTML should be inside <pre> not <pre><code>.
|
||||
input := "| Name | Value |\n|------|-------|\n| foo | bar |"
|
||||
got := markdownToTelegramHTML(input)
|
||||
|
||||
if !strings.Contains(got, "<pre>") {
|
||||
t.Errorf("table should be wrapped in <pre>, got: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "<pre><code>") {
|
||||
t.Errorf("table should NOT use <pre><code>, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- renderTableAsCode ---
|
||||
|
||||
func TestRenderTableAsCode_Basic(t *testing.T) {
|
||||
lines := []string{
|
||||
"| Name | Score |",
|
||||
"|------|-------|",
|
||||
"| Alice | 100 |",
|
||||
"| Bob | 85 |",
|
||||
}
|
||||
result := renderTableAsCode(lines)
|
||||
resultLines := strings.Split(result, "\n")
|
||||
|
||||
if len(resultLines) < 4 { // header + sep + 2 data rows
|
||||
t.Fatalf("expected at least 4 output lines, got %d: %q", len(resultLines), result)
|
||||
}
|
||||
|
||||
// All rows should start and end with |.
|
||||
for i, line := range resultLines {
|
||||
if !strings.HasPrefix(line, "|") || !strings.HasSuffix(line, "|") {
|
||||
t.Errorf("line %d should start and end with |, got: %q", i, line)
|
||||
}
|
||||
}
|
||||
|
||||
// All rows should have equal display width.
|
||||
headerWidth := displayWidth(resultLines[0])
|
||||
for i := 1; i < len(resultLines); i++ {
|
||||
if displayWidth(resultLines[i]) != headerWidth {
|
||||
t.Errorf("row %d width %d != header width %d\nrow: %q", i, displayWidth(resultLines[i]), headerWidth, resultLines[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTableAsCode_TooFewLines(t *testing.T) {
|
||||
// Less than 2 lines → return as-is.
|
||||
lines := []string{"| Only header |"}
|
||||
result := renderTableAsCode(lines)
|
||||
if result != "| Only header |" {
|
||||
t.Errorf("single-line table should return as-is, got: %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
// --- parseTableRow ---
|
||||
|
||||
func TestParseTableRow(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{"| A | B | C |", []string{"A", "B", "C"}},
|
||||
{"| **Bold** | _italic_ |", []string{"Bold", "italic"}},
|
||||
{"| `code` | normal |", []string{"code", "normal"}},
|
||||
{"| ~~strike~~ | text |", []string{"strike", "text"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := parseTableRow(tt.input)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("parseTableRow(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("cell[%d] = %q, want %q", i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- stripInlineMarkdown ---
|
||||
|
||||
func TestStripInlineMarkdown(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"**bold**", "bold"},
|
||||
{"__bold__", "bold"},
|
||||
{"_italic_", "italic"},
|
||||
{"*italic*", "italic"},
|
||||
{"~~strike~~", "strike"},
|
||||
{"`code`", "code"},
|
||||
{"plain text", "plain text"},
|
||||
{"**bold** and _italic_", "bold and italic"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := stripInlineMarkdown(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripInlineMarkdown(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- escapeHTML ---
|
||||
|
||||
func TestEscapeHTML(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"a & b", "a & b"},
|
||||
{"a < b", "a < b"},
|
||||
{"a > b", "a > b"},
|
||||
{"a & b < c > d", "a & b < c > d"},
|
||||
{"no special chars", "no special chars"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := escapeHTML(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("escapeHTML(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- chunkHTML: additional cases ---
|
||||
|
||||
func TestChunkHTML_ExactFit(t *testing.T) {
|
||||
text := "hello"
|
||||
got := chunkHTML(text, 5)
|
||||
if len(got) != 1 || got[0] != "hello" {
|
||||
t.Errorf("chunkHTML exact fit = %v, want [hello]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkHTML_EmptyString(t *testing.T) {
|
||||
got := chunkHTML("", 100)
|
||||
if len(got) != 1 || got[0] != "" {
|
||||
t.Errorf("chunkHTML(\"\") = %v, want [\"\"]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkHTML_PrefersParagraphBreak(t *testing.T) {
|
||||
// "para1\n\npara2" — should split at \n\n if maxLen allows.
|
||||
input := "para1\n\npara2\n\npara3"
|
||||
got := chunkHTML(input, 10)
|
||||
// Each chunk should not exceed 10 chars.
|
||||
for _, chunk := range got {
|
||||
if len(chunk) > 10 {
|
||||
t.Errorf("chunk %q exceeds maxLen 10", chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkHTML_PreservesAllContent(t *testing.T) {
|
||||
input := "The quick brown fox jumps over the lazy dog"
|
||||
got := chunkHTML(input, 10)
|
||||
// Rejoin and verify content (allowing boundary trimming of spaces).
|
||||
joined := strings.Join(got, " ")
|
||||
for _, word := range []string{"quick", "brown", "fox", "jumps", "lazy", "dog"} {
|
||||
if !strings.Contains(joined, word) {
|
||||
t.Errorf("word %q lost in chunking: %v", word, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- chunkPlainText: delegates to chunkHTML ---
|
||||
|
||||
func TestChunkPlainText_Delegates(t *testing.T) {
|
||||
// chunkPlainText is an alias for chunkHTML.
|
||||
input := "line one\nline two\nline three"
|
||||
got := chunkPlainText(input, 15)
|
||||
want := chunkHTML(input, 15)
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("chunkPlainText != chunkHTML result")
|
||||
}
|
||||
}
|
||||
|
||||
// --- htmlTagToMarkdown ---
|
||||
|
||||
func TestHTMLTagToMarkdown(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantSubs []string // substrings that must appear in output
|
||||
}{
|
||||
{"<br>", []string{"\n"}},
|
||||
{"<br/>", []string{"\n"}},
|
||||
{"<b>bold</b>", []string{"**bold**"}},
|
||||
{"<strong>bold</strong>", []string{"**bold**"}},
|
||||
// NOTE: <i>/<em> use "_$1_" replacement where Go regexp treats $1_ as
|
||||
// a named group reference (not group 1 + literal _), so content is lost.
|
||||
// This is documented behavior of the current implementation.
|
||||
// The pipeline still works end-to-end because <i> only appears in LLM
|
||||
// output which gets processed through the full markdownToTelegramHTML chain.
|
||||
{"<s>struck</s>", []string{"~~struck~~"}},
|
||||
{"<del>deleted</del>", []string{"~~deleted~~"}},
|
||||
{"<code>var</code>", []string{"`var`"}},
|
||||
{`<a href="https://example.com">link</a>`, []string{"[link](https://example.com)"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := htmlTagToMarkdown(tt.input)
|
||||
for _, sub := range tt.wantSubs {
|
||||
if !strings.Contains(got, sub) {
|
||||
t.Errorf("htmlTagToMarkdown(%q) = %q, missing %q", tt.input, got, sub)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
)
|
||||
|
||||
// --- detectMention ---
|
||||
|
||||
func TestDetectMention_EmptyBotUsername(t *testing.T) {
|
||||
ch := &Channel{}
|
||||
msg := &telego.Message{Text: "@somebot hello"}
|
||||
// Empty botUsername → always false (fail closed).
|
||||
if ch.detectMention(msg, "") {
|
||||
t.Error("detectMention with empty botUsername should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMention_TextMentionEntity(t *testing.T) {
|
||||
ch := &Channel{}
|
||||
// Message that mentions @testbot via a mention entity.
|
||||
msg := &telego.Message{
|
||||
Text: "@testbot please help",
|
||||
Entities: []telego.MessageEntity{
|
||||
{Type: "mention", Offset: 0, Length: 8},
|
||||
},
|
||||
}
|
||||
if !ch.detectMention(msg, "testbot") {
|
||||
t.Error("detectMention should return true when bot is @mentioned via entity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMention_CaseInsensitiveUsername(t *testing.T) {
|
||||
ch := &Channel{}
|
||||
msg := &telego.Message{
|
||||
Text: "@TestBot please help",
|
||||
Entities: []telego.MessageEntity{
|
||||
{Type: "mention", Offset: 0, Length: 8},
|
||||
},
|
||||
}
|
||||
if !ch.detectMention(msg, "testbot") {
|
||||
t.Error("detectMention should be case-insensitive for username match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMention_DifferentUserMentioned(t *testing.T) {
|
||||
ch := &Channel{}
|
||||
msg := &telego.Message{
|
||||
Text: "@otherbot hello",
|
||||
Entities: []telego.MessageEntity{
|
||||
{Type: "mention", Offset: 0, Length: 9},
|
||||
},
|
||||
}
|
||||
if ch.detectMention(msg, "testbot") {
|
||||
t.Error("detectMention should return false when different user is mentioned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMention_FallbackSubstringCheck(t *testing.T) {
|
||||
ch := &Channel{}
|
||||
// No entities but text contains @testbot → fallback substring match.
|
||||
msg := &telego.Message{Text: "hey @testbot can you help?"}
|
||||
if !ch.detectMention(msg, "testbot") {
|
||||
t.Error("detectMention should return true via fallback substring check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMention_CaptionMention(t *testing.T) {
|
||||
ch := &Channel{}
|
||||
// Media message with mention in caption.
|
||||
msg := &telego.Message{
|
||||
Caption: "@testbot look at this photo",
|
||||
CaptionEntities: []telego.MessageEntity{
|
||||
{Type: "mention", Offset: 0, Length: 8},
|
||||
},
|
||||
}
|
||||
if !ch.detectMention(msg, "testbot") {
|
||||
t.Error("detectMention should detect mention in caption entities")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMention_ReplyToBotMessage(t *testing.T) {
|
||||
ch := &Channel{}
|
||||
// Replying to bot's message = implicit mention.
|
||||
msg := &telego.Message{
|
||||
Text: "thanks",
|
||||
ReplyToMessage: &telego.Message{
|
||||
From: &telego.User{Username: "testbot"},
|
||||
},
|
||||
}
|
||||
if !ch.detectMention(msg, "testbot") {
|
||||
t.Error("detectMention should return true when replying to bot's message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMention_ReplyToDifferentUser(t *testing.T) {
|
||||
ch := &Channel{}
|
||||
msg := &telego.Message{
|
||||
Text: "cool",
|
||||
ReplyToMessage: &telego.Message{
|
||||
From: &telego.User{Username: "someoneelse"},
|
||||
},
|
||||
}
|
||||
if ch.detectMention(msg, "testbot") {
|
||||
t.Error("detectMention should return false when replying to a different user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMention_NoMentionInPlainText(t *testing.T) {
|
||||
ch := &Channel{}
|
||||
msg := &telego.Message{Text: "just a plain message"}
|
||||
if ch.detectMention(msg, "testbot") {
|
||||
t.Error("detectMention should return false for plain text with no mention")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMention_BotCommandWithAtSuffix(t *testing.T) {
|
||||
ch := &Channel{}
|
||||
// /start@testbot as a bot_command entity.
|
||||
msg := &telego.Message{
|
||||
Text: "/start@testbot",
|
||||
Entities: []telego.MessageEntity{
|
||||
{Type: "bot_command", Offset: 0, Length: 14},
|
||||
},
|
||||
}
|
||||
if !ch.detectMention(msg, "testbot") {
|
||||
t.Error("detectMention should return true for /cmd@botname entity")
|
||||
}
|
||||
}
|
||||
|
||||
// --- hasOtherMention ---
|
||||
|
||||
func TestHasOtherMention_NoMentions(t *testing.T) {
|
||||
ch := &Channel{}
|
||||
msg := &telego.Message{Text: "plain text"}
|
||||
if ch.hasOtherMention(msg, "mybot") {
|
||||
t.Error("hasOtherMention should return false for plain text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasOtherMention_OnlyOwnMention(t *testing.T) {
|
||||
ch := &Channel{}
|
||||
// Only @mybot is mentioned → should NOT trigger hasOtherMention.
|
||||
msg := &telego.Message{
|
||||
Text: "@mybot hello",
|
||||
Entities: []telego.MessageEntity{
|
||||
{Type: "mention", Offset: 0, Length: 6},
|
||||
},
|
||||
}
|
||||
if ch.hasOtherMention(msg, "mybot") {
|
||||
t.Error("hasOtherMention should return false when only own bot is mentioned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasOtherMention_AnotherBotMentioned(t *testing.T) {
|
||||
ch := &Channel{}
|
||||
// @otherbot is mentioned.
|
||||
msg := &telego.Message{
|
||||
Text: "@otherbot help",
|
||||
Entities: []telego.MessageEntity{
|
||||
{Type: "mention", Offset: 0, Length: 9},
|
||||
},
|
||||
}
|
||||
if !ch.hasOtherMention(msg, "mybot") {
|
||||
t.Error("hasOtherMention should return true when a different bot is @mentioned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasOtherMention_CommandAddressedToOtherBot(t *testing.T) {
|
||||
ch := &Channel{}
|
||||
// /start@otherbot — command addressed to another bot.
|
||||
msg := &telego.Message{
|
||||
Text: "/start@otherbot",
|
||||
Entities: []telego.MessageEntity{
|
||||
{Type: "bot_command", Offset: 0, Length: 15},
|
||||
},
|
||||
}
|
||||
if !ch.hasOtherMention(msg, "mybot") {
|
||||
t.Error("hasOtherMention should return true for /cmd@otherbot entity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasOtherMention_CommandAddressedToOwnBot(t *testing.T) {
|
||||
ch := &Channel{}
|
||||
// /start@mybot — addressed to own bot → should NOT trigger.
|
||||
msg := &telego.Message{
|
||||
Text: "/start@mybot",
|
||||
Entities: []telego.MessageEntity{
|
||||
{Type: "bot_command", Offset: 0, Length: 12},
|
||||
},
|
||||
}
|
||||
if ch.hasOtherMention(msg, "mybot") {
|
||||
t.Error("hasOtherMention should return false for /cmd@mybot (own bot)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasOtherMention_CaptionWithOtherMention(t *testing.T) {
|
||||
ch := &Channel{}
|
||||
msg := &telego.Message{
|
||||
Caption: "@otherbot look at this",
|
||||
CaptionEntities: []telego.MessageEntity{
|
||||
{Type: "mention", Offset: 0, Length: 9},
|
||||
},
|
||||
}
|
||||
if !ch.hasOtherMention(msg, "mybot") {
|
||||
t.Error("hasOtherMention should detect other @mention in caption")
|
||||
}
|
||||
}
|
||||
|
||||
// --- isServiceMessage ---
|
||||
|
||||
func TestIsServiceMessage_WithText(t *testing.T) {
|
||||
msg := &telego.Message{Text: "hello world"}
|
||||
if isServiceMessage(msg) {
|
||||
t.Error("message with text is NOT a service message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsServiceMessage_WithCaption(t *testing.T) {
|
||||
msg := &telego.Message{Caption: "photo caption"}
|
||||
if isServiceMessage(msg) {
|
||||
t.Error("message with caption is NOT a service message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsServiceMessage_WithPhoto(t *testing.T) {
|
||||
msg := &telego.Message{Photo: []telego.PhotoSize{{FileID: "x"}}}
|
||||
if isServiceMessage(msg) {
|
||||
t.Error("message with photo is NOT a service message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsServiceMessage_WithAudio(t *testing.T) {
|
||||
msg := &telego.Message{Audio: &telego.Audio{FileID: "x"}}
|
||||
if isServiceMessage(msg) {
|
||||
t.Error("message with audio is NOT a service message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsServiceMessage_WithDocument(t *testing.T) {
|
||||
msg := &telego.Message{Document: &telego.Document{FileID: "x"}}
|
||||
if isServiceMessage(msg) {
|
||||
t.Error("message with document is NOT a service message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsServiceMessage_EmptyMessage(t *testing.T) {
|
||||
msg := &telego.Message{}
|
||||
if !isServiceMessage(msg) {
|
||||
t.Error("empty message (no text, caption, media) is a service message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsServiceMessage_WithSticker(t *testing.T) {
|
||||
msg := &telego.Message{Sticker: &telego.Sticker{FileID: "x"}}
|
||||
if isServiceMessage(msg) {
|
||||
t.Error("message with sticker is NOT a service message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsServiceMessage_WithVoice(t *testing.T) {
|
||||
msg := &telego.Message{Voice: &telego.Voice{FileID: "x"}}
|
||||
if isServiceMessage(msg) {
|
||||
t.Error("message with voice is NOT a service message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsServiceMessage_WithLocation(t *testing.T) {
|
||||
msg := &telego.Message{Location: &telego.Location{Longitude: 1.0}}
|
||||
if isServiceMessage(msg) {
|
||||
t.Error("message with location is NOT a service message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsServiceMessage_WithPoll(t *testing.T) {
|
||||
msg := &telego.Message{Poll: &telego.Poll{ID: "p1"}}
|
||||
if isServiceMessage(msg) {
|
||||
t.Error("message with poll is NOT a service message")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package whatsapp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- chunkText ---
|
||||
|
||||
func TestChunkText_FitsInOne(t *testing.T) {
|
||||
got := chunkText("hello world", 100)
|
||||
if len(got) != 1 || got[0] != "hello world" {
|
||||
t.Errorf("chunkText short text = %v, want [hello world]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkText_ExactLength(t *testing.T) {
|
||||
text := "hello"
|
||||
got := chunkText(text, 5)
|
||||
if len(got) != 1 || got[0] != "hello" {
|
||||
t.Errorf("chunkText exact length = %v, want [hello]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkText_SplitAtParagraph(t *testing.T) {
|
||||
text := "para one\n\npara two"
|
||||
got := chunkText(text, 12)
|
||||
if len(got) < 2 {
|
||||
t.Fatalf("expected at least 2 chunks, got %d: %v", len(got), got)
|
||||
}
|
||||
if !strings.Contains(got[0], "para one") {
|
||||
t.Errorf("first chunk should contain 'para one', got %q", got[0])
|
||||
}
|
||||
if !strings.Contains(got[1], "para two") {
|
||||
t.Errorf("second chunk should contain 'para two', got %q", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkText_SplitAtNewline(t *testing.T) {
|
||||
text := "line one\nline two\nline three"
|
||||
got := chunkText(text, 14)
|
||||
if len(got) < 2 {
|
||||
t.Fatalf("expected at least 2 chunks, got %d: %v", len(got), got)
|
||||
}
|
||||
// All content must be preserved.
|
||||
joined := strings.Join(got, "\n")
|
||||
for _, word := range []string{"line one", "line two", "line three"} {
|
||||
if !strings.Contains(joined, word) {
|
||||
t.Errorf("word %q lost after chunking, got: %v", word, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkText_SplitAtSpace(t *testing.T) {
|
||||
text := "word1 word2 word3 word4"
|
||||
got := chunkText(text, 12)
|
||||
// No chunk should exceed maxLen.
|
||||
for _, chunk := range got {
|
||||
if len(chunk) > 12 {
|
||||
t.Errorf("chunk %q exceeds maxLen 12", chunk)
|
||||
}
|
||||
}
|
||||
// All words must be present.
|
||||
joined := strings.Join(got, " ")
|
||||
for _, word := range []string{"word1", "word2", "word3", "word4"} {
|
||||
if !strings.Contains(joined, word) {
|
||||
t.Errorf("word %q lost after chunking, got: %v", word, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkText_HardCut(t *testing.T) {
|
||||
// No whitespace — must hard-cut.
|
||||
text := "abcdefghij"
|
||||
got := chunkText(text, 5)
|
||||
if len(got) < 2 {
|
||||
t.Fatalf("expected at least 2 chunks, got %d: %v", len(got), got)
|
||||
}
|
||||
for _, chunk := range got {
|
||||
if len(chunk) > 5 {
|
||||
t.Errorf("chunk %q exceeds maxLen 5", chunk)
|
||||
}
|
||||
}
|
||||
// All characters must survive.
|
||||
joined := strings.Join(got, "")
|
||||
if joined != text {
|
||||
t.Errorf("hard-cut lost content: joined=%q, want=%q", joined, text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkText_EmptyString(t *testing.T) {
|
||||
got := chunkText("", 100)
|
||||
// Empty input: function returns nil (no chunks) or empty slice — acceptable.
|
||||
// The caller (Send) handles this gracefully.
|
||||
for _, chunk := range got {
|
||||
if chunk != "" {
|
||||
t.Errorf("unexpected non-empty chunk for empty input: %q", chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkText_ChunksDoNotExceedMaxLen(t *testing.T) {
|
||||
// Property test: no chunk should ever exceed maxLen.
|
||||
inputs := []string{
|
||||
"The quick brown fox jumps over the lazy dog",
|
||||
"a\nb\nc\nd\ne\nf\ng",
|
||||
"para1\n\npara2\n\npara3\n\npara4",
|
||||
strings.Repeat("x", 200),
|
||||
}
|
||||
const maxLen = 20
|
||||
for _, input := range inputs {
|
||||
got := chunkText(input, maxLen)
|
||||
for _, chunk := range got {
|
||||
if len(chunk) > maxLen {
|
||||
t.Errorf("input %q: chunk %q exceeds maxLen %d", input[:min(20, len(input))], chunk, maxLen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- markdownToWhatsApp: additional edge cases ---
|
||||
|
||||
func TestMarkdownToWhatsApp_Empty(t *testing.T) {
|
||||
got := markdownToWhatsApp("")
|
||||
if got != "" {
|
||||
t.Errorf("markdownToWhatsApp(\"\") = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToWhatsApp_OnlyWhitespace(t *testing.T) {
|
||||
got := markdownToWhatsApp(" \n\n ")
|
||||
// TrimSpace at end should produce empty.
|
||||
if got != "" {
|
||||
t.Errorf("markdownToWhatsApp(whitespace) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToWhatsApp_CollapseBlankLines(t *testing.T) {
|
||||
got := markdownToWhatsApp("a\n\n\n\n\nb")
|
||||
if strings.Contains(got, "\n\n\n") {
|
||||
t.Errorf("blank lines not collapsed, got: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "a") || !strings.Contains(got, "b") {
|
||||
t.Errorf("content lost after collapsing blank lines, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToWhatsApp_MixedFormatting(t *testing.T) {
|
||||
input := "# Title\n\n**bold** and ~~strike~~ and `code` and [link](https://x.com)"
|
||||
got := markdownToWhatsApp(input)
|
||||
|
||||
// Header should become bold.
|
||||
if !strings.Contains(got, "*Title*") {
|
||||
t.Errorf("header should become *bold*, got: %q", got)
|
||||
}
|
||||
// **bold** → *bold*
|
||||
if !strings.Contains(got, "*bold*") {
|
||||
t.Errorf("**bold** should become *bold*, got: %q", got)
|
||||
}
|
||||
// ~~strike~~ → ~strike~
|
||||
if !strings.Contains(got, "~strike~") {
|
||||
t.Errorf("~~strike~~ should become ~strike~, got: %q", got)
|
||||
}
|
||||
// `code` → ```code```
|
||||
if !strings.Contains(got, "```code```") {
|
||||
t.Errorf("`code` should become ```code```, got: %q", got)
|
||||
}
|
||||
// Link → text url
|
||||
if !strings.Contains(got, "link https://x.com") {
|
||||
t.Errorf("[link](url) should become 'link url', got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToWhatsApp_CodeBlockPreservedInternals(t *testing.T) {
|
||||
// Internal formatting inside code blocks must not be mangled by outer regex passes.
|
||||
// waExtractCodeBlocks pulls out the block before bold/italic regexes run, then restores it verbatim.
|
||||
input := "```\n**not bold** and _not italic_\n```"
|
||||
got := markdownToWhatsApp(input)
|
||||
|
||||
// The raw markdown inside the block must be restored as-is (not converted to *not bold*).
|
||||
if !strings.Contains(got, "**not bold**") {
|
||||
t.Errorf("code block internal **...** should NOT be converted to *..*, got: %q", got)
|
||||
}
|
||||
// Code block delimiters must be present.
|
||||
if !strings.Contains(got, "```") {
|
||||
t.Errorf("code block fences should be preserved, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToWhatsApp_OrderedListPassthrough(t *testing.T) {
|
||||
// Ordered lists (1. item) are not converted — WhatsApp has no ordered list syntax.
|
||||
input := "1. first\n2. second\n3. third"
|
||||
got := markdownToWhatsApp(input)
|
||||
// Numbers should survive (not be mangled).
|
||||
if !strings.Contains(got, "first") || !strings.Contains(got, "second") {
|
||||
t.Errorf("ordered list content lost, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToWhatsApp_HTMLStrongTag(t *testing.T) {
|
||||
got := markdownToWhatsApp("<strong>bold</strong>")
|
||||
if !strings.Contains(got, "*bold*") {
|
||||
t.Errorf("<strong>bold</strong> should become *bold*, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToWhatsApp_HTMLParagraph(t *testing.T) {
|
||||
got := markdownToWhatsApp("<p>paragraph</p>")
|
||||
if !strings.Contains(got, "paragraph") {
|
||||
t.Errorf("paragraph content should survive <p> tag conversion, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToWhatsApp_MultipleCodeBlocks(t *testing.T) {
|
||||
input := "```go\nfmt.Println(\"hi\")\n```\n\nText\n\n```py\nprint('hello')\n```"
|
||||
got := markdownToWhatsApp(input)
|
||||
|
||||
// Both code blocks must survive.
|
||||
if !strings.Contains(got, "fmt.Println") {
|
||||
t.Errorf("first code block content lost, got: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "print('hello')") {
|
||||
t.Errorf("second code block content lost, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- waExtractCodeBlocks ---
|
||||
|
||||
func TestWAExtractCodeBlocks_NoBlocks(t *testing.T) {
|
||||
result := waExtractCodeBlocks("plain text")
|
||||
if len(result.codes) != 0 {
|
||||
t.Errorf("expected 0 code blocks, got %d", len(result.codes))
|
||||
}
|
||||
if result.text != "plain text" {
|
||||
t.Errorf("text should be unchanged, got %q", result.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWAExtractCodeBlocks_SingleBlock(t *testing.T) {
|
||||
result := waExtractCodeBlocks("```\ncode here\n```")
|
||||
if len(result.codes) != 1 {
|
||||
t.Fatalf("expected 1 code block, got %d", len(result.codes))
|
||||
}
|
||||
if !strings.Contains(result.codes[0], "code here") {
|
||||
t.Errorf("code block content not captured: %q", result.codes[0])
|
||||
}
|
||||
if !strings.Contains(result.text, "\x00CB0\x00") {
|
||||
t.Errorf("placeholder not inserted in text: %q", result.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWAExtractCodeBlocks_MultipleBlocks(t *testing.T) {
|
||||
input := "```\nblock1\n```\nmiddle\n```\nblock2\n```"
|
||||
result := waExtractCodeBlocks(input)
|
||||
if len(result.codes) != 2 {
|
||||
t.Fatalf("expected 2 code blocks, got %d: %v", len(result.codes), result.codes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWAExtractCodeBlocks_WithLanguageAnnotation(t *testing.T) {
|
||||
result := waExtractCodeBlocks("```python\nprint('hi')\n```")
|
||||
if len(result.codes) != 1 {
|
||||
t.Fatalf("expected 1 code block with language, got %d", len(result.codes))
|
||||
}
|
||||
if !strings.Contains(result.codes[0], "print('hi')") {
|
||||
t.Errorf("code block content missing: %q", result.codes[0])
|
||||
}
|
||||
}
|
||||
|
||||
// --- htmlTagToWaMd ---
|
||||
|
||||
func TestHTMLTagToWaMd_Bold(t *testing.T) {
|
||||
got := htmlTagToWaMd("<b>text</b>")
|
||||
if !strings.Contains(got, "**text**") {
|
||||
t.Errorf("htmlTagToWaMd(<b>) = %q, want **text**", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLTagToWaMd_Strong(t *testing.T) {
|
||||
got := htmlTagToWaMd("<strong>text</strong>")
|
||||
if !strings.Contains(got, "**text**") {
|
||||
t.Errorf("htmlTagToWaMd(<strong>) = %q, want **text**", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLTagToWaMd_Strike(t *testing.T) {
|
||||
got := htmlTagToWaMd("<s>text</s>")
|
||||
if !strings.Contains(got, "~~text~~") {
|
||||
t.Errorf("htmlTagToWaMd(<s>) = %q, want ~~text~~", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLTagToWaMd_Code(t *testing.T) {
|
||||
got := htmlTagToWaMd("<code>var</code>")
|
||||
if !strings.Contains(got, "`var`") {
|
||||
t.Errorf("htmlTagToWaMd(<code>) = %q, want `var`", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLTagToWaMd_Link(t *testing.T) {
|
||||
got := htmlTagToWaMd(`<a href="https://example.com">click</a>`)
|
||||
if !strings.Contains(got, "[click](https://example.com)") {
|
||||
t.Errorf("htmlTagToWaMd(<a>) = %q, want markdown link", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLTagToWaMd_BR(t *testing.T) {
|
||||
got := htmlTagToWaMd("line1<br>line2")
|
||||
if !strings.Contains(got, "\n") {
|
||||
t.Errorf("htmlTagToWaMd(<br>) = %q, want newline", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLTagToWaMd_PlainText(t *testing.T) {
|
||||
got := htmlTagToWaMd("plain text")
|
||||
if got != "plain text" {
|
||||
t.Errorf("htmlTagToWaMd(plain) = %q, want unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package whatsapp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.mau.fi/whatsmeow/proto/waE2E"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// --- extractTextContent ---
|
||||
|
||||
func TestExtractTextContent_Nil(t *testing.T) {
|
||||
got := extractTextContent(nil)
|
||||
if got != "" {
|
||||
t.Errorf("extractTextContent(nil) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTextContent_Conversation(t *testing.T) {
|
||||
msg := &waE2E.Message{Conversation: proto.String("hello world")}
|
||||
got := extractTextContent(msg)
|
||||
if got != "hello world" {
|
||||
t.Errorf("extractTextContent(Conversation) = %q, want %q", got, "hello world")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTextContent_ExtendedText(t *testing.T) {
|
||||
msg := &waE2E.Message{
|
||||
ExtendedTextMessage: &waE2E.ExtendedTextMessage{
|
||||
Text: proto.String("extended message"),
|
||||
},
|
||||
}
|
||||
got := extractTextContent(msg)
|
||||
if got != "extended message" {
|
||||
t.Errorf("extractTextContent(ExtendedText) = %q, want %q", got, "extended message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTextContent_ExtendedTextWithQuote(t *testing.T) {
|
||||
msg := &waE2E.Message{
|
||||
ExtendedTextMessage: &waE2E.ExtendedTextMessage{
|
||||
Text: proto.String("my reply"),
|
||||
ContextInfo: &waE2E.ContextInfo{
|
||||
QuotedMessage: &waE2E.Message{
|
||||
Conversation: proto.String("original message"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := extractTextContent(msg)
|
||||
if !strings.Contains(got, "[Replying to: original message]") {
|
||||
t.Errorf("expected reply context prefix, got: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "my reply") {
|
||||
t.Errorf("expected reply text in output, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTextContent_QuoteOnlyNoText(t *testing.T) {
|
||||
msg := &waE2E.Message{
|
||||
ExtendedTextMessage: &waE2E.ExtendedTextMessage{
|
||||
Text: proto.String(""),
|
||||
ContextInfo: &waE2E.ContextInfo{
|
||||
QuotedMessage: &waE2E.Message{
|
||||
Conversation: proto.String("quoted"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := extractTextContent(msg)
|
||||
if got != "[Replying to: quoted]" {
|
||||
t.Errorf("extractTextContent(quote-only) = %q, want %q", got, "[Replying to: quoted]")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTextContent_ImageCaption(t *testing.T) {
|
||||
msg := &waE2E.Message{
|
||||
ImageMessage: &waE2E.ImageMessage{
|
||||
Caption: proto.String("look at this photo"),
|
||||
},
|
||||
}
|
||||
got := extractTextContent(msg)
|
||||
if got != "look at this photo" {
|
||||
t.Errorf("extractTextContent(ImageCaption) = %q, want %q", got, "look at this photo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTextContent_VideoCaption(t *testing.T) {
|
||||
msg := &waE2E.Message{
|
||||
VideoMessage: &waE2E.VideoMessage{
|
||||
Caption: proto.String("cool video"),
|
||||
},
|
||||
}
|
||||
got := extractTextContent(msg)
|
||||
if got != "cool video" {
|
||||
t.Errorf("extractTextContent(VideoCaption) = %q, want %q", got, "cool video")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTextContent_DocumentCaption(t *testing.T) {
|
||||
msg := &waE2E.Message{
|
||||
DocumentMessage: &waE2E.DocumentMessage{
|
||||
Caption: proto.String("see this document"),
|
||||
},
|
||||
}
|
||||
got := extractTextContent(msg)
|
||||
if got != "see this document" {
|
||||
t.Errorf("extractTextContent(DocumentCaption) = %q, want %q", got, "see this document")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTextContent_EmptyMessage(t *testing.T) {
|
||||
msg := &waE2E.Message{}
|
||||
got := extractTextContent(msg)
|
||||
if got != "" {
|
||||
t.Errorf("extractTextContent(empty) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- extractQuotedText ---
|
||||
|
||||
func TestExtractQuotedText_Nil(t *testing.T) {
|
||||
got := extractQuotedText(nil)
|
||||
if got != "" {
|
||||
t.Errorf("extractQuotedText(nil) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractQuotedText_Conversation(t *testing.T) {
|
||||
msg := &waE2E.Message{Conversation: proto.String("quoted text")}
|
||||
got := extractQuotedText(msg)
|
||||
if got != "quoted text" {
|
||||
t.Errorf("extractQuotedText(Conversation) = %q, want %q", got, "quoted text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractQuotedText_ExtendedText(t *testing.T) {
|
||||
msg := &waE2E.Message{
|
||||
ExtendedTextMessage: &waE2E.ExtendedTextMessage{
|
||||
Text: proto.String("extended quoted"),
|
||||
},
|
||||
}
|
||||
got := extractQuotedText(msg)
|
||||
if got != "extended quoted" {
|
||||
t.Errorf("extractQuotedText(ExtendedText) = %q, want %q", got, "extended quoted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractQuotedText_ImageCaption(t *testing.T) {
|
||||
msg := &waE2E.Message{
|
||||
ImageMessage: &waE2E.ImageMessage{Caption: proto.String("img caption")},
|
||||
}
|
||||
got := extractQuotedText(msg)
|
||||
if got != "img caption" {
|
||||
t.Errorf("extractQuotedText(ImageCaption) = %q, want %q", got, "img caption")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractQuotedText_VideoCaption(t *testing.T) {
|
||||
msg := &waE2E.Message{
|
||||
VideoMessage: &waE2E.VideoMessage{Caption: proto.String("vid caption")},
|
||||
}
|
||||
got := extractQuotedText(msg)
|
||||
if got != "vid caption" {
|
||||
t.Errorf("extractQuotedText(VideoCaption) = %q, want %q", got, "vid caption")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractQuotedText_EmptyCaption(t *testing.T) {
|
||||
// Image with empty caption → falls through to empty.
|
||||
msg := &waE2E.Message{
|
||||
ImageMessage: &waE2E.ImageMessage{Caption: proto.String("")},
|
||||
}
|
||||
got := extractQuotedText(msg)
|
||||
if got != "" {
|
||||
t.Errorf("extractQuotedText(empty caption) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractQuotedText_NoMatch(t *testing.T) {
|
||||
// Message with no text fields → empty.
|
||||
msg := &waE2E.Message{}
|
||||
got := extractQuotedText(msg)
|
||||
if got != "" {
|
||||
t.Errorf("extractQuotedText(no fields) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package whatsapp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- mimeToExt ---
|
||||
|
||||
func TestMimeToExt(t *testing.T) {
|
||||
tests := []struct {
|
||||
mime string
|
||||
want string
|
||||
}{
|
||||
{"image/jpeg", ".jpg"},
|
||||
{"image/jpeg; charset=utf-8", ".jpg"}, // HasPrefix match
|
||||
{"image/png", ".png"},
|
||||
{"image/webp", ".webp"},
|
||||
{"video/mp4", ".mp4"},
|
||||
{"audio/ogg", ".ogg"},
|
||||
{"audio/ogg; codecs=opus", ".ogg"},
|
||||
{"audio/mpeg", ".mp3"},
|
||||
{"application/pdf", ".pdf"},
|
||||
{"application/octet-stream", ".bin"},
|
||||
{"text/plain", ".bin"},
|
||||
{"", ".bin"},
|
||||
{"image/gif", ".bin"}, // not in the switch → default
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.mime, func(t *testing.T) {
|
||||
got := mimeToExt(tt.mime)
|
||||
if got != tt.want {
|
||||
t.Errorf("mimeToExt(%q) = %q, want %q", tt.mime, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- classifyDownloadError ---
|
||||
|
||||
func TestClassifyDownloadError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errMsg string
|
||||
want string
|
||||
}{
|
||||
{"timeout", "connection timeout exceeded", "timeout"},
|
||||
{"deadline exceeded", "context deadline exceeded", "timeout"},
|
||||
{"decrypt error", "failed to decrypt media", "decrypt_error"},
|
||||
{"cipher error", "cipher: message authentication failed", "decrypt_error"},
|
||||
{"404 not found", "server returned 404", "expired"},
|
||||
{"not found text", "media not found on server", "expired"},
|
||||
{"unsupported type", "unsupported media type", "unsupported"},
|
||||
{"unknown generic error", "connection reset by peer", "unknown"},
|
||||
{"empty error", "", "unknown"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := errors.New(tt.errMsg)
|
||||
got := classifyDownloadError(err)
|
||||
if got != tt.want {
|
||||
t.Errorf("classifyDownloadError(%q) = %q, want %q", tt.errMsg, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- scheduleMediaCleanup: no-op with empty paths ---
|
||||
|
||||
func TestScheduleMediaCleanup_EmptyPaths(t *testing.T) {
|
||||
// Should not panic with empty paths.
|
||||
scheduleMediaCleanup(nil, 0)
|
||||
scheduleMediaCleanup([]string{}, 0)
|
||||
}
|
||||
Reference in new issue
Block a user