package smartplaylist import ( "errors" "strings" "testing" "yellowjacket/backend/coverart" "yellowjacket/backend/database" ) // --------------------------------------------------------------------------- // Test helpers — seed data // --------------------------------------------------------------------------- // seedSmartPlaylistData inserts 8 tracks with the full FK chain // required for smart playlist evaluation tests. Extends the // seedSearchData pattern from search_test.go with a multi-genre // track (ID 8) that has both "Rock" and "Alternative" genres. // // Track list: // // ID 1: "Bohemian Rhapsody" by "Queen" album "A Night at the Opera" (1975) genre=Rock // ID 2: "Halo" by "Beyoncé" album "Lemonade" (2008) genre=Pop // ID 3: "Back in Black" by "AC/DC" album "Back in Black" (1980) genre=Hard Rock // ID 4: "Comfortably Numb" by "Pink Floyd" album "The Dark Side" (1979) genre=Progressive Rock // ID 5: "Another One" by "Queen" album "The Game" (1980) genre=Funk Rock // ID 6: "Thunderstruck" by "AC/DC" album "The Razors Edge" (1990) genre=Hard Rock // ID 7: "No One Knows" by "QOTSA" album "Rated R" (2000) genre=Stoner Rock // ID 8: "Under the Bridge" by "RHCP" album "Blood Sugar" (1991) genre=Rock+Alternative (multi-genre) func seedSmartPlaylistData(t *testing.T, db *database.DB) { t.Helper() type track struct { id int64 filePath string title string artist string album string trackNum *int64 discNum *int64 year int64 genres []string // supports multi-genre composer string lenMs int64 ftID int64 sr int64 bd int64 ch int64 br int64 fsize int64 } intPtr := func(v int64) *int64 { return &v } tracks := []track{ { 1, "/music/queen/bohemian_rhapsody.mp3", "Bohemian Rhapsody", "Queen", "A Night at the Opera", intPtr(11), intPtr(1), 1975, []string{"Rock"}, "Freddie Mercury", 354000, 0, 44100, 16, 2, 320000, 8500000, }, { 2, "/music/beyonce/halo.flac", "Halo", "Beyoncé", "Lemonade", intPtr(1), intPtr(1), 2008, []string{"Pop"}, "Ryan Tedder", 261000, 1, 96000, 24, 2, 1411000, 42000000, }, { 3, "/music/acdc/back_in_black.mp3", "Back in Black", "AC/DC", "Back in Black", intPtr(1), intPtr(1), 1980, []string{"Hard Rock"}, "Angus Young", 255000, 0, 44100, 16, 2, 320000, 6100000, }, { 4, "/music/pinkfloyd/comfortably_numb.flac", "Comfortably Numb", "Pink Floyd", "The Dark Side", intPtr(6), intPtr(1), 1979, []string{"Progressive Rock"}, "David Gilmour", 382000, 1, 96000, 24, 2, 1411000, 54000000, }, { 5, "/music/queen/another_one.mp3", "Another One Bites the Dust", "Queen", "The Game", intPtr(3), intPtr(1), 1980, []string{"Funk Rock"}, "John Deacon", 215000, 0, 44100, 16, 2, 320000, 5200000, }, { 6, "/music/acdc/thunderstruck.mp3", "Thunderstruck", "AC/DC", "The Razors Edge", intPtr(1), intPtr(1), 1990, []string{"Hard Rock"}, "Angus Young", 292000, 0, 44100, 16, 2, 320000, 7000000, }, { 7, "/music/qotsa/no_one_knows.mp3", "No One Knows", "QOTSA", "Rated R", intPtr(1), intPtr(1), 2000, []string{"Stoner Rock"}, "Josh Homme", 310000, 0, 44100, 16, 2, 320000, 7400000, }, { 8, "/music/rhcp/under_the_bridge.mp3", "Under the Bridge", "RHCP", "Blood Sugar", intPtr(2), intPtr(1), 1991, []string{"Rock", "Alternative"}, "Anthony Kiedis", 264000, 0, 44100, 16, 2, 320000, 6300000, }, } for _, tr := range tracks { var trackNum, discNum int64 if tr.trackNum != nil { trackNum = *tr.trackNum } if tr.discNum != nil { discNum = *tr.discNum } id := database.InsertTestTrack(t, db, database.TestTrack{ FilePath: tr.filePath, Title: tr.title, Artist: tr.artist, Album: tr.album, Genres: tr.genres, TrackNumber: trackNum, DiscNumber: discNum, Year: tr.year, LengthMs: tr.lenMs, }) if _, err := db.ExecContext( `UPDATE audio_files SET file_type_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, composer = ? WHERE id = ?`, tr.ftID, tr.sr, tr.bd, tr.ch, tr.br, tr.fsize, tr.composer, id, ); err != nil { t.Fatalf("set audio properties for %q: %v", tr.filePath, err) } } } // --------------------------------------------------------------------------- // BuildWhereClause tests (pure — no DB needed) // --------------------------------------------------------------------------- func TestBuildWhereClause_TextIs(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ {Field: "artist", Operator: "is", Value: "Queen"}, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } if clause != "artist_name = ? COLLATE NOCASE" { t.Errorf("clause = %q, want %q", clause, "artist_name = ? COLLATE NOCASE") } if len(args) != 1 || args[0] != "Queen" { t.Errorf("args = %v, want [Queen]", args) } } func TestBuildWhereClause_TextIsNot(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ {Field: "artist", Operator: "is_not", Value: "Queen"}, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } if clause != "artist_name != ? COLLATE NOCASE" { t.Errorf("clause = %q, want %q", clause, "artist_name != ? COLLATE NOCASE") } if len(args) != 1 || args[0] != "Queen" { t.Errorf("args = %v, want [Queen]", args) } } func TestBuildWhereClause_TextContains(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ {Field: "title", Operator: "contains", Value: "Black"}, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } if clause != "title LIKE ?" { t.Errorf("clause = %q, want %q", clause, "title LIKE ?") } if len(args) != 1 || args[0] != "%Black%" { t.Errorf("args = %v, want [%%Black%%]", args) } } func TestBuildWhereClause_TextDoesNotContain(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ { Field: "title", Operator: "does_not_contain", Value: "Black", }, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } if clause != "title NOT LIKE ?" { t.Errorf("clause = %q, want %q", clause, "title NOT LIKE ?") } if len(args) != 1 || args[0] != "%Black%" { t.Errorf("args = %v, want [%%Black%%]", args) } } func TestBuildWhereClause_TextStartsWith(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ {Field: "title", Operator: "starts_with", Value: "Back"}, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } if clause != "title LIKE ?" { t.Errorf("clause = %q, want %q", clause, "title LIKE ?") } if len(args) != 1 || args[0] != "Back%" { t.Errorf("args = %v, want [Back%%]", args) } } func TestBuildWhereClause_TextEndsWith(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ {Field: "title", Operator: "ends_with", Value: "Black"}, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } if clause != "title LIKE ?" { t.Errorf("clause = %q, want %q", clause, "title LIKE ?") } if len(args) != 1 || args[0] != "%Black" { t.Errorf("args = %v, want [%%Black]", args) } } func TestBuildWhereClause_TextIsAnyOf(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ { Field: "artist", Operator: "is_any_of", Value: `["Queen","AC/DC"]`, }, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } if clause != "artist_name IN (? COLLATE NOCASE, ? COLLATE NOCASE)" { t.Errorf("clause = %q, want %q", clause, "artist_name IN (? COLLATE NOCASE, ? COLLATE NOCASE)") } if len(args) != 2 || args[0] != "Queen" || args[1] != "AC/DC" { t.Errorf("args = %v, want [Queen AC/DC]", args) } } func TestBuildWhereClause_NumericIs(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ {Field: "year", Operator: "is", Value: "1980"}, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } if clause != "year = ?" { t.Errorf("clause = %q, want %q", clause, "year = ?") } if len(args) != 1 || args[0] != int64(1980) { t.Errorf("args = %v, want [1980]", args) } } func TestBuildWhereClause_NumericIsNot(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ {Field: "year", Operator: "is_not", Value: "1980"}, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } if clause != "year != ?" { t.Errorf("clause = %q, want %q", clause, "year != ?") } if len(args) != 1 || args[0] != int64(1980) { t.Errorf("args = %v, want [1980]", args) } } func TestBuildWhereClause_NumericGreaterThan(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ {Field: "year", Operator: "greater_than", Value: "2000"}, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } if clause != "year > ?" { t.Errorf("clause = %q, want %q", clause, "year > ?") } if len(args) != 1 || args[0] != int64(2000) { t.Errorf("args = %v, want [2000]", args) } } func TestBuildWhereClause_NumericLessThan(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ {Field: "year", Operator: "less_than", Value: "1980"}, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } if clause != "year < ?" { t.Errorf("clause = %q, want %q", clause, "year < ?") } if len(args) != 1 || args[0] != int64(1980) { t.Errorf("args = %v, want [1980]", args) } } func TestBuildWhereClause_NumericBetween(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ { Field: "year", Operator: "between", Value: "1975,1985", }, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } if clause != "year BETWEEN ? AND ?" { t.Errorf("clause = %q, want %q", clause, "year BETWEEN ? AND ?") } if len(args) != 2 || args[0] != int64(1975) || args[1] != int64(1985) { t.Errorf("args = %v, want [1975 1985]", args) } } func TestBuildWhereClause_NumericBetweenJSON(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ { Field: "year", Operator: "between", Value: `["1975","1985"]`, }, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } if clause != "year BETWEEN ? AND ?" { t.Errorf("clause = %q, want %q", clause, "year BETWEEN ? AND ?") } if len(args) != 2 || args[0] != int64(1975) || args[1] != int64(1985) { t.Errorf("args = %v, want [1975 1985]", args) } } func TestBuildWhereClause_GenreIsProducesSubquery(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ {Field: "genre", Operator: "is", Value: "Rock"}, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } // Must use subquery, NOT "genre = ?" if strings.Contains(clause, "genre =") { t.Errorf( "genre 'is' should use subquery, not direct column match: %q", clause, ) } if !strings.Contains(clause, "file_genres") { t.Errorf("genre 'is' should reference file_genres: %q", clause) } if !strings.Contains(clause, "g.name = ? COLLATE NOCASE") { t.Errorf("genre 'is' should have g.name = ? COLLATE NOCASE: %q", clause) } if len(args) != 1 || args[0] != "Rock" { t.Errorf("args = %v, want [Rock]", args) } } func TestBuildWhereClause_GenreIsNotProducesSubquery(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ {Field: "genre", Operator: "is_not", Value: "Rock"}, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(clause, "NOT IN") { t.Errorf("genre 'is_not' should use NOT IN: %q", clause) } if !strings.Contains(clause, "file_genres") { t.Errorf( "genre 'is_not' should reference file_genres: %q", clause, ) } if len(args) != 1 || args[0] != "Rock" { t.Errorf("args = %v, want [Rock]", args) } } func TestBuildWhereClause_GenreIsAnyOfProducesSubquery(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ { Field: "genre", Operator: "is_any_of", Value: `["Rock","Pop"]`, }, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(clause, "file_genres") { t.Errorf( "genre 'is_any_of' should reference file_genres: %q", clause, ) } if !strings.Contains(clause, "g.name IN (? COLLATE NOCASE, ? COLLATE NOCASE)") { t.Errorf( "genre 'is_any_of' should have g.name IN (? COLLATE NOCASE, ? COLLATE NOCASE): %q", clause, ) } if len(args) != 2 || args[0] != "Rock" || args[1] != "Pop" { t.Errorf("args = %v, want [Rock Pop]", args) } } func TestBuildWhereClause_GenreContainsUsesSubquery(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ {Field: "genre", Operator: "contains", Value: "Rock"}, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } // Since the smart playlist query no longer projects a concatenated // genre column, "contains" filters genres via file_genres // with g.name LIKE applied to individual genre rows. if !strings.Contains(clause, "file_genres") { t.Errorf( "genre 'contains' should use file_genres subquery: %q", clause, ) } if !strings.Contains(clause, "g.name LIKE ?") { t.Errorf( "genre 'contains' should filter with g.name LIKE ?: %q", clause, ) } if len(args) != 1 || args[0] != "%Rock%" { t.Errorf("args = %v, want [%%Rock%%]", args) } } func TestBuildWhereClause_MultipleRulesAND(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ {Field: "artist", Operator: "is", Value: "Queen"}, {Field: "year", Operator: "greater_than", Value: "1975"}, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } if clause != "artist_name = ? COLLATE NOCASE AND year > ?" { t.Errorf("clause = %q, want %q", clause, "artist_name = ? COLLATE NOCASE AND year > ?") } if len(args) != 2 || args[0] != "Queen" || args[1] != int64(1975) { t.Errorf("args = %v, want [Queen 1975]", args) } } func TestBuildWhereClause_MultipleRulesOR(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ {Field: "artist", Operator: "is", Value: "Queen"}, {Field: "year", Operator: "greater_than", Value: "1975"}, }, MatchAny) if err != nil { t.Fatalf("unexpected error: %v", err) } want := "(artist_name = ? COLLATE NOCASE) OR (year > ?)" if clause != want { t.Errorf("clause = %q, want %q", clause, want) } if len(args) != 2 || args[0] != "Queen" || args[1] != int64(1975) { t.Errorf("args = %v, want [Queen 1975]", args) } } // An empty match is what every rule set saved before the field existed // carries, and it has to keep meaning AND — a playlist silently // widening to OR on upgrade is the whole risk of adding this field. func TestBuildWhereClause_EmptyMatchIsAll(t *testing.T) { t.Parallel() rules := []Rule{ {Field: "artist", Operator: "is", Value: "Queen"}, {Field: "year", Operator: "greater_than", Value: "1975"}, } empty, _, err := BuildWhereClause(rules, "") if err != nil { t.Fatalf("unexpected error: %v", err) } all, _, err := BuildWhereClause(rules, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } if empty != all { t.Errorf("empty match = %q, want the same as MatchAll %q", empty, all) } } // A condition carrying its own top-level AND is what makes the // bracketing under OR load-bearing: `days_since_played less_than` // is two predicates, and both belong to the same rule. func TestBuildWhereClause_ORBracketsCompoundCondition(t *testing.T) { t.Parallel() clause, _, err := BuildWhereClause([]Rule{ {Field: "artist", Operator: "is", Value: "Queen"}, { Field: "days_since_played", Operator: "less_than", Value: "30", }, }, MatchAny) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(clause, "(last_played IS NOT NULL AND") { t.Errorf( "compound condition is not bracketed under OR: %q", clause, ) } } func TestParseRuleSet_RejectsUnknownMatch(t *testing.T) { t.Parallel() _, err := ParseRuleSet(`{"rules":[],"match":"either"}`) if err == nil { t.Fatal("expected an error for an unknown match type") } if !errors.Is(err, errInvalidMatch) { t.Errorf("err = %v, want errInvalidMatch", err) } } func TestParseRuleSet_AcceptsAnyAndAll(t *testing.T) { t.Parallel() for _, want := range []MatchType{MatchAll, MatchAny} { rs, err := ParseRuleSet( `{"rules":[],"match":"` + string(want) + `"}`, ) if err != nil { t.Fatalf("match %q: unexpected error: %v", want, err) } if rs.Match != want { t.Errorf("match = %q, want %q", rs.Match, want) } } } func TestBuildWhereClause_SameFieldMultipleTimes(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause([]Rule{ {Field: "genre", Operator: "contains", Value: "Rock"}, { Field: "genre", Operator: "does_not_contain", Value: "Punk", }, }, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } // Genre text ops combine via AND across subqueries against // file_genres; the exact SQL shape is asserted elsewhere. if !strings.Contains(clause, " AND ") { t.Errorf("clause should combine rules with AND: %q", clause) } if !strings.Contains(clause, "af.id IN") { t.Errorf("clause should include positive IN subquery: %q", clause) } if !strings.Contains(clause, "af.id NOT IN") { t.Errorf("clause should include NOT IN subquery: %q", clause) } if len(args) != 2 || args[0] != "%Rock%" || args[1] != "%Punk%" { t.Errorf("args = %v, want [%%Rock%% %%Punk%%]", args) } } func TestBuildWhereClause_EmptyRules(t *testing.T) { t.Parallel() clause, args, err := BuildWhereClause(nil, MatchAll) if err != nil { t.Fatalf("unexpected error: %v", err) } if clause != "" { t.Errorf("clause = %q, want empty string", clause) } if args != nil { t.Errorf("args = %v, want nil", args) } } func TestBuildWhereClause_InvalidField(t *testing.T) { t.Parallel() _, _, err := BuildWhereClause([]Rule{ { Field: "nonexistent", Operator: "is", Value: "anything", }, }, MatchAll) if err == nil { t.Fatal("expected error for invalid field, got nil") } if !strings.Contains(err.Error(), "invalid field") { t.Errorf( "error should mention 'invalid field': %v", err, ) } if !strings.Contains(err.Error(), "nonexistent") { t.Errorf( "error should include the field name: %v", err, ) } } func TestBuildWhereClause_InvalidOperatorForNumeric(t *testing.T) { t.Parallel() _, _, err := BuildWhereClause([]Rule{ {Field: "year", Operator: "contains", Value: "1980"}, }, MatchAll) if err == nil { t.Fatal( "expected error for text operator on numeric field", ) } if !strings.Contains(err.Error(), "invalid operator") { t.Errorf( "error should mention 'invalid operator': %v", err, ) } } func TestBuildWhereClause_InvalidOperatorForText(t *testing.T) { t.Parallel() _, _, err := BuildWhereClause([]Rule{ { Field: "artist", Operator: "greater_than", Value: "Queen", }, }, MatchAll) if err == nil { t.Fatal( "expected error for numeric operator on text field", ) } if !strings.Contains(err.Error(), "invalid operator") { t.Errorf( "error should mention 'invalid operator': %v", err, ) } } // --------------------------------------------------------------------------- // Evaluate tests (with DB) // --------------------------------------------------------------------------- func TestEvaluate_TextIs(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ {Field: "artist", Operator: "is", Value: "Queen"}, }, }) if err != nil { t.Fatalf("Evaluate: %v", err) } if len(tracks) != 2 { t.Fatalf("got %d tracks, want 2", len(tracks)) } for _, tr := range tracks { if tr.ArtistName != "Queen" { t.Errorf( "track %q has artist %q, want Queen", tr.TrackName, tr.ArtistName, ) } } } // Two rules that share no track at all: under AND this is empty, and // under OR it is the union. Before Match existed only the first was // expressible, so a playlist could only ever narrow — "jazz or blues" // had no way to be said. func TestEvaluate_MatchAnyUnionsWhereMatchAllIntersects(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) // Queen has two tracks; Beyoncé has one; no track is by both. rules := []Rule{ {Field: "artist", Operator: "is", Value: "Queen"}, {Field: "artist", Operator: "is", Value: "Beyoncé"}, } all, err := Evaluate(db, RuleSet{Rules: rules, Match: MatchAll}) if err != nil { t.Fatalf("Evaluate(all): %v", err) } if len(all) != 0 { t.Errorf("match=all returned %d tracks, want 0", len(all)) } either, err := Evaluate(db, RuleSet{Rules: rules, Match: MatchAny}) if err != nil { t.Fatalf("Evaluate(any): %v", err) } if len(either) != 3 { t.Fatalf("match=any returned %d tracks, want 3", len(either)) } for _, tr := range either { if tr.ArtistName != "Queen" && tr.ArtistName != "Beyoncé" { t.Errorf( "track %q has artist %q, want Queen or Beyoncé", tr.TrackName, tr.ArtistName, ) } } } // An empty match is what every playlist saved before the field existed // carries, and it has to keep meaning AND all the way through Evaluate // — a stored playlist silently widening on upgrade is the only real // risk in adding this. func TestEvaluate_EmptyMatchStillIntersects(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ {Field: "artist", Operator: "is", Value: "Queen"}, {Field: "year", Operator: "greater_than", Value: "1979"}, }, }) if err != nil { t.Fatalf("Evaluate: %v", err) } // Only "Another One Bites the Dust" (Queen, 1980) satisfies both. if len(tracks) != 1 { t.Fatalf("got %d tracks, want 1", len(tracks)) } if want := "Another One Bites the Dust"; tracks[0].TrackName != want { t.Errorf("got %q, want %q", tracks[0].TrackName, want) } } // TestEvaluate_ArtworkEnrichment verifies the presentation-only // cover-art and MusicBrainz-ID fields are attached to matched tracks // by the batched fetchArtwork pass (they are no longer part of the // lean filter query). func TestEvaluate_ArtworkEnrichment(t *testing.T) { t.Parallel() db := database.NewTestDB(t) // One file, fully identified: cover art on its album, MBIDs on the // album, the artist and the file itself. database.InsertTestTrack(t, db, database.TestTrack{ FilePath: "/music/bohemian.mp3", Title: "Bohemian Rhapsody", Artist: "Queen", ArtistMBID: "artist-mbid-1", Album: "A Night at the Opera", AlbumMBID: "rg-mbid-1", RecordingMBID: "rec-mbid-1", LengthMs: 354000, }) if _, err := db.ExecContext( "INSERT INTO cover_art (id, file_path, mime_type) " + "VALUES (1, '/covers/abc123.jpg', 'image/jpeg')", ); err != nil { t.Fatalf("seed cover art: %v", err) } if _, err := db.ExecContext( "UPDATE albums SET cover_art_id = 1 WHERE name = 'A Night at the Opera'", ); err != nil { t.Fatalf("attach cover art: %v", err) } tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ {Field: "artist", Operator: "is", Value: "Queen"}, }, }) if err != nil { t.Fatalf("Evaluate: %v", err) } if len(tracks) != 1 { t.Fatalf("got %d tracks, want 1", len(tracks)) } got := tracks[0] if got.CoverArtPath != coverart.ResolveURLs("/covers/abc123.jpg").Original { t.Errorf("cover art = %q, want the resolved original", got.CoverArtPath) } if got.ArtistMBID != "artist-mbid-1" { t.Errorf("artist mbid = %q, want artist-mbid-1", got.ArtistMBID) } if got.ReleaseGroupMBID != "rg-mbid-1" { t.Errorf("release group mbid = %q, want rg-mbid-1", got.ReleaseGroupMBID) } if got.RecordingMBID != "rec-mbid-1" { t.Errorf("recording mbid = %q, want rec-mbid-1", got.RecordingMBID) } } func TestEvaluate_TextContains(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ { Field: "title", Operator: "contains", Value: "Black", }, }, }) if err != nil { t.Fatalf("Evaluate: %v", err) } if len(tracks) != 1 { t.Fatalf("got %d tracks, want 1", len(tracks)) } if tracks[0].TrackName != "Back in Black" { t.Errorf("got %q, want %q", tracks[0].TrackName, "Back in Black") } } func TestEvaluate_TextStartsWith(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ { Field: "title", Operator: "starts_with", Value: "Back", }, }, }) if err != nil { t.Fatalf("Evaluate: %v", err) } if len(tracks) != 1 { t.Fatalf("got %d tracks, want 1", len(tracks)) } if tracks[0].TrackName != "Back in Black" { t.Errorf("got %q, want %q", tracks[0].TrackName, "Back in Black") } } func TestEvaluate_TextEndsWith(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ { Field: "title", Operator: "ends_with", Value: "Numb", }, }, }) if err != nil { t.Fatalf("Evaluate: %v", err) } if len(tracks) != 1 { t.Fatalf("got %d tracks, want 1", len(tracks)) } if tracks[0].TrackName != "Comfortably Numb" { t.Errorf("got %q, want %q", tracks[0].TrackName, "Comfortably Numb") } } func TestEvaluate_TextDoesNotContain(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ { Field: "title", Operator: "does_not_contain", Value: "the", }, }, }) if err != nil { t.Fatalf("Evaluate: %v", err) } // "the" appears in: "Another One Bites the Dust", // "Under the Bridge". The rest should be returned. for _, tr := range tracks { if strings.Contains( strings.ToLower(tr.TrackName), "the") { t.Errorf( "track %q should not contain 'the'", tr.TrackName, ) } } } func TestEvaluate_TextIsAnyOf(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ { Field: "artist", Operator: "is_any_of", Value: `["Queen","AC/DC"]`, }, }, }) if err != nil { t.Fatalf("Evaluate: %v", err) } if len(tracks) != 4 { t.Fatalf("got %d tracks, want 4 (2 Queen + 2 AC/DC)", len(tracks)) } for _, tr := range tracks { if tr.ArtistName != "Queen" && tr.ArtistName != "AC/DC" { t.Errorf( "unexpected artist %q", tr.ArtistName, ) } } } func TestEvaluate_NumericGreaterThan(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ { Field: "year", Operator: "greater_than", Value: "2000", }, }, }) if err != nil { t.Fatalf("Evaluate: %v", err) } // Only "Halo" (2008) has year > 2000. if len(tracks) != 1 { t.Fatalf("got %d tracks, want 1", len(tracks)) } if tracks[0].TrackName != "Halo" { t.Errorf("got %q, want Halo", tracks[0].TrackName) } } func TestEvaluate_NumericLessThan(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ { Field: "year", Operator: "less_than", Value: "1980", }, }, }) if err != nil { t.Fatalf("Evaluate: %v", err) } // Bohemian Rhapsody (1975) and Comfortably Numb (1979) if len(tracks) != 2 { t.Fatalf("got %d tracks, want 2", len(tracks)) } for _, tr := range tracks { if tr.Year >= 1980 { t.Errorf("track %q year=%d should be < 1980", tr.TrackName, tr.Year) } } } func TestEvaluate_NumericBetween(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ { Field: "year", Operator: "between", Value: "1975,1985", }, }, }) if err != nil { t.Fatalf("Evaluate: %v", err) } // 1975: Bohemian Rhapsody, 1979: Comfortably Numb, // 1980: Back in Black, 1980: Another One Bites the Dust if len(tracks) != 4 { t.Fatalf("got %d tracks, want 4", len(tracks)) } for _, tr := range tracks { if tr.Year < 1975 || tr.Year > 1985 { t.Errorf( "track %q year=%d outside 1975-1985", tr.TrackName, tr.Year, ) } } } func TestEvaluate_GenreIs_MultiGenreTrack(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) // Genre "is Rock" must match track 8 (Rock+Alternative) and // track 1 (Rock). This proves the subquery works correctly // with multi-genre tracks. tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ {Field: "genre", Operator: "is", Value: "Rock"}, }, }) if err != nil { t.Fatalf("Evaluate: %v", err) } if len(tracks) != 2 { names := make([]string, len(tracks)) for i, tr := range tracks { names[i] = tr.TrackName } t.Fatalf( "genre 'is Rock' got %d tracks %v, want 2 "+ "(Bohemian Rhapsody + Under the Bridge)", len(tracks), names, ) } foundBR := false foundUTB := false for _, tr := range tracks { if tr.TrackName == "Bohemian Rhapsody" { foundBR = true } if tr.TrackName == "Under the Bridge" { foundUTB = true } } if !foundBR { t.Error("genre 'is Rock' missing Bohemian Rhapsody") } if !foundUTB { t.Error( "genre 'is Rock' missing Under the Bridge " + "(multi-genre track)", ) } } func TestEvaluate_GenreIsNot_MultiGenreTrack(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) // Genre "is_not Rock" must NOT return tracks 1 or 8. tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ { Field: "genre", Operator: "is_not", Value: "Rock", }, }, }) if err != nil { t.Fatalf("Evaluate: %v", err) } for _, tr := range tracks { if tr.TrackName == "Bohemian Rhapsody" || tr.TrackName == "Under the Bridge" { t.Errorf( "genre 'is_not Rock' should not return %q", tr.TrackName, ) } } // Should return the other 6 tracks. if len(tracks) != 6 { t.Errorf("got %d tracks, want 6", len(tracks)) } } func TestEvaluate_GenreContains(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) // "contains Rock" on the concatenated genre column should match // any track that has "Rock" anywhere in its genre string. tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ { Field: "genre", Operator: "contains", Value: "Rock", }, }, }) if err != nil { t.Fatalf("Evaluate: %v", err) } // Rock (1,8), Hard Rock (3,6), Progressive Rock (4), // Funk Rock (5), Stoner Rock (7) = 7 tracks if len(tracks) != 7 { names := make([]string, len(tracks)) for i, tr := range tracks { names[i] = tr.TrackName } t.Fatalf( "genre 'contains Rock' got %d tracks %v, want 7", len(tracks), names, ) } } func TestEvaluate_MultipleRulesAND(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) // genre contains "Rock" AND year > 1970 AND year < 1981 tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ { Field: "genre", Operator: "contains", Value: "Rock", }, { Field: "year", Operator: "greater_than", Value: "1970", }, { Field: "year", Operator: "less_than", Value: "1981", }, }, }) if err != nil { t.Fatalf("Evaluate: %v", err) } // Bohemian Rhapsody (1975, Rock), Comfortably Numb (1979, Progressive Rock), // Back in Black (1980, Hard Rock), Another One (1980, Funk Rock) if len(tracks) != 4 { names := make([]string, len(tracks)) for i, tr := range tracks { names[i] = tr.TrackName } t.Fatalf("got %d tracks %v, want 4", len(tracks), names) } } func TestEvaluate_EmptyRulesReturnsAll(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) tracks, err := Evaluate(db, RuleSet{}) if err != nil { t.Fatalf("Evaluate: %v", err) } if len(tracks) != 8 { t.Fatalf("got %d tracks, want 8 (all seeded)", len(tracks)) } } func TestEvaluate_Limit(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) tracks, err := Evaluate(db, RuleSet{Limit: 3}) if err != nil { t.Fatalf("Evaluate: %v", err) } if len(tracks) != 3 { t.Fatalf("got %d tracks, want 3 (limited)", len(tracks)) } } func TestEvaluate_SortByYearASC(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) tracks, err := Evaluate(db, RuleSet{ SortField: "year", SortDir: "ASC", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if len(tracks) < 2 { t.Fatalf("got %d tracks, want >= 2", len(tracks)) } for i := 1; i < len(tracks); i++ { if tracks[i].Year < tracks[i-1].Year { t.Errorf( "sort ASC violated: track[%d].Year=%d < "+ "track[%d].Year=%d", i, tracks[i].Year, i-1, tracks[i-1].Year, ) } } } func TestEvaluate_SortByYearDESC(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) tracks, err := Evaluate(db, RuleSet{ SortField: "year", SortDir: "DESC", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if len(tracks) < 2 { t.Fatalf("got %d tracks, want >= 2", len(tracks)) } for i := 1; i < len(tracks); i++ { if tracks[i].Year > tracks[i-1].Year { t.Errorf( "sort DESC violated: track[%d].Year=%d > "+ "track[%d].Year=%d", i, tracks[i].Year, i-1, tracks[i-1].Year, ) } } } func TestEvaluate_SortByRandom(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) // Just verify it doesn't error. tracks, err := Evaluate(db, RuleSet{ SortField: "random", }) if err != nil { t.Fatalf("Evaluate with random sort: %v", err) } if len(tracks) != 8 { t.Fatalf("got %d tracks, want 8", len(tracks)) } } func TestEvaluate_InvalidField(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) _, err := Evaluate(db, RuleSet{ Rules: []Rule{ {Field: "bogus", Operator: "is", Value: "x"}, }, }) if err == nil { t.Fatal("expected error for invalid field, got nil") } if !strings.Contains(err.Error(), "invalid field") { t.Errorf("error should mention 'invalid field': %v", err) } } // --------------------------------------------------------------------------- // SQL injection tests // --------------------------------------------------------------------------- func TestSQLInjection_FieldName(t *testing.T) { t.Parallel() _, _, err := BuildWhereClause([]Rule{ { Field: "title; DROP TABLE playlists", Operator: "is", Value: "x", }, }, MatchAll) if err == nil { t.Fatal( "expected error for injected field name, got nil", ) } if !strings.Contains(err.Error(), "invalid field") { t.Errorf("error should mention 'invalid field': %v", err) } } func TestSQLInjection_Value(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) // Value with SQL injection — should produce no error (safe // parameterization) and return 0 results. tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ { Field: "title", Operator: "is", Value: "'; DROP TABLE playlists; --", }, }, }) if err != nil { t.Fatalf("unexpected error with injection value: %v", err) } if len(tracks) != 0 { t.Errorf("got %d tracks, want 0", len(tracks)) } } // --------------------------------------------------------------------------- // ParseRuleSet tests // --------------------------------------------------------------------------- func TestParseRuleSet_Valid(t *testing.T) { t.Parallel() input := `{ "rules": [ {"field": "artist", "operator": "is", "value": "Queen"} ], "limit": 50, "sort_field": "year", "sort_dir": "DESC" }` rs, err := ParseRuleSet(input) if err != nil { t.Fatalf("ParseRuleSet: %v", err) } if len(rs.Rules) != 1 { t.Fatalf("got %d rules, want 1", len(rs.Rules)) } if rs.Rules[0].Field != "artist" { t.Errorf("Field = %q, want artist", rs.Rules[0].Field) } if rs.Limit != 50 { t.Errorf("Limit = %d, want 50", rs.Limit) } if rs.SortField != "year" { t.Errorf("SortField = %q, want year", rs.SortField) } if rs.SortDir != "DESC" { t.Errorf("SortDir = %q, want DESC", rs.SortDir) } } func TestParseRuleSet_Invalid(t *testing.T) { t.Parallel() _, err := ParseRuleSet("not json at all") if err == nil { t.Fatal("expected error for invalid JSON, got nil") } if !strings.Contains(err.Error(), "invalid smart playlist") { t.Errorf( "error should mention 'invalid smart playlist': %v", err, ) } } func TestParseRuleSet_EmptyRules(t *testing.T) { t.Parallel() rs, err := ParseRuleSet(`{"rules":[]}`) if err != nil { t.Fatalf("ParseRuleSet: %v", err) } if len(rs.Rules) != 0 { t.Errorf("got %d rules, want 0", len(rs.Rules)) } } // --------------------------------------------------------------------------- // Track field mapping test // --------------------------------------------------------------------------- func TestEvaluate_TrackFieldMapping(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) // Fetch Bohemian Rhapsody and verify all library.Track fields // are correctly populated. tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ { Field: "title", Operator: "is", Value: "Bohemian Rhapsody", }, }, }) if err != nil { t.Fatalf("Evaluate: %v", err) } if len(tracks) != 1 { t.Fatalf("got %d tracks, want 1", len(tracks)) } tr := tracks[0] if tr.TrackName != "Bohemian Rhapsody" { t.Errorf("TrackName = %q", tr.TrackName) } if tr.ArtistName != "Queen" { t.Errorf("ArtistName = %q", tr.ArtistName) } if tr.TrackLength != "354000" { t.Errorf("TrackLength = %q, want 354000", tr.TrackLength) } if tr.FilePath != "/music/queen/bohemian_rhapsody.mp3" { t.Errorf("FilePath = %q", tr.FilePath) } if tr.TrackNumber != 11 { t.Errorf("TrackNumber = %d, want 11", tr.TrackNumber) } if tr.DiscNumber != 1 { t.Errorf("DiscNumber = %d, want 1", tr.DiscNumber) } if tr.Album != "A Night at the Opera" { t.Errorf("Album = %q", tr.Album) } if len(tr.Genre) != 1 || tr.Genre[0] != "Rock" { t.Errorf("Genre = %v, want [Rock]", tr.Genre) } if tr.Year != 1975 { t.Errorf("Year = %d, want 1975", tr.Year) } if tr.Composer != "Freddie Mercury" { t.Errorf("Composer = %q", tr.Composer) } if tr.FileType != ".mp3" { t.Errorf("FileType = %q, want .mp3", tr.FileType) } if tr.SampleRate != 44100 { t.Errorf("SampleRate = %d", tr.SampleRate) } if tr.BitDepth != 16 { t.Errorf("BitDepth = %d", tr.BitDepth) } if tr.Channels != 2 { t.Errorf("Channels = %d", tr.Channels) } if tr.Bitrate != 320000 { t.Errorf("Bitrate = %d", tr.Bitrate) } if tr.FileSize != 8500000 { t.Errorf("FileSize = %d", tr.FileSize) } } // TestEvaluate_MultiGenreTrackGenreField verifies that a track with // multiple genres has them split correctly into the []string field. func TestEvaluate_MultiGenreTrackGenreField(t *testing.T) { t.Parallel() db := database.NewTestDB(t) seedSmartPlaylistData(t, db) tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ { Field: "title", Operator: "is", Value: "Under the Bridge", }, }, }) if err != nil { t.Fatalf("Evaluate: %v", err) } if len(tracks) != 1 { t.Fatalf("got %d tracks, want 1", len(tracks)) } tr := tracks[0] if len(tr.Genre) != 2 { t.Fatalf("Genre = %v, want 2 genres", tr.Genre) } hasRock := false hasAlt := false for _, g := range tr.Genre { if g == "Rock" { hasRock = true } if g == "Alternative" { hasAlt = true } } if !hasRock || !hasAlt { t.Errorf( "Genre = %v, want [Rock, Alternative]", tr.Genre, ) } } // TestEvaluate_YearUsesOriginalReleaseYear is a regression test for a // bug where the smart-playlist year filter tested recordings.year (the // file's ID3/reissue tag year) instead of the release group's original // first-release year, the way the canonical track_metadata view and the // UI do. That made a 1977 album owned as a 2010s reissue leak into a // "2010s" year filter even though it displays as 1977. func TestEvaluate_YearUsesOriginalReleaseYear(t *testing.T) { t.Parallel() db := database.NewTestDB(t) // One track: a 1977 album the user owns as a 2013 reissue. The // file's own year is 2013; the album's original-release year is // 1977, and that is what a year filter means. database.InsertTestTrack(t, db, database.TestTrack{ FilePath: "/music/b52s/rock_lobster.mp3", Title: "Rock Lobster", Artist: "The B-52's", Album: "Reissue Compilation", Year: 2013, LengthMs: 300000, }) if _, err := db.ExecContext( "UPDATE albums SET year = 2013, original_year = 1977", ); err != nil { t.Fatalf("set album years: %v", err) } // A "2010s" filter must NOT match — the album is originally from 1977. tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ {Field: "year", Operator: "between", Value: "2010,2019"}, }, }) if err != nil { t.Fatalf("Evaluate 2010s: %v", err) } if len(tracks) != 0 { t.Errorf( "2010s filter matched %d tracks, want 0 "+ "(reissue year leaked in)", len(tracks), ) } // A "1970s" filter must match — original_year is 1977. tracks, err = Evaluate(db, RuleSet{ Rules: []Rule{ {Field: "year", Operator: "between", Value: "1970,1979"}, }, }) if err != nil { t.Fatalf("Evaluate 1970s: %v", err) } if len(tracks) != 1 { t.Fatalf( "1970s filter matched %d tracks, want 1", len(tracks), ) } if tracks[0].Year != 1977 { t.Errorf("Year = %d, want 1977", tracks[0].Year) } // The release_year field, by contrast, tracks the specific release // owned (the 2013 reissue), so a 2010s filter on it MUST match. tracks, err = Evaluate(db, RuleSet{ Rules: []Rule{ {Field: "release_year", Operator: "between", Value: "2010,2019"}, }, }) if err != nil { t.Fatalf("Evaluate release_year 2010s: %v", err) } if len(tracks) != 1 { t.Fatalf( "release_year 2010s filter matched %d tracks, want 1", len(tracks), ) } // And a 1970s release_year filter must NOT match — the owned // release is from 2013. tracks, err = Evaluate(db, RuleSet{ Rules: []Rule{ {Field: "release_year", Operator: "between", Value: "1970,1979"}, }, }) if err != nil { t.Fatalf("Evaluate release_year 1970s: %v", err) } if len(tracks) != 0 { t.Errorf( "release_year 1970s filter matched %d tracks, want 0", len(tracks), ) } }