Files
FernandoJVideiraandClaude Sonnet 5.5 acdb5ead35 Tickets: settings, category edit and ticket list from Discord; NOTIFY triggers in schema.sql
- /tickets settings (on or off, panel title and text, open tickets per member), /tickets category edit, /tickets list
- schema.sql now creates the NOTIFY triggers, so the integration tests run on a database built from it, and two stale expectations (tickets start off) are fixed

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 02:05:45 +01:00

288 lines
8.8 KiB
Go

//go:build integration
// Runs against a real Postgres with schema.sql loaded:
//
// 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")
}
}
}
func TestUpdateSettingsCategoriesAndList(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
str := func(v string) *string { return &v }
num := func(v int) *int { return &v }
on := true
// Changing the text of a server that never set tickets up creates its row
// and leaves the system off.
if err := r.UpdateSettings(ctx, g, SettingsPatch{PanelTitle: str("Help desk"), MaxOpenPerUser: num(3)}); err != nil {
t.Fatal(err)
}
st, _ := r.GetSettings(ctx, g)
if st.Enabled || st.PanelTitle != "Help desk" || st.MaxOpenPerUser != 3 || st.PanelDescription != DefaultPanelDescription {
t.Errorf("settings: %+v", st)
}
if err := r.UpdateSettings(ctx, g, SettingsPatch{Enabled: &on}); err != nil {
t.Fatal(err)
}
if st, _ = r.GetSettings(ctx, g); !st.Enabled || st.PanelTitle != "Help desk" {
t.Errorf("enabling keeps the rest: %+v", st)
}
if err := r.UpdateSettings(ctx, g, SettingsPatch{MaxOpenPerUser: num(11)}); err == nil {
t.Error("the database refuses more than 10 open tickets per member")
}
if err := r.UpdateSettings(ctx, g, SettingsPatch{}); err != nil {
t.Errorf("an empty patch is fine: %v", err)
}
cat, err := r.AddCategory(ctx, Category{GuildID: g, Name: "Support", Emoji: "🎫", Description: "Questions", StaffRoleID: 99})
if err != nil {
t.Fatal(err)
}
other, err := r.AddCategory(ctx, Category{GuildID: g, Name: "Report"})
if err != nil {
t.Fatal(err)
}
zero := int64(0)
empty := ""
got, err := r.UpdateCategory(ctx, g, cat.ID, CategoryPatch{Name: str("Help"), Emoji: &empty, StaffRoleID: &zero})
if err != nil || got == nil {
t.Fatalf("update: %v %v", got, err)
}
if got.Name != "Help" || got.Emoji != "" || got.StaffRoleID != 0 || got.Description != "Questions" {
t.Errorf("only the named fields change, and empty clears: %+v", got)
}
if got, _ = r.UpdateCategory(ctx, g, cat.ID, CategoryPatch{StaffRoleID: func() *int64 { v := int64(5); return &v }()}); got.StaffRoleID != 5 {
t.Errorf("setting the role: %+v", got)
}
if gone, err := r.UpdateCategory(ctx, g+1, cat.ID, CategoryPatch{Name: str("x")}); err != nil || gone != nil {
t.Errorf("another server's category can't be edited: %v %v", gone, err)
}
if _, err := r.UpdateCategory(ctx, g, other.ID, CategoryPatch{Name: str("")}); err == nil {
t.Error("the database refuses an empty name")
}
for n, status := range []string{"open", "open", "closed"} {
id, err := r.CreateTicket(ctx, Ticket{GuildID: g, Number: n + 1, CategoryID: cat.ID, CategoryName: "Help", ThreadID: int64(9100 + n), OpenerID: 7, Subject: "x"})
if err != nil {
t.Fatal(err)
}
if status == "closed" {
if _, err := r.Close(ctx, int64(9100+n), 7, "done"); err != nil {
t.Fatal(err)
}
}
_ = id
}
open, err := r.ListTickets(ctx, g, StatusOpen, 10)
if err != nil || len(open) != 2 || open[0].Number != 2 {
t.Errorf("open tickets, newest first: %v %v", open, err)
}
closed, _ := r.ListTickets(ctx, g, StatusClosed, 10)
if len(closed) != 1 {
t.Errorf("closed tickets: %v", closed)
}
if other, _ := r.ListTickets(ctx, g+1, StatusOpen, 10); len(other) != 0 {
t.Error("another server's tickets must not show")
}
}