mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 22:15:00 +00:00
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
229 lines
6.3 KiB
Go
229 lines
6.3 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|