package discord import ( "context" "sync" "testing" "strconv" "plumber/internal/events" "plumber/internal/store" ) type recordedSend struct { Kind string ChannelID string Name string Msg Message } type fakeAPI struct { mu sync.Mutex sends []recordedSend edits []recordedSend next int failSend error } func (f *fakeAPI) SendToChannel(_ context.Context, channelID string, msg Message) (string, error) { return f.record("channel", channelID, "", msg) } func (f *fakeAPI) StartThread(_ context.Context, channelID, messageID, name string) (string, error) { f.mu.Lock() defer f.mu.Unlock() f.next++ f.sends = append(f.sends, recordedSend{ Kind: "thread", ChannelID: channelID, Name: name, Msg: Message{ThreadName: name, URL: messageID}, }) return "thread-" + messageID, nil } func (f *fakeAPI) SendToThread(_ context.Context, threadID string, msg Message) (string, error) { return f.record("thread-msg", threadID, "", msg) } func (f *fakeAPI) Edit(_ context.Context, channelID, messageID string, msg Message) error { f.mu.Lock() defer f.mu.Unlock() f.edits = append(f.edits, recordedSend{ Kind: "edit", ChannelID: channelID, Name: messageID, Msg: msg, }) return nil } func (f *fakeAPI) Close() error { return nil } func (f *fakeAPI) record(kind, channelID, name string, msg Message) (string, error) { f.mu.Lock() defer f.mu.Unlock() if f.failSend != nil { return "", f.failSend } f.next++ id := "msg-" + strconv.Itoa(f.next) f.sends = append(f.sends, recordedSend{Kind: kind, ChannelID: channelID, Name: name, Msg: msg}) return id, nil } func TestOutboundRootReplyAndEdit(t *testing.T) { t.Parallel() links := newMemoryLinks() api := &fakeAPI{} bot := New("channel-1", links, api) ctx := context.Background() root := events.PostEvent{ PostID: "root-1", RootID: "root-1", Title: "Leaky sink", Body: "Water under the cabinet.", City: "Oakland", AuthorName: "sam", Permalink: "https://www.askaplumberfirst.com/questions/root-1#post-root-1", Images: []events.Image{{URL: "https://cdn.example/a.jpg"}, {URL: "https://cdn.example/b.jpg"}}, } bot.Handle(ctx, events.PostCreated{PostEvent: root}) if len(api.sends) != 2 || api.sends[0].Kind != "channel" || api.sends[1].Kind != "thread" { t.Fatalf("root sends = %+v", api.sends) } if api.sends[0].ChannelID != "channel-1" || api.sends[1].Name != "Leaky sink" { t.Fatalf("root routing = %+v", api.sends) } if got := api.sends[0].Msg.ImageURLs; len(got) != 2 || got[0] != "https://cdn.example/a.jpg" { t.Fatalf("root images = %v", got) } link, err := links.GetByPostID(ctx, "root-1") if err != nil || link.MessageID != "msg-1" || link.ThreadID != "thread-msg-1" { t.Fatalf("root link = %+v, %v", link, err) } reply := events.PostEvent{ PostID: "reply-1", RootID: "root-1", ParentID: "root-1", Body: "Replace the cartridge.", AuthorName: "plumber", Permalink: "https://www.askaplumberfirst.com/questions/root-1#post-reply-1", } bot.Handle(ctx, events.PostCreated{PostEvent: reply}) if len(api.sends) != 3 || api.sends[2].Kind != "thread-msg" || api.sends[2].ChannelID != "thread-msg-1" { t.Fatalf("reply sends = %+v", api.sends) } replyLink, err := links.GetByPostID(ctx, "reply-1") if err != nil || replyLink.MessageID != "msg-3" || replyLink.ThreadID != "" { t.Fatalf("reply link = %+v, %v", replyLink, err) } root.Body = "Updated leak." bot.Handle(ctx, events.PostUpdated{PostEvent: root}) if len(api.edits) != 1 || api.edits[0].ChannelID != "channel-1" || api.edits[0].Name != "msg-1" { t.Fatalf("root edit = %+v", api.edits) } if api.edits[0].Msg.Description != "Updated leak." { t.Fatalf("root edit body = %+v", api.edits[0].Msg) } reply.Body = "Use a ceramic cartridge." bot.Handle(ctx, events.PostUpdated{PostEvent: reply}) if len(api.edits) != 2 || api.edits[1].ChannelID != "thread-msg-1" || api.edits[1].Name != "msg-3" { t.Fatalf("reply edit = %+v", api.edits) } } func TestOutboundSkipsReplyWithoutRootLink(t *testing.T) { t.Parallel() api := &fakeAPI{} bot := New("channel-1", newMemoryLinks(), api) bot.Handle(context.Background(), events.PostCreated{PostEvent: events.PostEvent{ PostID: "reply-1", RootID: "missing", ParentID: "missing", Body: "Orphan reply", }}) if len(api.sends) != 0 { t.Fatalf("unexpected sends %+v", api.sends) } } func TestOutboundUpdateWithoutLinkCreates(t *testing.T) { t.Parallel() links := newMemoryLinks() api := &fakeAPI{} bot := New("channel-1", links, api) bot.Handle(context.Background(), events.PostUpdated{PostEvent: events.PostEvent{ PostID: "root-2", RootID: "root-2", Title: "Late question", Body: "Created while Discord was down.", }}) link, err := links.GetByPostID(context.Background(), "root-2") if err != nil || link.ThreadID == "" || len(api.sends) != 2 { t.Fatalf("late create link=%+v sends=%+v err=%v", link, api.sends, err) } } func TestFormatMessage(t *testing.T) { t.Parallel() got := formatMessage(events.PostEvent{ Title: "Leaky sink", Body: "It drips.", City: "Oakland", AuthorName: "sam", Permalink: "https://example.com/q", Images: []events.Image{{URL: "https://cdn.example/a.jpg", Description: "ignored"}}, }) if got.Title != "Leaky sink" || got.Description != "It drips." || got.City != "Oakland" || got.Author != "sam" || got.URL != "https://example.com/q" || got.ThreadName != "Leaky sink" || len(got.ImageURLs) != 1 { t.Fatalf("format = %+v", got) } reply := formatMessage(events.PostEvent{Body: "Thanks", AuthorName: ""}) if reply.Title != "Reply" || reply.Author != "Someone" || reply.ThreadName != "Question" { t.Fatalf("reply format = %+v", reply) } } func TestFromEnvDisabled(t *testing.T) { t.Setenv("DISCORD_BOT_TOKEN", "") t.Setenv("DISCORD_CHANNEL_ID", "") bot, err := FromEnv(newMemoryLinks(), nil) if err != nil || bot != nil { t.Fatalf("disabled FromEnv = (%v, %v)", bot, err) } } func TestFromEnvRequiresBoth(t *testing.T) { t.Setenv("DISCORD_BOT_TOKEN", "token") t.Setenv("DISCORD_CHANNEL_ID", "") if _, err := FromEnv(newMemoryLinks(), nil); err == nil { t.Fatal("expected error when channel is missing") } t.Setenv("DISCORD_BOT_TOKEN", "") t.Setenv("DISCORD_CHANNEL_ID", "channel") if _, err := FromEnv(newMemoryLinks(), nil); err == nil { t.Fatal("expected error when token is missing") } } func TestMemoryLinkUpsertKeepsThread(t *testing.T) { t.Parallel() links := newMemoryLinks() ctx := context.Background() if err := links.Upsert(ctx, store.DiscordLink{PostID: "p", MessageID: "m1", ThreadID: "t1"}); err != nil { t.Fatal(err) } if err := links.Upsert(ctx, store.DiscordLink{PostID: "p", MessageID: "m2"}); err != nil { t.Fatal(err) } got, err := links.GetByPostID(ctx, "p") if err != nil || got.MessageID != "m2" || got.ThreadID != "t1" { t.Fatalf("upsert keep thread = %+v, %v", got, err) } if _, err := links.GetByMessageID(ctx, "m2"); err != nil { t.Fatal(err) } if _, err := links.GetRootByThreadID(ctx, "t1"); err != nil { t.Fatal(err) } }