package api import ( "testing" "time" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // missingRow builds a ListMissingTracksRow with just the fields the grouping // fold reads, so a test case states its directory and title and nothing else. func missingRow(dir, title string, missingAt time.Time) dbq.ListMissingTracksRow { return dbq.ListMissingTracksRow{ ID: pgtype.UUID{Bytes: [16]byte{1}, Valid: true}, Title: title, FilePath: dir + "/" + title + ".flac", Directory: dir, MissingSince: pgtype.Timestamptz{Time: missingAt, Valid: true}, DurationMs: 180_000, AlbumTitle: "Album", ArtistName: "Artist", } } func TestGroupMissingByDirectory(t *testing.T) { base := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC) t.Run("consecutive rows in one directory collapse to one group", func(t *testing.T) { groups := groupMissingByDirectory([]dbq.ListMissingTracksRow{ missingRow("/music/Linkin Park/Minutes to Midnight", "Given Up", base), missingRow("/music/Linkin Park/Minutes to Midnight", "Leave Out All the Rest", base), missingRow("/music/Linkin Park/Minutes to Midnight", "Bleed It Out", base), }) if len(groups) != 1 { t.Fatalf("want 1 group, got %d", len(groups)) } if got := len(groups[0].Tracks); got != 3 { t.Errorf("want 3 tracks in the group, got %d", got) } if groups[0].Directory != "/music/Linkin Park/Minutes to Midnight" { t.Errorf("unexpected directory %q", groups[0].Directory) } }) t.Run("distinct directories stay separate and keep query order", func(t *testing.T) { groups := groupMissingByDirectory([]dbq.ListMissingTracksRow{ missingRow("/music/A/One", "a1", base), missingRow("/music/B/Two", "b1", base), missingRow("/music/B/Two", "b2", base), missingRow("/music/C/Three", "c1", base), }) if len(groups) != 3 { t.Fatalf("want 3 groups, got %d", len(groups)) } wantDirs := []string{"/music/A/One", "/music/B/Two", "/music/C/Three"} for i, want := range wantDirs { if groups[i].Directory != want { t.Errorf("group %d: want %q, got %q", i, want, groups[i].Directory) } } if got := len(groups[1].Tracks); got != 2 { t.Errorf("middle group: want 2 tracks, got %d", got) } }) // The fold is run-length, not a map, so a directory that appears in two // non-adjacent runs legitimately produces two groups. That can only happen // if the query's ORDER BY directory is dropped — pinning it here means such // a change fails a test instead of silently fragmenting the UI. t.Run("a directory split by a foreign row yields two groups", func(t *testing.T) { groups := groupMissingByDirectory([]dbq.ListMissingTracksRow{ missingRow("/music/A", "a1", base), missingRow("/music/B", "b1", base), missingRow("/music/A", "a2", base), }) if len(groups) != 3 { t.Fatalf("want 3 groups from an unordered input, got %d", len(groups)) } }) t.Run("no rows yields an empty, non-nil slice", func(t *testing.T) { groups := groupMissingByDirectory(nil) if groups == nil { t.Fatal("want a non-nil slice so the JSON encoder emits [] not null") } if len(groups) != 0 { t.Errorf("want 0 groups, got %d", len(groups)) } }) t.Run("a never-played track carries a null last_played_at", func(t *testing.T) { groups := groupMissingByDirectory([]dbq.ListMissingTracksRow{ missingRow("/music/A", "a1", base), }) if groups[0].Tracks[0].LastPlayedAt != nil { t.Errorf("want nil last_played_at, got %v", *groups[0].Tracks[0].LastPlayedAt) } }) t.Run("a played track carries its timestamp", func(t *testing.T) { row := missingRow("/music/A", "a1", base) row.LastPlayedAt = pgtype.Timestamptz{Time: base.Add(-48 * time.Hour), Valid: true} groups := groupMissingByDirectory([]dbq.ListMissingTracksRow{row}) got := groups[0].Tracks[0].LastPlayedAt if got == nil { t.Fatal("want a last_played_at, got nil") } if want := "2026-08-04T12:00:00Z"; *got != want { t.Errorf("want %q, got %q", want, *got) } }) t.Run("duration is reported in seconds", func(t *testing.T) { groups := groupMissingByDirectory([]dbq.ListMissingTracksRow{ missingRow("/music/A", "a1", base), }) if got := groups[0].Tracks[0].DurationSec; got != 180 { t.Errorf("want 180s from 180000ms, got %d", got) } }) }