Files
velox-bot/internal/tickets/tickets_test.go
T
FernandoJVideiraandClaude Opus 5.5 e10e1f7a86 Use a dropdown and a Create ticket button on the ticket panel, in orange
The panel is now one category dropdown and a Create ticket button
instead of a button per category. Discord doesn't send the dropdown's
value with a button click, so each member's pick is remembered in
memory for 15 minutes (per guild and member, capped). Panels posted
before keep working until they're reposted. Every ticket embed is
orange, closed tickets included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:58:19 +01:00

400 lines
15 KiB
Go

package tickets
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"velox-bot/internal/db/repos/ticketrepo"
"github.com/bwmarrin/discordgo"
)
const (
guild = int64(1)
channel = int64(10)
logCh = int64(20)
staffRl = int64(30)
)
var ctx = context.Background()
func member(userID string, roles ...string) *discordgo.Member {
return &discordgo.Member{User: &discordgo.User{ID: userID, Username: "user" + userID}, Roles: roles}
}
func TestThreadName(t *testing.T) {
for _, c := range []struct{ cat, user, want string }{
{"Support", "Ana", "support-0042-ana"},
{"Ban Appeal!", "João Silva", "ban-appeal-0042-jo-o-silva"},
{"🎫", "〈ぬべ〉", "ticket-0042"},
{strings.Repeat("x", 80), strings.Repeat("y", 80), strings.Repeat("x", 30) + "-0042-" + strings.Repeat("y", 32)},
} {
if got := ThreadName(c.cat, 42, c.user); got != c.want {
t.Errorf("ThreadName(%q, %q) = %q, want %q", c.cat, c.user, got, c.want)
}
}
}
func TestIsStaff(t *testing.T) {
cat := &ticketrepo.Category{StaffRoleID: staffRl}
if !IsStaff(member("1", "30"), 0, cat) {
t.Error("the staff role should count")
}
if IsStaff(member("1", "31"), 0, cat) {
t.Error("another role must not count")
}
for _, p := range []int64{discordgo.PermissionManageThreads, discordgo.PermissionManageGuild, discordgo.PermissionAdministrator} {
if !IsStaff(member("1"), p, nil) {
t.Errorf("permission %d should count, even with the category deleted", p)
}
}
if IsStaff(member("1"), discordgo.PermissionSendMessages, &ticketrepo.Category{}) {
t.Error("a category without a staff role must not make everyone staff")
}
}
func TestMissingPerms(t *testing.T) {
got := MissingPerms(discordgo.PermissionViewChannel|discordgo.PermissionSendMessages, NeedLogChannel)
if strings.Join(got, ",") != "Embed Links,Attach Files" {
t.Errorf("missing = %v", got)
}
if MissingPerms(discordgo.PermissionAdministrator, NeedTicketChannel) != nil {
t.Error("Administrator has everything")
}
}
func TestBuildPanel(t *testing.T) {
s := &ticketrepo.Settings{PanelTitle: "Help", PanelDescription: "Pick one"}
cats := []ticketrepo.Category{
{ID: 1, Name: "Support", Emoji: "🛟", Description: "General help"},
{ID: 2, Name: "Appeal", Emoji: "<a:party:123>"},
}
embed, comps := BuildPanel(s, cats)
if embed.Title != "Help" || embed.Color != 0xf97316 {
t.Errorf("panel embed wrong: %+v", embed)
}
if len(comps) != 2 {
t.Fatalf("want a dropdown row and a button row, got %d rows", len(comps))
}
menu := comps[0].(discordgo.ActionsRow).Components[0].(discordgo.SelectMenu)
if menu.CustomID != "tickets:select" || len(menu.Options) != 2 || menu.Options[0].Value != "1" || menu.Options[0].Emoji.Name != "🛟" {
t.Errorf("dropdown wrong: %+v", menu)
}
if e := menu.Options[1].Emoji; e.ID != "123" || e.Name != "party" || !e.Animated {
t.Errorf("custom emoji not parsed: %+v", e)
}
button := comps[1].(discordgo.ActionsRow).Components[0].(discordgo.Button)
if button.CustomID != "tickets:create" || button.Label != "Create ticket" {
t.Errorf("create button wrong: %+v", button)
}
if len(embed.Fields) != 1 || embed.Fields[0].Value != "General help" {
t.Errorf("descriptions belong in the embed: %+v", embed.Fields)
}
var many []ticketrepo.Category
for i := 1; i <= 30; i++ {
many = append(many, ticketrepo.Category{ID: int64(i), Name: fmt.Sprint("Cat ", i)})
}
_, comps = BuildPanel(s, many)
if n := len(comps[0].(discordgo.ActionsRow).Components[0].(discordgo.SelectMenu).Options); n != 25 {
t.Errorf("a dropdown holds at most 25 options, got %d", n)
}
}
func TestPicks(t *testing.T) {
clock := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
now = func() time.Time { return clock }
t.Cleanup(func() { now = time.Now; picks = map[pickKey]pick{} })
if _, ok := PickedCategory(guild, 5); ok {
t.Error("nothing picked yet")
}
RememberPick(guild, 5, 3)
RememberPick(guild, 5, 4)
RememberPick(guild, 6, 9)
if c, ok := PickedCategory(guild, 5); !ok || c != 4 {
t.Errorf("the latest pick wins: %d %v", c, ok)
}
if _, ok := PickedCategory(guild+1, 5); ok {
t.Error("picks are per guild")
}
clock = clock.Add(pickTTL + time.Second)
if _, ok := PickedCategory(guild, 5); ok {
t.Error("a pick expires")
}
// Full: new members can't add more, but expired ones make room.
picks = map[pickKey]pick{}
for i := 0; i < maxPicks; i++ {
picks[pickKey{guild + 100, int64(i)}] = pick{1, clock}
}
RememberPick(guild, 7, 1)
if _, ok := PickedCategory(guild, 7); ok {
t.Error("a full map takes no new members")
}
RememberPick(guild+100, 0, 2)
if c, _ := PickedCategory(guild+100, 0); c != 2 {
t.Error("someone already in the map can still change their pick")
}
clock = clock.Add(pickTTL + time.Second)
RememberPick(guild, 7, 1)
if _, ok := PickedCategory(guild, 7); !ok {
t.Error("expired picks should be cleared to make room")
}
}
func TestPostPanel(t *testing.T) {
st, d := newStore(), newDiscord()
if _, err := PostPanel(ctx, d, st, guild); !errors.Is(err, ErrNotSetUp) {
t.Errorf("err = %v, want not set up", err)
}
st.configure(guild, channel, logCh)
if _, err := PostPanel(ctx, d, st, guild); !errors.Is(err, ErrNoCategories) {
t.Errorf("err = %v, want no categories", err)
}
st.addCat(ticketrepo.Category{GuildID: guild, Name: "Support"})
msg, err := PostPanel(ctx, d, st, guild)
if err != nil || len(d.sent["10"]) != 1 || st.settings[guild].PanelMessageID != mustID(msg.ID) {
t.Fatalf("first post: err=%v sent=%d saved=%d", err, len(d.sent["10"]), st.settings[guild].PanelMessageID)
}
// A second post updates the same message.
if _, err := PostPanel(ctx, d, st, guild); err != nil || len(d.edits) != 1 || len(d.sent["10"]) != 1 {
t.Errorf("repost should edit: err=%v edits=%d sent=%d", err, len(d.edits), len(d.sent["10"]))
}
// If the old panel is gone, a new one is posted and remembered.
d.failEdit = errors.New("unknown message")
old := st.settings[guild].PanelMessageID
if _, err := PostPanel(ctx, d, st, guild); err != nil || len(d.sent["10"]) != 2 || st.settings[guild].PanelMessageID == old {
t.Errorf("deleted panel should be replaced: err=%v sent=%d", err, len(d.sent["10"]))
}
}
func setup() (*fakeStore, *fakeDiscord, ticketrepo.Category) {
st, d := newStore(), newDiscord()
st.configure(guild, channel, logCh)
cat := st.addCat(ticketrepo.Category{GuildID: guild, Name: "Support", StaffRoleID: staffRl})
return st, d, cat
}
func open(t *testing.T, st *fakeStore, d *fakeDiscord, cat ticketrepo.Category, userID string) *discordgo.Channel {
t.Helper()
th, err := Open(ctx, d, st, OpenRequest{
GuildID: guild, CategoryID: cat.ID,
User: &discordgo.User{ID: userID, Username: "ana", GlobalName: "Ana"},
Subject: "Can't join voice", Details: "It says no permission",
})
if err != nil {
t.Fatal(err)
}
return th
}
func TestCheckCanOpen(t *testing.T) {
st := newStore()
if _, _, err := CheckCanOpen(ctx, st, guild, 5, 1); !errors.Is(err, ErrNotSetUp) {
t.Errorf("err = %v", err)
}
st.configure(guild, channel, logCh)
if _, _, err := CheckCanOpen(ctx, st, guild, 5, 99); !errors.Is(err, ErrUnknownCategory) {
t.Errorf("err = %v", err)
}
st.settings[guild].Enabled = false
if _, _, err := CheckCanOpen(ctx, st, guild, 5, 1); !errors.Is(err, ErrDisabled) {
t.Errorf("err = %v", err)
}
}
func TestOpen(t *testing.T) {
st, d, cat := setup()
th := open(t, st, d, cat, "500")
start := d.threads[0]
if start.Type != discordgo.ChannelTypeGuildPrivateThread || start.Invitable || start.Name != "support-0001-ana" {
t.Errorf("thread wrong: %+v", start)
}
if strings.Join(d.members[th.ID], ",") != "500" {
t.Errorf("opener not added: %v", d.members[th.ID])
}
intro := d.sent[th.ID][0]
if intro.Content != "<@500> <@&30>" || strings.Join(intro.AllowedMentions.Roles, ",") != "30" {
t.Errorf("the staff role must be mentioned so its members join: %+v", intro)
}
if !strings.Contains(intro.Embeds[0].Description, "It says no permission") || intro.Embeds[0].Title != "Ticket #0001 · Support" {
t.Errorf("intro embed wrong: %+v", intro.Embeds[0])
}
tk := st.tickets[0]
if tk.Number != 1 || tk.ThreadID != mustID(th.ID) || tk.OpenerID != 500 || tk.IntroMessage == 0 || tk.Subject != "Can't join voice" {
t.Errorf("ticket row wrong: %+v", tk)
}
// The limit applies to the second ticket.
_, err := Open(ctx, d, st, OpenRequest{GuildID: guild, CategoryID: cat.ID, User: &discordgo.User{ID: "500"}, Subject: "again"})
var ue UserError
if !errors.As(err, &ue) || !strings.Contains(string(ue), "already have an open ticket") {
t.Errorf("err = %v", err)
}
}
func TestOpenCleansUpOnFailure(t *testing.T) {
st, d, cat := setup()
d.failSend = errors.New("missing access")
_, err := Open(ctx, d, st, OpenRequest{GuildID: guild, CategoryID: cat.ID, User: &discordgo.User{ID: "500"}, Subject: "help"})
if err == nil || len(d.deleted) != 1 || len(st.tickets) != 0 {
t.Errorf("a half-made ticket must be removed: err=%v deleted=%v tickets=%d", err, d.deleted, len(st.tickets))
}
}
func TestClaim(t *testing.T) {
st, d, cat := setup()
th := open(t, st, d, cat, "500")
if err := Claim(ctx, d, st, th.ID, Actor{Member: member("600")}); !errors.Is(err, ErrNotStaff) {
t.Errorf("non-staff claim: %v", err)
}
if err := Claim(ctx, d, st, th.ID, Actor{Member: member("700", "30")}); err != nil {
t.Fatal(err)
}
if st.tickets[0].ClaimedBy != 700 {
t.Errorf("claim not saved")
}
if len(d.edits) != 1 {
t.Fatalf("the first message should be updated")
}
e := (*d.edits[0].Embeds)[0]
if e.Fields[1].Value != "<@700>" || !strings.Contains(e.Description, "It says no permission") {
t.Errorf("updated intro lost details or claim: %+v", e)
}
if b := (*d.edits[0].Components)[0].(discordgo.ActionsRow).Components[0].(discordgo.Button); !b.Disabled || b.Label != "Claimed" {
t.Errorf("claim button should be disabled: %+v", b)
}
err := Claim(ctx, d, st, th.ID, Actor{Member: member("701"), Perms: discordgo.PermissionManageThreads})
if err == nil || !strings.Contains(err.Error(), "<@700> is already handling") {
t.Errorf("second claim: %v", err)
}
if err := Claim(ctx, d, st, "999", Actor{Member: member("700", "30")}); !errors.Is(err, ErrNotATicket) {
t.Errorf("claim outside a ticket: %v", err)
}
}
func TestClose(t *testing.T) {
st, d, cat := setup()
th := open(t, st, d, cat, "500")
ts := time.Date(2026, 10, 20, 18, 0, 0, 0, time.UTC)
d.history[th.ID] = []*discordgo.Message{ // newest first
{ID: "3", Content: "thanks, works now", Author: &discordgo.User{Username: "ana"}, Timestamp: ts.Add(2 * time.Minute)},
{ID: "2", Content: "try rejoining", Author: &discordgo.User{Username: "mod", GlobalName: "Mod"}, Timestamp: ts.Add(time.Minute),
Attachments: []*discordgo.MessageAttachment{{Filename: "steps.png", URL: "https://cdn/x.png"}}},
{ID: "1", Author: &discordgo.User{Username: "velox", Bot: true}, Embeds: []*discordgo.MessageEmbed{{Title: "Ticket #0001 · Support"}}, Timestamp: ts},
}
if err := Close(ctx, d, st, th.ID, Actor{Member: member("600")}, ""); !errors.Is(err, ErrNotInvolved) {
t.Errorf("an outsider must not close: %v", err)
}
if err := Close(ctx, d, st, th.ID, Actor{Member: member("500")}, "Solved"); err != nil {
t.Fatal(err)
}
tk := st.tickets[0]
if tk.Status != ticketrepo.StatusClosed || tk.ClosedBy != 500 || tk.CloseReason != "Solved" {
t.Errorf("ticket not closed: %+v", tk)
}
if e := d.chEdits[th.ID]; e == nil || !*e.Locked || !*e.Archived {
t.Errorf("thread should be locked and archived: %+v", e)
}
logged := d.sent["20"]
if len(logged) != 1 || !strings.Contains(logged[0].Embeds[0].Title, "Ticket #0001 closed") {
t.Fatalf("log channel post missing: %+v", logged)
}
transcript := d.files["ticket-0001.txt"]
i1, i2, i3 := strings.Index(transcript, "[embed] Ticket #0001"), strings.Index(transcript, "try rejoining"), strings.Index(transcript, "thanks, works now")
if i1 < 0 || i2 < i1 || i3 < i2 {
t.Errorf("transcript must be oldest first:\n%s", transcript)
}
if !strings.Contains(transcript, "Mod (mod)") || !strings.Contains(transcript, "[file] steps.png https://cdn/x.png") || !strings.Contains(transcript, "velox [bot]") {
t.Errorf("transcript details missing:\n%s", transcript)
}
if err := Close(ctx, d, st, th.ID, Actor{Member: member("500")}, ""); !errors.Is(err, ErrClosed) {
t.Errorf("closing twice: %v", err)
}
// A closed ticket no longer counts towards the limit.
open(t, st, d, cat, "500")
}
func TestTranscriptPages(t *testing.T) {
d := newDiscord()
var hist []*discordgo.Message
for i := 250; i >= 1; i-- {
hist = append(hist, &discordgo.Message{ID: fmt.Sprint(i), Content: fmt.Sprint("msg ", i), Author: &discordgo.User{Username: "u"}})
}
d.history["5"] = hist
out, n, err := Transcript(d, &ticketrepo.Ticket{ThreadID: 5, Number: 1})
if err != nil || n != 250 {
t.Fatalf("n=%d err=%v", n, err)
}
if strings.Index(string(out), "msg 1\n") > strings.Index(string(out), "msg 250\n") {
t.Error("pages must be stitched oldest first")
}
d.failFetch = errors.New("boom")
out, _, err = Transcript(d, &ticketrepo.Ticket{ThreadID: 5})
if err == nil || !strings.Contains(string(out), "only part of the thread") {
t.Error("a failed read should still produce a transcript that says so")
}
}
func TestAddAndRemoveMember(t *testing.T) {
st, d, cat := setup()
th := open(t, st, d, cat, "500")
if err := AddMember(ctx, d, st, th.ID, Actor{Member: member("600")}, "601"); !errors.Is(err, ErrNotInvolved) {
t.Errorf("outsider add: %v", err)
}
if err := AddMember(ctx, d, st, th.ID, Actor{Member: member("500")}, "601"); err != nil {
t.Errorf("the opener can add: %v", err)
}
if err := RemoveMember(ctx, d, st, th.ID, Actor{Member: member("500")}, "601"); !errors.Is(err, ErrNotStaff) {
t.Errorf("only staff remove: %v", err)
}
if err := RemoveMember(ctx, d, st, th.ID, Actor{Member: member("700", "30")}, "500"); err == nil {
t.Error("the opener can't be removed")
}
if err := RemoveMember(ctx, d, st, th.ID, Actor{Member: member("700", "30")}, "601"); err != nil {
t.Error(err)
}
if strings.Join(d.members[th.ID], ",") != "500" {
t.Errorf("members = %v", d.members[th.ID])
}
}
func TestThreadDeletedFreesTheLimit(t *testing.T) {
st, d, cat := setup()
th := open(t, st, d, cat, "500")
ThreadDeleted(ctx, st, th.ID)
if st.tickets[0].Status != ticketrepo.StatusClosed || st.tickets[0].CloseReason != "Thread deleted" {
t.Errorf("ticket = %+v", st.tickets[0])
}
open(t, st, d, cat, "500")
}
func TestProcessCommands(t *testing.T) {
st, d, _ := setup()
st.cmds = []ticketrepo.Command{{ID: 1, GuildID: guild, CommandType: "post_panel"}, {ID: 2, GuildID: 99, CommandType: "post_panel"}, {ID: 3, CommandType: "nope"}}
ProcessCommands(ctx, d, st)
if len(d.sent["10"]) != 1 {
t.Errorf("panel not posted")
}
if len(st.done) != 3 {
t.Errorf("every command must be marked done, even failed ones: %v", st.done)
}
}