package discord import ( "context" "testing" "time" "plumber/internal/events" "plumber/internal/mail" "plumber/internal/pacific" "plumber/internal/store" ) func TestParseAdminMap(t *testing.T) { t.Parallel() got := parseAdminMap(" 123:Plumber ,456:other,bad, :empty,789: ") if got["123"] != "plumber" || got["456"] != "other" || len(got) != 2 { t.Fatalf("parseAdminMap = %#v", got) } } func TestInboundCreatesSiteReply(t *testing.T) { t.Parallel() mem, homeowner, admin := seedInboundUsers(t) links := newMemoryLinks() api := &fakeAPI{} mailer := &mail.Recording{} bot := inboundTestBot(mem, links, api, mailer, admin.Username) bus := events.New() defer bus.Close() bus.Subscribe(bot.Handle) root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1") bot.handleInbound(inboundMessage{ ID: "d-reply-1", ChannelID: "thread-1", GuildID: "guild-1", AuthorID: "snow-admin", Content: "Replace the cartridge.", }) thread, err := mem.GetPostThread(context.Background(), root.ID) if err != nil || len(thread.Replies) != 1 { t.Fatalf("thread = %+v, %v", thread, err) } reply := thread.Replies[0] if reply.AuthorID != admin.ID || reply.Body != "Replace the cartridge." || reply.ParentID == nil || *reply.ParentID != root.ID { t.Fatalf("reply = %+v", reply) } link, err := links.GetByPostID(context.Background(), reply.ID) if err != nil || link.MessageID != "d-reply-1" || link.ThreadID != "" { t.Fatalf("inbound link = %+v, %v", link, err) } time.Sleep(20 * time.Millisecond) if len(api.sends) != 0 || len(api.edits) != 0 { t.Fatalf("inbound echoed to Discord: sends=%+v edits=%+v", api.sends, api.edits) } msgs := waitForMail(t, mailer, 1) if msgs[0].ToEmail != homeowner.Email || msgs[0].ReplyID != reply.ID || msgs[0].RootID != root.ID { t.Fatalf("mail = %+v", msgs[0]) } } func TestInboundParentsFromReference(t *testing.T) { t.Parallel() mem, homeowner, admin := seedInboundUsers(t) links := newMemoryLinks() bot := inboundTestBot(mem, links, &fakeAPI{}, &mail.Recording{}, admin.Username) root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1") plumberReply := &store.Post{ParentID: &root.ID, AuthorID: admin.ID, Body: "First look."} if err := mem.CreatePost(context.Background(), plumberReply); err != nil { t.Fatal(err) } if err := links.Upsert(context.Background(), store.DiscordLink{ PostID: plumberReply.ID, MessageID: "d-plumber-1", }); err != nil { t.Fatal(err) } bot.handleInbound(inboundMessage{ ID: "d-nested", ChannelID: "thread-1", GuildID: "guild-1", AuthorID: "snow-admin", Content: "More detail.", ReferencedMessageID: "d-plumber-1", }) thread, err := mem.GetPostThread(context.Background(), root.ID) if err != nil || len(thread.Replies) != 1 || len(thread.Replies[0].Replies) != 1 { t.Fatalf("thread = %+v, %v", thread, err) } nested := thread.Replies[0].Replies[0] if nested.ParentID == nil || *nested.ParentID != plumberReply.ID { t.Fatalf("nested parent = %+v", nested) } } func TestInboundIgnoresAllowlistHiddenAndEchoSources(t *testing.T) { t.Parallel() mem, homeowner, admin := seedInboundUsers(t) links := newMemoryLinks() api := &fakeAPI{} bot := inboundTestBot(mem, links, api, mail.Nop{}, admin.Username) root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1") hidden := &store.Post{ AuthorID: homeowner.ID, Title: "Hidden", Body: "No.", PostDate: pacific.Today(), PostState: store.PostStateHidden, } if err := mem.CreatePost(context.Background(), hidden); err != nil { t.Fatal(err) } if err := links.Upsert(context.Background(), store.DiscordLink{ PostID: hidden.ID, MessageID: "d-hidden", ThreadID: "thread-hidden", }); err != nil { t.Fatal(err) } cases := []inboundMessage{ {ID: "bot", ChannelID: "thread-1", GuildID: "g", AuthorID: "snow-admin", Content: "x", Bot: true}, {ID: "dm", ChannelID: "thread-1", AuthorID: "snow-admin", Content: "x"}, {ID: "self", ChannelID: "thread-1", GuildID: "g", AuthorID: "bot-1", Content: "x"}, {ID: "stranger", ChannelID: "thread-1", GuildID: "g", AuthorID: "snow-other", Content: "x"}, {ID: "elsewhere", ChannelID: "other-thread", GuildID: "g", AuthorID: "snow-admin", Content: "x"}, {ID: "empty", ChannelID: "thread-1", GuildID: "g", AuthorID: "snow-admin", Content: " ", Attachments: 1}, {ID: "hidden", ChannelID: "thread-hidden", GuildID: "g", AuthorID: "snow-admin", Content: "x"}, {ID: "channel-root", ChannelID: "channel-1", GuildID: "g", AuthorID: "snow-admin", Content: "new question"}, } for _, in := range cases { bot.handleInbound(in) } thread, err := mem.GetPostThread(context.Background(), root.ID) if err != nil || len(thread.Replies) != 0 { t.Fatalf("unexpected replies: %+v, %v", thread, err) } if len(api.sends) != 0 { t.Fatalf("unexpected discord sends %+v", api.sends) } } func inboundTestBot(mem *store.Memory, links *memoryLinks, api *fakeAPI, mailer mail.Notifier, adminUsername string) *Bot { bot := New("channel-1", links, api) bot.store = mem bot.mail = mailer bot.admins = map[string]string{"snow-admin": adminUsername} bot.botUserID = "bot-1" return bot } func seedInboundUsers(t *testing.T) (*store.Memory, *store.User, *store.User) { t.Helper() mem := store.NewMemory() homeowner := &store.User{ Username: "homeowner", Name: "Sam", Email: "sam@example.com", PasswordHash: "x", Role: store.RoleUser, } if err := mem.CreateUser(context.Background(), homeowner); err != nil { t.Fatal(err) } admin := &store.User{ Username: "plumber", Name: "Pat", Email: "pat@example.com", PasswordHash: "x", Role: store.RoleAdmin, } if err := mem.CreateUser(context.Background(), admin); err != nil { t.Fatal(err) } return mem, homeowner, admin } func seedLinkedRoot(t *testing.T, mem *store.Memory, links *memoryLinks, authorID, threadID string) *store.Post { t.Helper() root := &store.Post{ AuthorID: authorID, Title: "Leaky sink", Body: "It drips.", PostDate: pacific.Today(), } if err := mem.CreatePost(context.Background(), root); err != nil { t.Fatal(err) } if err := links.Upsert(context.Background(), store.DiscordLink{ PostID: root.ID, MessageID: "d-root", ThreadID: threadID, }); err != nil { t.Fatal(err) } return root } func waitForMail(t *testing.T, recording *mail.Recording, want int) []mail.PostReply { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if recording.Len() >= want { return recording.Snapshot() } time.Sleep(10 * time.Millisecond) } t.Fatalf("recorded %d notifications, want %d", recording.Len(), want) return nil }