discrawl/internal/syncer/syncer_tail_test.go
2026-05-05 10:07:56 +01:00

265 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package syncer
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"github.com/bwmarrin/discordgo"
"github.com/stretchr/testify/require"
"github.com/openclaw/discrawl/internal/store"
)
func TestNormalizeMessageIncludesRichFields(t *testing.T) {
t.Parallel()
now := time.Now().UTC()
message := &discordgo.Message{
Content: "base",
Attachments: []*discordgo.MessageAttachment{
{Filename: "trace.txt"},
},
Embeds: []*discordgo.MessageEmbed{
{Title: "title", Description: "desc"},
},
ReferencedMessage: &discordgo.Message{Content: "prior"},
Poll: &discordgo.Poll{
Question: discordgo.PollMedia{Text: "question"},
Answers: []discordgo.PollAnswer{
{Media: &discordgo.PollMedia{Text: "answer"}},
},
},
Timestamp: now,
}
content := normalizeMessage(message)
require.Contains(t, content, "base")
require.Contains(t, content, "trace.txt")
require.Contains(t, content, "title")
require.Contains(t, content, "reply:prior")
require.Contains(t, content, "question")
require.Contains(t, content, "answer")
}
func TestNormalizeMessageSanitizesMalformedUnicodeAndWhitespace(t *testing.T) {
t.Parallel()
message := &discordgo.Message{
Content: string([]byte{'h', 'i', 0xff, ' ', 't', 'h', 'e', 'r', 'e'}) + "\u200b",
Attachments: []*discordgo.MessageAttachment{
{Filename: "\u200d.txt"},
},
Embeds: []*discordgo.MessageEmbed{
{Title: " spaced\u00a0out ", Description: "line\u0000break"},
},
ReferencedMessage: &discordgo.Message{Content: "prior reply"},
}
content := normalizeMessage(message)
require.Equal(t, "hi there\nFoo.txt\nspaced out\nlinebreak\nreply:prior reply", content)
require.NotContains(t, content, "\u200b")
require.NotContains(t, content, "\u200d")
}
func TestTailHandlerWritesEvents(t *testing.T) {
t.Parallel()
ctx := context.Background()
s, err := store.Open(ctx, filepath.Join(t.TempDir(), "discrawl.db"))
require.NoError(t, err)
defer func() { _ = s.Close() }()
handler := &tailHandler{store: s}
msg := &discordgo.Message{
ID: "9",
GuildID: "g1",
ChannelID: "c1",
Content: "tail event",
Timestamp: time.Now().UTC(),
Author: &discordgo.User{ID: "u1", Username: "peter"},
}
require.NoError(t, handler.OnMessageCreate(ctx, msg))
require.NoError(t, handler.OnMessageUpdate(ctx, msg))
require.NoError(t, handler.OnMessageDelete(ctx, &discordgo.MessageDelete{Message: &discordgo.Message{
ID: "9",
GuildID: "g1",
ChannelID: "c1",
}}))
require.NoError(t, handler.OnChannelUpsert(ctx, &discordgo.Channel{
ID: "c1",
GuildID: "g1",
Name: "general",
Type: discordgo.ChannelTypeGuildText,
}))
require.NoError(t, handler.OnMemberUpsert(ctx, "g1", &discordgo.Member{
GuildID: "g1",
Nick: "Peter",
User: &discordgo.User{ID: "u1", Username: "peter"},
}))
require.NoError(t, handler.OnMemberDelete(ctx, "g1", "u1"))
status, err := s.Status(context.Background(), "db", "")
require.NoError(t, err)
require.Equal(t, 1, status.ChannelCount)
require.Equal(t, 1, status.MessageCount)
cursor, err := s.GetSyncState(context.Background(), "channel:c1:latest_message_id")
require.NoError(t, err)
require.Equal(t, "9", cursor)
}
func TestHelpers(t *testing.T) {
t.Parallel()
require.Equal(t, "101", maxSnowflake("100", "101"))
require.Equal(t, "abc", maxSnowflake("", "abc"))
require.Equal(t, "abc", maxSnowflake("abc", ""))
require.Equal(t, "zzz", maxSnowflake("abc", "zzz"))
require.Equal(t, "text", channelKind(&discordgo.Channel{Type: discordgo.ChannelTypeGuildText}))
require.Equal(t, "category", channelKind(&discordgo.Channel{Type: discordgo.ChannelTypeGuildCategory}))
require.Equal(t, "thread_private", channelKind(&discordgo.Channel{Type: discordgo.ChannelTypeGuildPrivateThread}))
require.Equal(t, "thread_public", channelKind(&discordgo.Channel{Type: discordgo.ChannelTypeGuildPublicThread}))
require.Equal(t, "thread_announcement", channelKind(&discordgo.Channel{Type: discordgo.ChannelTypeGuildNewsThread}))
require.Equal(t, "announcement", channelKind(&discordgo.Channel{Type: discordgo.ChannelTypeGuildNews}))
require.Equal(t, "forum", channelKind(&discordgo.Channel{Type: discordgo.ChannelTypeGuildForum}))
require.Equal(t, "voice", channelKind(&discordgo.Channel{Type: discordgo.ChannelTypeGuildVoice}))
require.Equal(t, "type_99", channelKind(&discordgo.Channel{Type: discordgo.ChannelType(99)}))
require.Equal(t, discordgo.ChannelTypeGuildCategory, channelTypeFromKind("category"))
require.Equal(t, discordgo.ChannelTypeGuildNews, channelTypeFromKind("announcement"))
require.Equal(t, discordgo.ChannelTypeGuildForum, channelTypeFromKind("forum"))
require.Equal(t, discordgo.ChannelTypeGuildPublicThread, channelTypeFromKind("thread_public"))
require.Equal(t, discordgo.ChannelTypeGuildPrivateThread, channelTypeFromKind("thread_private"))
require.Equal(t, discordgo.ChannelTypeGuildNewsThread, channelTypeFromKind("thread_announcement"))
require.Equal(t, discordgo.ChannelTypeGuildVoice, channelTypeFromKind("voice"))
require.Equal(t, discordgo.ChannelTypeGuildText, channelTypeFromKind("unknown"))
require.True(t, isThreadParent(&discordgo.Channel{Type: discordgo.ChannelTypeGuildForum}))
require.True(t, isThreadParent(&discordgo.Channel{Type: discordgo.ChannelTypeGuildText}))
require.False(t, isThreadParent(&discordgo.Channel{Type: discordgo.ChannelTypeGuildVoice}))
require.True(t, isMessageChannel(&discordgo.Channel{Type: discordgo.ChannelTypeGuildNewsThread}))
require.True(t, isMessageChannel(&discordgo.Channel{Type: discordgo.ChannelTypeGuildPrivateThread}))
require.False(t, isMessageChannel(&discordgo.Channel{Type: discordgo.ChannelTypeGuildCategory}))
require.Len(t, selectGuilds([]*discordgo.UserGuild{{ID: "g1"}, {ID: "g2"}}, []string{"g2"}), 1)
require.Len(t, selectGuilds([]*discordgo.UserGuild{{ID: "g1"}}, nil), 1)
require.Nil(t, makeGuildSet(nil))
record := toChannelRecord(&discordgo.Channel{
ID: "t1",
GuildID: "g1",
ParentID: "c1",
Name: "thread",
Type: discordgo.ChannelTypeGuildPrivateThread,
ThreadMetadata: &discordgo.ThreadMetadata{
Archived: true,
Locked: true,
ArchiveTimestamp: time.Now().UTC(),
},
}, `{}`)
require.True(t, record.IsArchived)
require.True(t, record.IsLocked)
require.True(t, record.IsPrivateThread)
sorted := mapsToSlice(map[string]*discordgo.Channel{
"b": {ID: "b", Position: 2},
"a": {ID: "a", Position: 1},
"c": {ID: "c", Position: 1},
})
require.Equal(t, []string{"a", "c", "b"}, []string{sorted[0].ID, sorted[1].ID, sorted[2].ID})
selected := mapsToSlice(selectStoredChannels([]store.ChannelRow{
{ID: "c2", GuildID: "g1", Kind: "thread_private", Name: "thread", Position: 2, IsArchived: true, IsLocked: true, ArchiveTimestamp: time.Unix(10, 0).UTC()},
{ID: "c1", GuildID: "g1", Kind: "text", Name: "general", Position: 1},
}, makeGuildSet([]string{"c2", "c1"})))
require.Len(t, selected, 2)
require.Equal(t, "c1", selected[0].ID)
require.Nil(t, selected[0].ThreadMetadata)
require.Equal(t, "c2", selected[1].ID)
require.NotNil(t, selected[1].ThreadMetadata)
require.True(t, selected[1].ThreadMetadata.Archived)
handler := &tailHandler{guilds: makeGuildSet([]string{"g1"})}
require.True(t, handler.allowGuild("g1"))
require.False(t, handler.allowGuild("g2"))
require.Empty(t, displayName(nil))
require.Equal(t, "Nick", displayName(&discordgo.Member{Nick: "Nick", User: &discordgo.User{Username: "user"}}))
require.Equal(t, "Global", displayName(&discordgo.Member{User: &discordgo.User{GlobalName: "Global", Username: "user"}}))
require.Equal(t, "user", displayName(&discordgo.Member{User: &discordgo.User{Username: "user"}}))
require.True(t, isMissingAccess(errors.New("HTTP 403 Forbidden")))
require.True(t, isMissingAccess(errors.New("Missing Access")))
require.False(t, isMissingAccess(errors.New("boom")))
require.Equal(t, "missing_access", unavailableReason(errors.New("HTTP 403 Forbidden")))
require.Equal(t, "unknown_channel", unavailableReason(errors.New("HTTP 404 Not Found, {\"message\": \"Unknown Channel\", \"code\": 10003}")))
require.True(t, isUnknownChannel(errors.New("Unknown Channel")))
require.False(t, isUnknownChannel(errors.New("boom")))
require.True(t, isRetryableSyncError(context.Background(), context.DeadlineExceeded))
require.True(t, isRetryableSyncError(context.Background(), errors.New("HTTP 503 Service Unavailable")))
require.True(t, isRetryableSyncError(context.Background(), errors.New("stream error: stream ID 1; INTERNAL_ERROR")))
require.False(t, isRetryableSyncError(context.Background(), context.Canceled))
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
require.False(t, isRetryableSyncError(canceledCtx, context.DeadlineExceeded))
}
func TestRunTail(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := store.Open(ctx, filepath.Join(t.TempDir(), "discrawl.db"))
require.NoError(t, err)
defer func() { _ = s.Close() }()
handled := make(chan struct{}, 1)
client := &fakeClient{tailHandled: handled}
svc := New(client, s, nil)
go func() {
select {
case <-handled:
case <-time.After(time.Second):
}
cancel()
}()
err = svc.RunTail(ctx, nil, 0)
require.True(t, err == nil || errors.Is(err, context.Canceled))
status, err := s.Status(context.Background(), "db", "")
require.NoError(t, err)
require.Equal(t, 1, status.MessageCount)
require.Equal(t, 1, client.tailCalls)
}
func TestRunTailWithRepairLoop(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := store.Open(ctx, filepath.Join(t.TempDir(), "discrawl.db"))
require.NoError(t, err)
defer func() { _ = s.Close() }()
client := &fakeClient{
guilds: []*discordgo.UserGuild{{ID: "g1", Name: "Guild"}},
guildByID: map[string]*discordgo.Guild{
"g1": {ID: "g1", Name: "Guild"},
},
channels: map[string][]*discordgo.Channel{
"g1": {{ID: "c1", GuildID: "g1", Name: "general", Type: discordgo.ChannelTypeGuildText}},
},
messages: map[string][]*discordgo.Message{
"c1": {{ID: "10", GuildID: "g1", ChannelID: "c1", Content: "repair", Timestamp: time.Now().UTC(), Author: &discordgo.User{ID: "u1", Username: "user"}}},
},
}
svc := New(client, s, nil)
go func() {
time.Sleep(40 * time.Millisecond)
cancel()
}()
err = svc.RunTail(ctx, []string{"g1"}, 10*time.Millisecond)
require.True(t, err == nil || errors.Is(err, context.Canceled))
status, err := s.Status(context.Background(), "db", "")
require.NoError(t, err)
require.GreaterOrEqual(t, status.MessageCount, 1)
}