Files
2026-07-10 22:25:45 +07:00

477 lines
14 KiB
Go

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/channelmemory"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// --- 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)
}
})
}
}
func TestDiscordAuthorLabelIncludesHandleAndID(t *testing.T) {
m := &discordgo.MessageCreate{Message: &discordgo.Message{
Author: &discordgo.User{
ID: "u123",
Username: "alexproducer",
GlobalName: "Alex Producer",
},
}}
got := discordAuthorLabel(m.Author, m.Member)
want := "Alex Producer (@alexproducer, id: u123)"
if got != want {
t.Fatalf("discordAuthorLabel() = %q, want %q", got, want)
}
}
func TestResolveCachedChannelTitle(t *testing.T) {
session, err := discordgo.New("Bot test-token")
if err != nil {
t.Fatalf("discordgo.New() error = %v", err)
}
session.State = discordgo.NewState()
if err := session.State.GuildAdd(&discordgo.Guild{ID: "guild-1"}); err != nil {
t.Fatalf("GuildAdd() error = %v", err)
}
if err := session.State.ChannelAdd(&discordgo.Channel{ID: "chan-1", GuildID: "guild-1", Name: "support-room"}); err != nil {
t.Fatalf("ChannelAdd() error = %v", err)
}
ch := &Channel{session: session}
if got := ch.resolveCachedChannelTitle("chan-1"); got != "support-room" {
t.Fatalf("resolveCachedChannelTitle() = %q, want support-room", got)
}
if got := ch.resolveCachedChannelTitle("missing"); got != "" {
t.Fatalf("missing channel title = %q, want empty", got)
}
}
func TestResolveGroupDisplayTitleQualifiesDiscordThread(t *testing.T) {
session, err := discordgo.New("Bot test-token")
if err != nil {
t.Fatalf("discordgo.New() error = %v", err)
}
session.State = discordgo.NewState()
if err := session.State.GuildAdd(&discordgo.Guild{ID: "guild-1"}); err != nil {
t.Fatalf("GuildAdd() error = %v", err)
}
for _, channel := range []*discordgo.Channel{
{ID: "parent-1", GuildID: "guild-1", Name: "product-planning", Type: discordgo.ChannelTypeGuildText},
{ID: "thread-1", GuildID: "guild-1", Name: "launch-thread", ParentID: "parent-1", Type: discordgo.ChannelTypeGuildPublicThread},
} {
if err := session.State.ChannelAdd(channel); err != nil {
t.Fatalf("ChannelAdd(%s) error = %v", channel.ID, err)
}
}
ch := &Channel{session: session}
if got := ch.resolveCachedGroupDisplayTitle("thread-1"); got != "launch-thread / product-planning" {
t.Fatalf("cached display title = %q, want qualified thread title", got)
}
if got := ch.resolveCachedGroupDisplayTitle("parent-1"); got != "product-planning" {
t.Fatalf("parent display title = %q, want raw parent title", got)
}
if got, err := ch.ResolveGroupDisplayTitle(context.Background(), "thread-1"); err != nil || got != "launch-thread / product-planning" {
t.Fatalf("ResolveGroupDisplayTitle() = %q, %v", got, err)
}
}
func TestResolveMemoryExtractionContextIncludesThreadParentAndCategory(t *testing.T) {
session, err := discordgo.New("Bot test-token")
if err != nil {
t.Fatalf("discordgo.New() error = %v", err)
}
session.State = discordgo.NewState()
if err := session.State.GuildAdd(&discordgo.Guild{ID: "guild-1"}); err != nil {
t.Fatalf("GuildAdd() error = %v", err)
}
for _, ch := range []*discordgo.Channel{
{ID: "cat-1", GuildID: "guild-1", Name: "Launch", Type: discordgo.ChannelTypeGuildCategory},
{ID: "parent-1", GuildID: "guild-1", Name: "design", ParentID: "cat-1", Type: discordgo.ChannelTypeGuildText},
{ID: "thread-1", GuildID: "guild-1", Name: "project-thread", ParentID: "parent-1", Type: discordgo.ChannelTypeGuildPublicThread},
} {
if err := session.State.ChannelAdd(ch); err != nil {
t.Fatalf("ChannelAdd(%s) error = %v", ch.ID, err)
}
}
ch := &Channel{BaseChannel: channels.NewBaseChannel(channels.TypeDiscord, nil, nil), session: session}
ch.SetName("discord-main")
ctx, err := ch.ResolveMemoryExtractionContext(context.Background(), &store.ChannelInstanceData{Name: "discord-main"}, store.PendingMessageGroup{
ChannelName: "discord-main",
HistoryKey: "thread-1",
ParentHistoryKey: "parent-1",
})
if err != nil {
t.Fatalf("ResolveMemoryExtractionContext() error = %v", err)
}
want := channelmemory.ExtractionContext{
Platform: channels.TypeDiscord,
ChannelInstance: "discord-main",
HistoryKey: "thread-1",
ChannelID: "thread-1",
ChannelName: "project-thread",
ParentChannelID: "parent-1",
ParentChannelName: "design",
CategoryID: "cat-1",
CategoryName: "Launch",
}
if ctx != want {
t.Fatalf("context = %+v, want %+v", ctx, want)
}
}
func TestTargetAgentIDUsesConfiguredChannelAgent(t *testing.T) {
ch := &Channel{BaseChannel: channels.NewBaseChannel(channels.TypeDiscord, nil, nil)}
ch.SetAgentID("co-assistant")
if got := ch.targetAgentID(nil); got != "co-assistant" {
t.Fatalf("targetAgentID() = %q, want configured channel agent", got)
}
}
func TestTargetAgentIDVoiceOverrideOnlyForAudio(t *testing.T) {
ch := &Channel{
BaseChannel: channels.NewBaseChannel(channels.TypeDiscord, nil, nil),
config: config.DiscordConfig{VoiceAgentID: "voice-agent"},
}
ch.SetAgentID("co-assistant")
if got := ch.targetAgentID([]media.MediaInfo{{Type: media.TypeImage}}); got != "co-assistant" {
t.Fatalf("image targetAgentID() = %q, want channel agent", got)
}
if got := ch.targetAgentID([]media.MediaInfo{{Type: media.TypeVoice}}); got != "voice-agent" {
t.Fatalf("voice targetAgentID() = %q, want voice override", got)
}
}
// --- 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))
}
}