Files
minstrel/internal/gc/worker_test.go
T
bvandeusen 305d4780ac
test-go / test (push) Successful in 36s
test-go / integration (push) Failing after 14m34s
fix(server): TestGcCloseStalePlayEvents seeds artist with sort_name
The artists table requires sort_name (NOT NULL constraint added by
0009_artist_sort.up.sql). My GC integration test was inserting only
name + relying on a separate SELECT to pull the id back, which both
(a) violated the NOT NULL constraint and (b) was unnecessarily
indirect. RETURNING the id directly is the standard pattern used
everywhere else in the test suite.

Test now matches the real-world insert pattern in api.search +
library scan (sort_name mirrors name when no MBID-driven sort hint
is available). Other GC tests in this file don't touch artists so
they were already fine.
2026-06-02 18:47:31 -04:00

316 lines
10 KiB
Go

package gc
import (
"context"
"io"
"log/slog"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
)
// testWorker constructs a Worker against MINSTREL_TEST_DATABASE_URL.
// Mirrors the api package's testHandlers pattern — skip when not in
// integration mode, migrate + reset, return the pool for the caller
// to seed.
func testWorker(t *testing.T) (*Worker, *pgxpool.Pool) {
t.Helper()
if testing.Short() {
t.Skip("skipping gc integration in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := db.Migrate(dsn, logger); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return NewWorker(pool, logger), pool
}
// seedUser creates a minimal user row for tests that need play_events
// / play_sessions / scrobble_queue rows. Username is prefixed so
// dbtest.ResetDB cleans up between runs.
func seedUser(t *testing.T, pool *pgxpool.Pool, name string) pgtype.UUID {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name,
PasswordHash: "test-hash",
ApiToken: "test-token-" + name,
IsAdmin: false,
})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
return u.ID
}
func TestGcCloseStalePlayEvents_ClosesOnly24hOldRows(t *testing.T) {
w, pool := testWorker(t)
ctx := context.Background()
userID := seedUser(t, pool, "alice")
// Need a track + session to satisfy FKs on play_events.
var trackID pgtype.UUID
var artistID pgtype.UUID
// `artists` has sort_name NOT NULL; mirror the title in sort_name
// like every real insert site does (see api.search / library scan).
if err := pool.QueryRow(ctx, `
INSERT INTO artists (name, sort_name) VALUES ('A', 'A') RETURNING id
`).Scan(&artistID); err != nil {
t.Fatalf("seed artist row: %v", err)
}
var albumID pgtype.UUID
if err := pool.QueryRow(ctx, `
INSERT INTO albums (artist_id, title, sort_title) VALUES ($1, 'X', 'X') RETURNING id
`, artistID).Scan(&albumID); err != nil {
t.Fatalf("seed album: %v", err)
}
if err := pool.QueryRow(ctx, `
INSERT INTO tracks (album_id, artist_id, title, file_path, duration_ms)
VALUES ($1, $2, 'T', '/x.mp3', 180000) RETURNING id
`, albumID, artistID).Scan(&trackID); err != nil {
t.Fatalf("seed track: %v", err)
}
var sessionID pgtype.UUID
if err := pool.QueryRow(ctx, `
INSERT INTO play_sessions (user_id, started_at, last_event_at)
VALUES ($1, now() - interval '2 hours', now()) RETURNING id
`, userID).Scan(&sessionID); err != nil {
t.Fatalf("seed session: %v", err)
}
// Stale row (25h old, no ended_at) — should be closed.
if _, err := pool.Exec(ctx, `
INSERT INTO play_events (user_id, track_id, session_id, started_at)
VALUES ($1, $2, $3, now() - interval '25 hours')
`, userID, trackID, sessionID); err != nil {
t.Fatalf("seed stale event: %v", err)
}
// Fresh row (1h old, no ended_at) — should be left alone.
if _, err := pool.Exec(ctx, `
INSERT INTO play_events (user_id, track_id, session_id, started_at)
VALUES ($1, $2, $3, now() - interval '1 hour')
`, userID, trackID, sessionID); err != nil {
t.Fatalf("seed fresh event: %v", err)
}
w.tickOnce(ctx)
var closedStale, openFresh bool
if err := pool.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM play_events
WHERE started_at < now() - interval '24 hours'
AND ended_at IS NOT NULL)
`).Scan(&closedStale); err != nil {
t.Fatalf("check stale: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM play_events
WHERE started_at > now() - interval '2 hours'
AND ended_at IS NULL)
`).Scan(&openFresh); err != nil {
t.Fatalf("check fresh: %v", err)
}
if !closedStale {
t.Errorf("stale play_events row not closed")
}
if !openFresh {
t.Errorf("fresh play_events row was closed (should be left alone)")
}
}
func TestGcClosePlaySessions_ClosesIdleAndEmptyStarvedSessions(t *testing.T) {
w, pool := testWorker(t)
ctx := context.Background()
userID := seedUser(t, pool, "bob")
// Idle session — has events, last_event_at 8h ago.
if _, err := pool.Exec(ctx, `
INSERT INTO play_sessions (user_id, started_at, last_event_at, track_count)
VALUES ($1, now() - interval '8 hours', now() - interval '8 hours', 5)
`, userID); err != nil {
t.Fatalf("seed idle session: %v", err)
}
// Empty-starved session — no events, started 2h ago.
if _, err := pool.Exec(ctx, `
INSERT INTO play_sessions (user_id, started_at, last_event_at, track_count)
VALUES ($1, now() - interval '2 hours', now() - interval '2 hours', 0)
`, userID); err != nil {
t.Fatalf("seed empty session: %v", err)
}
// Active session — recent last_event_at, has events.
if _, err := pool.Exec(ctx, `
INSERT INTO play_sessions (user_id, started_at, last_event_at, track_count)
VALUES ($1, now() - interval '30 minutes', now() - interval '5 minutes', 3)
`, userID); err != nil {
t.Fatalf("seed active session: %v", err)
}
w.tickOnce(ctx)
var closedCount, openCount int
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM play_sessions
WHERE user_id = $1 AND ended_at IS NOT NULL
`, userID).Scan(&closedCount); err != nil {
t.Fatalf("count closed: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM play_sessions
WHERE user_id = $1 AND ended_at IS NULL
`, userID).Scan(&openCount); err != nil {
t.Fatalf("count open: %v", err)
}
if closedCount != 2 {
t.Errorf("closed sessions = %d, want 2 (idle + empty-starved)", closedCount)
}
if openCount != 1 {
t.Errorf("open sessions = %d, want 1 (active)", openCount)
}
}
func TestGcResetStuckSystemPlaylistRuns(t *testing.T) {
w, pool := testWorker(t)
ctx := context.Background()
stuckUser := seedUser(t, pool, "stuck")
activeUser := seedUser(t, pool, "active")
// Stuck row: in_flight, last_run_at 30 min ago.
if _, err := pool.Exec(ctx, `
INSERT INTO system_playlist_runs (user_id, last_run_at, in_flight)
VALUES ($1, now() - interval '30 minutes', true)
`, stuckUser); err != nil {
t.Fatalf("seed stuck run: %v", err)
}
// Active row: in_flight, last_run_at 2 min ago — still legitimate.
if _, err := pool.Exec(ctx, `
INSERT INTO system_playlist_runs (user_id, last_run_at, in_flight)
VALUES ($1, now() - interval '2 minutes', true)
`, activeUser); err != nil {
t.Fatalf("seed active run: %v", err)
}
w.tickOnce(ctx)
var stuckFlight, activeFlight bool
if err := pool.QueryRow(ctx, `
SELECT in_flight FROM system_playlist_runs WHERE user_id = $1
`, stuckUser).Scan(&stuckFlight); err != nil {
t.Fatalf("read stuck row: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT in_flight FROM system_playlist_runs WHERE user_id = $1
`, activeUser).Scan(&activeFlight); err != nil {
t.Fatalf("read active row: %v", err)
}
if stuckFlight {
t.Errorf("stuck row still in_flight after sweep")
}
if !activeFlight {
t.Errorf("active row was reset (should be left alone — only 2 min old)")
}
}
func TestGcDeleteExpiredPasswordResets(t *testing.T) {
w, pool := testWorker(t)
ctx := context.Background()
userID := seedUser(t, pool, "pwd")
// Expired-unused (> 1h past expires_at) — should delete.
if _, err := pool.Exec(ctx, `
INSERT INTO password_resets (token, user_id, expires_at)
VALUES ('expired-old', $1, now() - interval '2 hours')
`, userID); err != nil {
t.Fatalf("seed expired: %v", err)
}
// Used (> 7 days past used_at) — should delete.
if _, err := pool.Exec(ctx, `
INSERT INTO password_resets (token, user_id, expires_at, used_at)
VALUES ('used-old', $1, now() - interval '8 days', now() - interval '8 days')
`, userID); err != nil {
t.Fatalf("seed used-old: %v", err)
}
// Active (expires in future) — should survive.
if _, err := pool.Exec(ctx, `
INSERT INTO password_resets (token, user_id, expires_at)
VALUES ('active', $1, now() + interval '1 hour')
`, userID); err != nil {
t.Fatalf("seed active: %v", err)
}
// Recently used (< 7 days) — should survive for audit.
if _, err := pool.Exec(ctx, `
INSERT INTO password_resets (token, user_id, expires_at, used_at)
VALUES ('used-recent', $1, now() - interval '1 hour', now() - interval '1 day')
`, userID); err != nil {
t.Fatalf("seed used-recent: %v", err)
}
w.tickOnce(ctx)
var remaining []string
rows, err := pool.Query(ctx, `SELECT token FROM password_resets WHERE user_id = $1`, userID)
if err != nil {
t.Fatalf("list remaining: %v", err)
}
defer rows.Close()
for rows.Next() {
var tok string
if err := rows.Scan(&tok); err != nil {
t.Fatalf("scan: %v", err)
}
remaining = append(remaining, tok)
}
wantSet := map[string]bool{"active": true, "used-recent": true}
if len(remaining) != len(wantSet) {
t.Errorf("remaining tokens = %v, want %v", remaining, []string{"active", "used-recent"})
}
for _, tok := range remaining {
if !wantSet[tok] {
t.Errorf("unexpected surviving token %q", tok)
}
}
}
// Verifies tickOnce doesn't blow up when the tables are completely
// empty — sweeps just no-op. Regression guard against an EXEC vs
// QUERY-row-count mismatch failing on zero rows.
func TestGcTickOnce_NoOpOnEmptyTables(t *testing.T) {
w, _ := testWorker(t)
w.tickOnce(context.Background())
}
// Sanity check on the Run() loop's cancel behaviour — we don't want
// to leave a goroutine spinning at test-runner exit. 10ms tick with
// an immediate cancel should return promptly.
func TestGcRun_HonoursContextCancel(t *testing.T) {
w, _ := testWorker(t)
w.tick = 10 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
w.Run(ctx)
close(done)
}()
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Run did not return after cancel")
}
}