- /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]>
288 lines
8.8 KiB
Go
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")
|
|
}
|
|
}
|