Members open a ticket from a panel in the ticket channel: one button per category (a dropdown past five), then a short form. Each ticket is a private thread named after the category, number and opener, with a first message that mentions the category's staff role so its members join, and Claim and Close buttons. - Claim: staff (the category's role, or Manage Threads / Manage Server) take a ticket; the first message shows who has it. - Close: the opener or staff close it, with an optional reason. The thread is locked and archived, and the log channel gets a summary and a .txt transcript of the whole thread. - /ticket add|remove|claim|close inside a ticket; /tickets setup, /tickets category add|remove|list and /tickets panel for admins. - Per-member limit on open tickets (default 1), per-server ticket numbers handed out atomically, a deleted thread closes its ticket. - The dashboard asks the bot to post or refresh the panel through ticket_commands (LISTEN/NOTIFY, like music); the listener reconnects if the database connection drops. - Setup warns about missing bot permissions in the ticket and log channels. The flows live in internal/tickets behind small Discord and store interfaces, with unit tests on fakes; the repo and the listener have integration tests (-tags integration, TICKETS_TEST_DB). Needs the ticket tables (velox-dashboard-api migration 000016_tickets). .gitignore gains the /tldr benchmark rules so private chat exports and benchmark results on disk can't be committed from this branch. Co-Authored-By: Claude Opus 5.5 <[email protected]>
207 lines
5.7 KiB
Go
207 lines
5.7 KiB
Go
//go:build integration
|
|
|
|
// Runs against a real Postgres with the dashboard API's migrations applied:
|
|
//
|
|
// TICKETS_TEST_DB=postgres://... go test -tags integration ./internal/db/repos/ticketrepo
|
|
package ticketrepo
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
)
|
|
|
|
func testRepo(t *testing.T) (*Repo, int64) {
|
|
t.Helper()
|
|
dsn := os.Getenv("TICKETS_TEST_DB")
|
|
if dsn == "" {
|
|
t.Skip("TICKETS_TEST_DB not set")
|
|
}
|
|
db, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
const guild = 424242
|
|
for _, q := range []string{
|
|
`DELETE FROM tickets WHERE guild_id = $1`,
|
|
`DELETE FROM ticket_categories WHERE guild_id = $1`,
|
|
`DELETE FROM ticket_settings WHERE guild_id = $1`,
|
|
`DELETE FROM ticket_commands WHERE guild_id = $1`,
|
|
} {
|
|
if _, err := db.Exec(q, guild); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return NewRepo(db), guild
|
|
}
|
|
|
|
func TestSettingsDefaultsAndChannels(t *testing.T) {
|
|
r, g := testRepo(t)
|
|
ctx := context.Background()
|
|
|
|
s, err := r.GetSettings(ctx, g)
|
|
if err != nil || s.Configured || !s.Enabled || s.PanelTitle != DefaultPanelTitle || s.MaxOpenPerUser != 1 {
|
|
t.Fatalf("defaults wrong: %+v %v", s, err)
|
|
}
|
|
if err := r.SetChannels(ctx, g, 111, 222); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.SetPanelMessage(ctx, g, 333); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, _ = r.GetSettings(ctx, g)
|
|
if !s.Configured || s.ChannelID != 111 || s.LogChannelID != 222 || s.PanelMessageID != 333 {
|
|
t.Fatalf("saved settings wrong: %+v", s)
|
|
}
|
|
// Clearing the log channel.
|
|
_ = r.SetChannels(ctx, g, 111, 0)
|
|
s, _ = r.GetSettings(ctx, g)
|
|
if s.LogChannelID != 0 {
|
|
t.Errorf("log channel not cleared: %+v", s)
|
|
}
|
|
}
|
|
|
|
func TestNextNumberIsUniqueUnderConcurrency(t *testing.T) {
|
|
r, g := testRepo(t)
|
|
ctx := context.Background()
|
|
|
|
var mu sync.Mutex
|
|
seen := map[int]bool{}
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 20; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
n, err := r.NextNumber(ctx, g)
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if seen[n] {
|
|
t.Errorf("number %d handed out twice", n)
|
|
}
|
|
seen[n] = true
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
for n := 1; n <= 20; n++ {
|
|
if !seen[n] {
|
|
t.Errorf("number %d missing: numbers should be 1..20", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCategories(t *testing.T) {
|
|
r, g := testRepo(t)
|
|
ctx := context.Background()
|
|
|
|
a, err := r.AddCategory(ctx, Category{GuildID: g, Name: "Support", Emoji: "🛟", StaffRoleID: 9})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _ := r.AddCategory(ctx, Category{GuildID: g, Name: "Appeal", Description: "Ban appeals"})
|
|
if a.Position != 0 || b.Position != 1 || b.Emoji != "" || b.StaffRoleID != 0 {
|
|
t.Fatalf("positions or defaults wrong: %+v %+v", a, b)
|
|
}
|
|
list, _ := r.ListCategories(ctx, g)
|
|
if len(list) != 2 || list[0].Name != "Support" || list[1].Description != "Ban appeals" {
|
|
t.Fatalf("list wrong: %+v", list)
|
|
}
|
|
if c, _ := r.GetCategory(ctx, g, a.ID); c == nil || c.Emoji != "🛟" {
|
|
t.Errorf("get wrong: %+v", c)
|
|
}
|
|
if c, _ := r.GetCategory(ctx, g+1, a.ID); c != nil {
|
|
t.Error("a category must not be readable from another guild")
|
|
}
|
|
if ok, _ := r.DeleteCategoryByName(ctx, g, "support"); !ok {
|
|
t.Error("delete should match case-insensitively")
|
|
}
|
|
if ok, _ := r.DeleteCategoryByName(ctx, g, "nope"); ok {
|
|
t.Error("deleting a missing category should report false")
|
|
}
|
|
}
|
|
|
|
func TestTicketLifecycle(t *testing.T) {
|
|
r, g := testRepo(t)
|
|
ctx := context.Background()
|
|
|
|
cat, _ := r.AddCategory(ctx, Category{GuildID: g, Name: "Support"})
|
|
id, err := r.CreateTicket(ctx, Ticket{GuildID: g, Number: 1, CategoryID: cat.ID, CategoryName: "Support", ThreadID: 5001, IntroMessage: 6001, OpenerID: 77, Subject: "Help"})
|
|
if err != nil || id == 0 {
|
|
t.Fatal(err)
|
|
}
|
|
if n, _ := r.CountOpenByOpener(ctx, g, 77); n != 1 {
|
|
t.Errorf("open count = %d", n)
|
|
}
|
|
|
|
if holder, _ := r.Claim(ctx, 5001, 10); holder != 10 {
|
|
t.Errorf("first claim should win, holder = %d", holder)
|
|
}
|
|
if holder, _ := r.Claim(ctx, 5001, 11); holder != 10 {
|
|
t.Errorf("second claim must not take over, holder = %d", holder)
|
|
}
|
|
|
|
if ok, _ := r.Close(ctx, 5001, 10, "solved"); !ok {
|
|
t.Error("first close should succeed")
|
|
}
|
|
if ok, _ := r.Close(ctx, 5001, 11, ""); ok {
|
|
t.Error("closing twice must report false")
|
|
}
|
|
tk, _ := r.GetTicketByThread(ctx, 5001)
|
|
if tk == nil || tk.Status != StatusClosed || tk.ClosedBy != 10 || tk.CloseReason != "solved" || tk.ClosedAt.IsZero() || tk.ClaimedBy != 10 || tk.IntroMessage != 6001 {
|
|
t.Fatalf("closed ticket wrong: %+v", tk)
|
|
}
|
|
if n, _ := r.CountOpenByOpener(ctx, g, 77); n != 0 {
|
|
t.Errorf("closed ticket still counted as open")
|
|
}
|
|
if holder, _ := r.Claim(ctx, 5001, 12); holder != 0 {
|
|
t.Error("a closed ticket cannot be claimed")
|
|
}
|
|
|
|
// Deleting the category keeps the ticket and its copied name.
|
|
_, _ = r.DeleteCategoryByName(ctx, g, "Support")
|
|
tk, _ = r.GetTicketByThread(ctx, 5001)
|
|
if tk.CategoryID != 0 || tk.CategoryName != "Support" {
|
|
t.Errorf("ticket after category delete: %+v", tk)
|
|
}
|
|
if tk, _ := r.GetTicketByThread(ctx, 999); tk != nil {
|
|
t.Error("unknown thread should return nil")
|
|
}
|
|
}
|
|
|
|
func TestCommands(t *testing.T) {
|
|
r, g := testRepo(t)
|
|
ctx := context.Background()
|
|
if _, err := r.db.Exec(`INSERT INTO ticket_commands (guild_id, command_type, requested_by) VALUES ($1, 'post_panel', 5)`, g); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmds, err := r.ListUnprocessedCommands(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var mine *Command
|
|
for i := range cmds {
|
|
if cmds[i].GuildID == g {
|
|
mine = &cmds[i]
|
|
}
|
|
}
|
|
if mine == nil || mine.CommandType != "post_panel" || mine.RequestedBy != 5 {
|
|
t.Fatalf("command not listed: %+v", cmds)
|
|
}
|
|
_ = r.MarkCommandProcessed(ctx, mine.ID)
|
|
cmds, _ = r.ListUnprocessedCommands(ctx)
|
|
for _, c := range cmds {
|
|
if c.ID == mine.ID {
|
|
t.Error("processed command still listed")
|
|
}
|
|
}
|
|
}
|