Add Discord outbound posting.
CI / test (pull_request) Successful in 6m20s

This commit is contained in:
2026-08-29 01:13:44 -07:00
parent b8f1d88d6e
commit 06fcc16c02
16 changed files with 1037 additions and 2 deletions
+247
View File
@@ -0,0 +1,247 @@
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)
}
}