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