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: ""}, } embed, comps := BuildPanel(s, cats) row := comps[0].(discordgo.ActionsRow) b1, b2 := row.Components[0].(discordgo.Button), row.Components[1].(discordgo.Button) if embed.Title != "Help" || b1.CustomID != "tickets:open:1" || b1.Emoji.Name != "🛟" { t.Errorf("first button wrong: %+v", b1) } if b2.Emoji.ID != "123" || b2.Emoji.Name != "party" || !b2.Emoji.Animated { t.Errorf("custom emoji not parsed: %+v", b2.Emoji) } 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 <= 7; i++ { many = append(many, ticketrepo.Category{ID: int64(i), Name: fmt.Sprint("Cat ", i)}) } _, comps = BuildPanel(s, many) menu := comps[0].(discordgo.ActionsRow).Components[0].(discordgo.SelectMenu) if menu.CustomID != "tickets:select" || len(menu.Options) != 7 || menu.Options[6].Value != "7" { t.Errorf("more than 5 categories should use a dropdown: %+v", menu) } } 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) } }