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

954 lines
28 KiB
Go

package syncer
import (
"context"
"errors"
"path/filepath"
"sync"
"testing"
"time"
"github.com/bwmarrin/discordgo"
"github.com/stretchr/testify/require"
discordclient "github.com/openclaw/discrawl/internal/discord"
"github.com/openclaw/discrawl/internal/store"
)
type fakeClient struct {
guilds []*discordgo.UserGuild
guildByID map[string]*discordgo.Guild
channels map[string][]*discordgo.Channel
activeThreads map[string][]*discordgo.Channel
guildThreads map[string][]*discordgo.Channel
threadErrors map[string]error
guildThreadErrs map[string]error
publicArchived map[string][]*discordgo.Channel
privateArchive map[string][]*discordgo.Channel
archivedErrors map[string]error
archivedCalls map[string]int
members map[string][]*discordgo.Member
messages map[string][]*discordgo.Message
messageErrors map[string]error
messageCalls map[string]int
beforeErrors map[string]map[string]error
memberDelay time.Duration
tailCalls int
tailHandled chan struct{}
messageDelay time.Duration
guildChanCalls int
threadCalls int
guildThreadCalls int
memberCalls int
mu sync.Mutex
inFlight int
maxInFlight int
}
func (f *fakeClient) Self(context.Context) (*discordgo.User, error) {
return &discordgo.User{ID: "bot"}, nil
}
func (f *fakeClient) Guilds(context.Context) ([]*discordgo.UserGuild, error) {
return f.guilds, nil
}
func (f *fakeClient) Guild(_ context.Context, guildID string) (*discordgo.Guild, error) {
return f.guildByID[guildID], nil
}
func (f *fakeClient) GuildChannels(_ context.Context, guildID string) ([]*discordgo.Channel, error) {
f.guildChanCalls++
return f.channels[guildID], nil
}
func (f *fakeClient) ThreadsActive(_ context.Context, channelID string) ([]*discordgo.Channel, error) {
f.threadCalls++
if err := f.threadErrors[channelID]; err != nil {
return nil, err
}
return f.activeThreads[channelID], nil
}
func (f *fakeClient) GuildThreadsActive(_ context.Context, guildID string) ([]*discordgo.Channel, error) {
f.guildThreadCalls++
if err := f.guildThreadErrs[guildID]; err != nil {
return nil, err
}
if f.guildThreads != nil {
return f.guildThreads[guildID], nil
}
var out []*discordgo.Channel
for _, threads := range f.activeThreads {
out = append(out, threads...)
}
return out, nil
}
func (f *fakeClient) ThreadsArchived(_ context.Context, channelID string, private bool) ([]*discordgo.Channel, error) {
f.threadCalls++
if f.archivedCalls == nil {
f.archivedCalls = make(map[string]int)
}
f.archivedCalls[channelID]++
if err := f.archivedErrors[channelID]; err != nil {
return nil, err
}
if private {
return f.privateArchive[channelID], nil
}
return f.publicArchived[channelID], nil
}
func (f *fakeClient) GuildMembers(ctx context.Context, guildID string) ([]*discordgo.Member, error) {
f.memberCalls++
if f.memberDelay > 0 {
timer := time.NewTimer(f.memberDelay)
defer timer.Stop()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-timer.C:
}
}
return f.members[guildID], nil
}
func (f *fakeClient) ChannelMessages(ctx context.Context, channelID string, limit int, beforeID, afterID string) ([]*discordgo.Message, error) {
f.mu.Lock()
if f.messageCalls == nil {
f.messageCalls = make(map[string]int)
}
f.messageCalls[channelID]++
f.mu.Unlock()
if err := f.messageErrors[channelID]; err != nil {
return nil, err
}
if err := f.beforeErrors[channelID][beforeID]; err != nil {
return nil, err
}
if f.messageDelay > 0 {
f.mu.Lock()
f.inFlight++
if f.inFlight > f.maxInFlight {
f.maxInFlight = f.inFlight
}
f.mu.Unlock()
timer := time.NewTimer(f.messageDelay)
select {
case <-ctx.Done():
timer.Stop()
f.mu.Lock()
f.inFlight--
f.mu.Unlock()
return nil, ctx.Err()
case <-timer.C:
}
f.mu.Lock()
f.inFlight--
f.mu.Unlock()
}
all := f.messages[channelID]
if afterID != "" {
var filtered []*discordgo.Message
for _, msg := range all {
if msg.ID > afterID {
filtered = append(filtered, msg)
}
}
return filtered, nil
}
if beforeID == "" {
if len(all) <= limit {
return all, nil
}
return all[:limit], nil
}
var filtered []*discordgo.Message
for _, msg := range all {
if msg.ID < beforeID {
filtered = append(filtered, msg)
}
}
if len(filtered) <= limit {
return filtered, nil
}
return filtered[:limit], nil
}
func (f *fakeClient) ChannelMessage(_ context.Context, channelID, messageID string) (*discordgo.Message, error) {
for _, msg := range f.messages[channelID] {
if msg.ID == messageID {
return msg, nil
}
}
return nil, nil
}
func (f *fakeClient) Tail(ctx context.Context, handler discordclient.EventHandler) error {
f.tailCalls++
msg := &discordgo.Message{
ID: "m3",
GuildID: "g1",
ChannelID: "c1",
Content: "tail event",
Timestamp: time.Now().UTC(),
Author: &discordgo.User{ID: "u1", Username: "peter"},
}
if err := handler.OnMessageCreate(ctx, msg); err != nil {
return err
}
if f.tailHandled != nil {
select {
case f.tailHandled <- struct{}{}:
default:
}
}
<-ctx.Done()
return nil
}
func TestSyncFullAndIncremental(t *testing.T) {
ctx := context.Background()
dbPath := filepath.Join(t.TempDir(), "discrawl.db")
s, err := store.Open(ctx, dbPath)
require.NoError(t, err)
defer func() { _ = s.Close() }()
client := &fakeClient{
guilds: []*discordgo.UserGuild{{ID: "g1", Name: "Guild One"}},
guildByID: map[string]*discordgo.Guild{
"g1": {ID: "g1", Name: "Guild One"},
},
channels: map[string][]*discordgo.Channel{
"g1": {
{ID: "c1", GuildID: "g1", Name: "general", Type: discordgo.ChannelTypeGuildText},
},
},
activeThreads: map[string][]*discordgo.Channel{
"c1": {{ID: "t1", GuildID: "g1", ParentID: "c1", Name: "thread", Type: discordgo.ChannelTypeGuildPublicThread}},
},
members: map[string][]*discordgo.Member{
"g1": {{
GuildID: "g1",
Nick: "Peter",
User: &discordgo.User{ID: "u1", Username: "peter"},
}},
},
messages: map[string][]*discordgo.Message{
"c1": {{
ID: "100",
GuildID: "g1",
ChannelID: "c1",
Content: "panic locked database",
Timestamp: time.Now().UTC(),
Author: &discordgo.User{ID: "u1", Username: "peter"},
}},
"t1": {{
ID: "200",
GuildID: "g1",
ChannelID: "t1",
Content: "thread post",
Timestamp: time.Now().UTC(),
Author: &discordgo.User{ID: "u1", Username: "peter"},
}},
},
}
svc := New(client, s, nil)
discovered, err := svc.DiscoverGuilds(ctx)
require.NoError(t, err)
require.Len(t, discovered, 1)
stats, err := svc.Sync(ctx, SyncOptions{Full: true})
require.NoError(t, err)
require.Equal(t, 1, stats.Guilds)
require.Equal(t, 2, stats.Channels)
require.Equal(t, 1, stats.Threads)
require.Equal(t, 1, stats.Members)
require.Equal(t, 2, stats.Messages)
results, err := s.SearchMessages(ctx, store.SearchOptions{Query: "panic"})
require.NoError(t, err)
require.Len(t, results, 1)
client.messages["c1"] = append(client.messages["c1"], &discordgo.Message{
ID: "101",
GuildID: "g1",
ChannelID: "c1",
Content: "new message",
Timestamp: time.Now().UTC(),
Author: &discordgo.User{ID: "u1", Username: "peter"},
})
stats, err = svc.Sync(ctx, SyncOptions{Full: false})
require.NoError(t, err)
require.Equal(t, 1, stats.Messages)
}
func TestSyncUsesConfiguredConcurrency(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() }()
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: "one", Type: discordgo.ChannelTypeGuildText},
{ID: "c2", GuildID: "g1", Name: "two", Type: discordgo.ChannelTypeGuildText},
},
},
messages: map[string][]*discordgo.Message{
"c1": {{ID: "10", GuildID: "g1", ChannelID: "c1", Content: "one", Timestamp: time.Now().UTC(), Author: &discordgo.User{ID: "u1", Username: "user"}}},
"c2": {{ID: "20", GuildID: "g1", ChannelID: "c2", Content: "two", Timestamp: time.Now().UTC(), Author: &discordgo.User{ID: "u1", Username: "user"}}},
},
messageDelay: 40 * time.Millisecond,
}
svc := New(client, s, nil)
stats, err := svc.Sync(ctx, SyncOptions{Full: true, Concurrency: 2})
require.NoError(t, err)
require.Equal(t, 2, stats.Messages)
client.mu.Lock()
maxInFlight := client.maxInFlight
client.mu.Unlock()
require.GreaterOrEqual(t, maxInFlight, 2)
}
func TestSyncMemberRefreshTimeoutStillMarksSuccess(t *testing.T) {
t.Parallel()
ctx := context.Background()
dbPath := filepath.Join(t.TempDir(), "discrawl.db")
s, err := store.Open(ctx, dbPath)
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: "100",
GuildID: "g1",
ChannelID: "c1",
Content: "hello",
Timestamp: time.Now().UTC(),
Author: &discordgo.User{ID: "u1", Username: "peter"},
}},
},
memberDelay: 100 * time.Millisecond,
}
svc := New(client, s, nil)
svc.memberRefreshTimeout = 10 * time.Millisecond
stats, err := svc.Sync(ctx, SyncOptions{Full: true})
require.NoError(t, err)
require.Equal(t, 0, stats.Members)
require.Equal(t, 1, stats.Messages)
require.Equal(t, 1, client.memberCalls)
lastSync, err := s.GetSyncState(ctx, "sync:last_success")
require.NoError(t, err)
require.NotEmpty(t, lastSync)
}
func TestSyncSkipsMemberRefreshWhenExistingSnapshotPresent(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() }()
require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild", RawJSON: `{}`}))
require.NoError(t, s.UpsertMember(ctx, store.MemberRecord{
GuildID: "g1",
UserID: "u1",
Username: "user",
DisplayName: "User",
RoleIDsJSON: "[]",
RawJSON: `{"user":{"id":"u1"}}`,
}))
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: "100",
GuildID: "g1",
ChannelID: "c1",
Content: "hello",
Timestamp: time.Now().UTC(),
Author: &discordgo.User{ID: "u1", Username: "user"},
}},
},
}
svc := New(client, s, nil)
stats, err := svc.Sync(ctx, SyncOptions{Full: true})
require.NoError(t, err)
require.Equal(t, 0, stats.Members)
require.Zero(t, client.memberCalls)
lastSuccess, err := s.GetSyncState(ctx, guildMemberSyncSuccessScope("g1"))
require.NoError(t, err)
require.NotEmpty(t, lastSuccess)
}
func TestSyncSkipMembersFlagSkipsMemberRefresh(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() }()
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},
},
},
members: map[string][]*discordgo.Member{
"g1": {{
User: &discordgo.User{ID: "u1", Username: "user"},
}},
},
messages: map[string][]*discordgo.Message{
"c1": {{
ID: "100",
GuildID: "g1",
ChannelID: "c1",
Content: "hello",
Timestamp: time.Now().UTC(),
Author: &discordgo.User{ID: "u1", Username: "user"},
}},
},
}
svc := New(client, s, nil)
stats, err := svc.Sync(ctx, SyncOptions{SkipMembers: true})
require.NoError(t, err)
require.Equal(t, 0, stats.Members)
require.Zero(t, client.memberCalls)
}
func TestSyncLatestOnlySkipsChannelsWithoutLatestCursor(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() }()
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: "empty", Type: discordgo.ChannelTypeGuildText},
},
},
messages: map[string][]*discordgo.Message{
"c1": {{
ID: "100",
GuildID: "g1",
ChannelID: "c1",
Content: "would bootstrap without latest-only",
Timestamp: time.Now().UTC(),
Author: &discordgo.User{ID: "u1", Username: "user"},
}},
},
}
svc := New(client, s, nil)
stats, err := svc.Sync(ctx, SyncOptions{LatestOnly: true})
require.NoError(t, err)
require.Zero(t, stats.Messages)
require.Zero(t, client.messageCalls["c1"])
}
func TestSyncLatestOnlySkipsUnchangedIncompleteChannel(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() }()
require.NoError(t, s.SetSyncState(ctx, channelLatestScope("c1"), "200"))
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, LastMessageID: "200"},
},
},
}
svc := New(client, s, nil)
stats, err := svc.Sync(ctx, SyncOptions{LatestOnly: true})
require.NoError(t, err)
require.Zero(t, stats.Messages)
require.Zero(t, client.messageCalls["c1"])
}
func TestSyncLatestOnlyUsesIncrementalCatalog(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() }()
require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild", RawJSON: `{}`}))
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{
ID: "archived",
GuildID: "g1",
ParentID: "c1",
Kind: "thread_public",
Name: "archived-thread",
RawJSON: `{"id":"archived"}`,
}))
require.NoError(t, s.SetSyncState(ctx, channelLatestScope("c1"), "100"))
require.NoError(t, s.SetSyncState(ctx, channelLatestScope("active"), "200"))
require.NoError(t, s.SetSyncState(ctx, channelLatestScope("archived"), "300"))
now := time.Now().UTC()
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,
LastMessageID: "100",
}},
},
guildThreads: map[string][]*discordgo.Channel{
"g1": {{
ID: "active",
GuildID: "g1",
ParentID: "c1",
Name: "active-thread",
Type: discordgo.ChannelTypeGuildPublicThread,
LastMessageID: "201",
}},
},
messages: map[string][]*discordgo.Message{
"active": {{
ID: "201",
GuildID: "g1",
ChannelID: "active",
Content: "new active thread message",
Timestamp: now,
Author: &discordgo.User{ID: "u1", Username: "user"},
}},
},
}
svc := New(client, s, nil)
stats, err := svc.Sync(ctx, SyncOptions{LatestOnly: true, GuildIDs: []string{"g1"}, SkipMembers: true})
require.NoError(t, err)
require.Equal(t, 1, stats.Messages)
require.Equal(t, 1, client.guildThreadCalls)
require.Zero(t, client.threadCalls)
require.Zero(t, client.messageCalls["archived"])
require.Equal(t, 1, client.messageCalls["active"])
}
func TestSyncFullAutoBatchesIncompleteStoredChannels(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() }()
require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild", RawJSON: `{}`}))
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{
ID: "t1",
GuildID: "g1",
ParentID: "f1",
Kind: "thread_public",
Name: "thread-1",
RawJSON: `{"id":"t1"}`,
}))
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{
ID: "t2",
GuildID: "g1",
ParentID: "f1",
Kind: "thread_public",
Name: "thread-2",
RawJSON: `{"id":"t2"}`,
}))
now := time.Now().UTC()
client := &fakeClient{
guilds: []*discordgo.UserGuild{{ID: "g1", Name: "Guild"}},
guildByID: map[string]*discordgo.Guild{
"g1": {ID: "g1", Name: "Guild"},
},
members: map[string][]*discordgo.Member{
"g1": {{
User: &discordgo.User{ID: "u1", Username: "user"},
}},
},
messages: map[string][]*discordgo.Message{
"t1": {{ID: "10", GuildID: "g1", ChannelID: "t1", Content: "first", Timestamp: now, Author: &discordgo.User{ID: "u1", Username: "user"}}},
"t2": {{ID: "20", GuildID: "g1", ChannelID: "t2", Content: "second", Timestamp: now, Author: &discordgo.User{ID: "u1", Username: "user"}}},
},
}
svc := New(client, s, nil)
stats, err := svc.Sync(ctx, SyncOptions{Full: true, GuildIDs: []string{"g1"}})
require.NoError(t, err)
require.Equal(t, 2, stats.Messages)
require.Equal(t, 1, stats.Members)
require.Zero(t, client.guildChanCalls)
require.Zero(t, client.threadCalls)
require.Equal(t, 1, client.memberCalls)
require.Equal(t, 1, client.messageCalls["t1"])
require.Equal(t, 1, client.messageCalls["t2"])
}
func TestSyncFullAutoBatchHonorsSkipMembers(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() }()
require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild", RawJSON: `{}`}))
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{
ID: "c1",
GuildID: "g1",
Kind: "text",
Name: "general",
RawJSON: `{"id":"c1"}`,
}))
now := time.Now().UTC()
client := &fakeClient{
guilds: []*discordgo.UserGuild{{ID: "g1", Name: "Guild"}},
guildByID: map[string]*discordgo.Guild{
"g1": {ID: "g1", Name: "Guild"},
},
members: map[string][]*discordgo.Member{
"g1": {{
User: &discordgo.User{ID: "u1", Username: "user"},
}},
},
messages: map[string][]*discordgo.Message{
"c1": {{ID: "10", GuildID: "g1", ChannelID: "c1", Content: "first", Timestamp: now, Author: &discordgo.User{ID: "u1", Username: "user"}}},
},
}
svc := New(client, s, nil)
stats, err := svc.Sync(ctx, SyncOptions{Full: true, GuildIDs: []string{"g1"}, SkipMembers: true})
require.NoError(t, err)
require.Equal(t, 1, stats.Messages)
require.Zero(t, stats.Members)
require.Zero(t, client.memberCalls)
}
func TestSyncFullUsesIncrementalCatalogWhenArchiveAlreadyComplete(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() }()
require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild", RawJSON: `{}`}))
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{
ID: "c1",
GuildID: "g1",
Kind: "text",
Name: "general",
RawJSON: `{"id":"c1"}`,
}))
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{
ID: "t1",
GuildID: "g1",
ParentID: "c1",
Kind: "thread_public",
Name: "archived-thread",
RawJSON: `{"id":"t1"}`,
}))
require.NoError(t, s.SetSyncState(ctx, channelLatestScope("c1"), "200"))
require.NoError(t, s.SetSyncState(ctx, channelHistoryCompleteScope("c1"), "1"))
require.NoError(t, s.SetSyncState(ctx, channelLatestScope("t1"), "300"))
require.NoError(t, s.SetSyncState(ctx, channelHistoryCompleteScope("t1"), "1"))
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,
LastMessageID: "200",
}},
},
}
svc := New(client, s, nil)
stats, err := svc.Sync(ctx, SyncOptions{Full: true, GuildIDs: []string{"g1"}})
require.NoError(t, err)
require.Zero(t, stats.Messages)
require.Equal(t, 1, stats.Channels)
require.Zero(t, client.messageCalls["t1"])
require.Zero(t, client.threadCalls)
require.Equal(t, 1, client.guildThreadCalls)
}
func TestSetAttachmentTextEnabled(t *testing.T) {
t.Parallel()
svc := New(&fakeClient{}, nil, nil)
require.True(t, svc.attachmentTextEnabled)
svc.SetAttachmentTextEnabled(false)
require.False(t, svc.attachmentTextEnabled)
}
func TestSyncChannelSubsetUsesStoredMetadata(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() }()
require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild", RawJSON: `{}`}))
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{
ID: "c1",
GuildID: "g1",
Kind: "text",
Name: "general",
RawJSON: `{"id":"c1"}`,
}))
client := &fakeClient{
guilds: []*discordgo.UserGuild{{ID: "g1", Name: "Guild"}},
guildByID: map[string]*discordgo.Guild{
"g1": {ID: "g1", Name: "Guild"},
},
messages: map[string][]*discordgo.Message{
"c1": {{
ID: "10",
GuildID: "g1",
ChannelID: "c1",
Content: "hello",
Timestamp: time.Now().UTC(),
Author: &discordgo.User{ID: "u1", Username: "user"},
}},
},
}
svc := New(client, s, nil)
stats, err := svc.Sync(ctx, SyncOptions{Full: true, GuildIDs: []string{"g1"}, ChannelIDs: []string{"c1"}})
require.NoError(t, err)
require.Equal(t, 1, stats.Messages)
require.Zero(t, client.guildChanCalls)
require.Zero(t, client.threadCalls)
require.Zero(t, client.memberCalls)
cursor, err := s.GetSyncState(ctx, "channel:c1:latest_message_id")
require.NoError(t, err)
require.Equal(t, "10", cursor)
}
func TestSyncSkipsMissingAccessChannels(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() }()
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},
{ID: "c2", GuildID: "g1", Name: "private", Type: discordgo.ChannelTypeGuildText},
},
},
messages: map[string][]*discordgo.Message{
"c1": {{ID: "10", GuildID: "g1", ChannelID: "c1", Content: "ok", Timestamp: time.Now().UTC(), Author: &discordgo.User{ID: "u1", Username: "user"}}},
},
messageErrors: map[string]error{
"c2": errors.New("HTTP 403 Forbidden, {\"message\": \"Missing Access\", \"code\": 50001}"),
},
}
svc := New(client, s, nil)
stats, err := svc.Sync(ctx, SyncOptions{Full: true, Concurrency: 2})
require.NoError(t, err)
require.Equal(t, 1, stats.Messages)
cursor, err := s.GetSyncState(ctx, "channel:c2:unavailable")
require.NoError(t, err)
require.Equal(t, "missing_access", cursor)
}
func TestSyncSkipsUnknownChannels(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() }()
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},
{ID: "c2", GuildID: "g1", Name: "gone", Type: discordgo.ChannelTypeGuildText},
},
},
messages: map[string][]*discordgo.Message{
"c1": {{ID: "10", GuildID: "g1", ChannelID: "c1", Content: "ok", Timestamp: time.Now().UTC(), Author: &discordgo.User{ID: "u1", Username: "user"}}},
},
messageErrors: map[string]error{
"c2": errors.New("HTTP 404 Not Found, {\"message\": \"Unknown Channel\", \"code\": 10003}"),
},
}
svc := New(client, s, nil)
stats, err := svc.Sync(ctx, SyncOptions{Full: true, Concurrency: 2})
require.NoError(t, err)
require.Equal(t, 1, stats.Messages)
cursor, err := s.GetSyncState(ctx, "channel:c2:unavailable")
require.NoError(t, err)
require.Equal(t, "unknown_channel", cursor)
}
func TestSyncSkipsRetryableChannelErrors(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() }()
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},
{ID: "c2", GuildID: "g1", Name: "flaky", Type: discordgo.ChannelTypeGuildText},
},
},
messages: map[string][]*discordgo.Message{
"c1": {{ID: "10", GuildID: "g1", ChannelID: "c1", Content: "ok", Timestamp: time.Now().UTC(), Author: &discordgo.User{ID: "u1", Username: "user"}}},
},
messageErrors: map[string]error{
"c2": context.DeadlineExceeded,
},
}
svc := New(client, s, nil)
stats, err := svc.Sync(ctx, SyncOptions{Full: true, Concurrency: 2})
require.NoError(t, err)
require.Equal(t, 1, stats.Messages)
cursor, err := s.GetSyncState(ctx, "channel:c2:latest_message_id")
require.NoError(t, err)
require.Empty(t, cursor)
unavailable, err := s.GetSyncState(ctx, "channel:c2:unavailable")
require.NoError(t, err)
require.Empty(t, unavailable)
}
func TestSyncClearsUnavailableMarkerAfterSuccessfulRead(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() }()
require.NoError(t, s.SetSyncState(ctx, "channel:c1:unavailable", "missing_access"))
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: "ok", Timestamp: time.Now().UTC(), Author: &discordgo.User{ID: "u1", Username: "user"}}},
},
}
svc := New(client, s, nil)
stats, err := svc.Sync(ctx, SyncOptions{Full: true})
require.NoError(t, err)
require.Equal(t, 1, stats.Messages)
unavailable, err := s.GetSyncState(ctx, "channel:c1:unavailable")
require.NoError(t, err)
require.Empty(t, unavailable)
}