navidrome/model/criteria/sort_test.go
Deluan Quintão 1bd736dae9
refactor: centralize criteria sort parsing and extract smart playlist logic (#5415)
* test: add tests for recordingdate alias resolution in smart playlists

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: update FieldInfo structure and simplify fieldMap initialization

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: move sort parsing logic from persistence to criteria package

Extracted sort field parsing, validation, and direction handling from
persistence/criteria_sql.go into model/criteria/sort.go. The new
OrderByFields method on Criteria parses the Sort/Order strings into
validated SortField structs (field name + direction), resolving aliases
and handling +/- prefixes and order inversion. The persistence layer now
consumes these parsed fields and only handles SQL expression mapping.
This centralizes sort parsing to enforce consistent implementations.

* refactor: standardize field access in smartPlaylistCriteria structure

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: add ResolveLimit method to Criteria

Moved the percentage-limit resolution logic from playlist_repository
into Criteria.ResolveLimit, replacing the 3-line mutate-after-query
pattern with a single method call. The method preserves LimitPercent
rather than zeroing it, since IsPercentageLimit already returns false
once Limit is set, making the clear redundant and lossy.

* refactor: improve child playlist loading and error handling in refresh logic

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: extract smart playlist logic to dedicated files

Moved refreshSmartPlaylist, addSmartPlaylistAnnotationJoins, and
addCriteria methods from playlist_repository.go to a new
smart_playlist_repository.go file. Extracted all smart playlist tests
to smart_playlist_repository_test.go. Added DeferCleanup to the
"valid rules" test to fix ordering flakiness when Ginkgo randomizes
test execution across files.

* refactor: break refreshSmartPlaylist into smaller focused methods

Split the monolithic refreshSmartPlaylist method into discrete helpers
for readability: shouldRefreshSmartPlaylist for guard checks,
refreshChildPlaylists for recursive dependency refresh,
resolvePercentageLimit for count-based limit resolution,
buildSmartPlaylistQuery for assembling the SELECT with joins, and
addMediaFileAnnotationJoin to DRY up the repeated annotation join clause.

* refactor: deduplicate child playlist IDs in Criteria

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: simplify withSmartPlaylistOwner to accept model.User

Replaced separate ownerID string and ownerIsAdmin bool parameters with a
single model.User struct, reducing the field count in smartPlaylistCriteria
and making the option function signature clearer. Updated all call sites
and tests accordingly.

* fix: handle empty sort fields and propagate child playlist load errors

OrderByFields now falls back to [{title, asc}] when all user-supplied
sort fields are invalid, preventing empty ORDER BY clauses that would
produce invalid SQL in row_number() window functions. Also restored the
original behavior where a DB error loading child playlists aborts the
parent smart playlist refresh, by making refreshChildPlaylists return a
bool.

* refactor: log warning when no valid sort fields are found

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-26 14:49:59 -04:00

104 lines
3.3 KiB
Go

package criteria
import (
. "github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
var _ = Describe("OrderByFields", func() {
It("defaults to title ascending when Sort is empty", func() {
c := Criteria{}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
})
It("parses a single field", func() {
c := Criteria{Sort: "title"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
})
It("parses descending prefix", func() {
c := Criteria{Sort: "-rating"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "rating", Desc: true}}))
})
It("parses ascending prefix", func() {
c := Criteria{Sort: "+title"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
})
It("parses multiple comma-separated fields", func() {
c := Criteria{Sort: "title,-rating"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
{Field: "title", Desc: false},
{Field: "rating", Desc: true},
}))
})
It("inverts directions when Order is desc", func() {
c := Criteria{Sort: "-date,title", Order: "desc"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
{Field: "date", Desc: false},
{Field: "title", Desc: true},
}))
})
It("skips invalid fields", func() {
c := Criteria{Sort: "bogus,title"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
})
It("falls back to title when all fields are invalid", func() {
c := Criteria{Sort: "bogus,invalid"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
})
It("resolves tag aliases (albumtype -> releasetype)", func() {
c := Criteria{Sort: "albumtype"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "releasetype", Desc: false}}))
})
It("resolves field aliases (recordingdate -> date)", func() {
AddTagNames([]string{"recordingdate"})
c := Criteria{Sort: "recordingdate"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "date", Desc: false}}))
})
It("handles the random field", func() {
c := Criteria{Sort: "random"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "random", Desc: false}}))
})
It("ignores invalid Order value", func() {
c := Criteria{Sort: "-title", Order: "invalid"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: true}}))
})
It("handles whitespace in fields", func() {
c := Criteria{Sort: " title , -rating "}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
{Field: "title", Desc: false},
{Field: "rating", Desc: true},
}))
})
It("skips empty parts from trailing commas", func() {
c := Criteria{Sort: "title,,rating,"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
{Field: "title", Desc: false},
{Field: "rating", Desc: false},
}))
})
})
var _ = Describe("SortFieldNames", func() {
It("returns canonical field names", func() {
c := Criteria{Sort: "title,-rating,albumtype"}
gomega.Expect(c.SortFieldNames()).To(gomega.Equal([]string{"title", "rating", "releasetype"}))
})
It("defaults to title when Sort is empty", func() {
c := Criteria{}
gomega.Expect(c.SortFieldNames()).To(gomega.Equal([]string{"title"}))
})
})