//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") } }