Files
navidrome-navidrome/model/criteria/criteria_test.go
T
dualfroz 45e13950f3 fix(smartplaylist): don't let invalid rules hide all playlists - #6316
Empty nested groups were saved as {} and could not be read back, failing
every playlist query. Drop them while parsing, and log and skip unreadable
rules stored in the DB instead of failing the whole listing.

Signed-off-by: DualFroz <me@dualfroz.com>
2026-10-14 19:07:59 +02:00

452 lines
14 KiB
Go

package criteria
import (
"bytes"
"encoding/json"
"time"
"github.com/google/uuid"
. "github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
var _ = Describe("Criteria", func() {
var goObj Criteria
var jsonObj string
Context("with a complex criteria", func() {
BeforeEach(func() {
goObj = Criteria{
Expression: All{
Contains{"title": "love"},
NotContains{"title": "hate"},
Any{
IsNot{"artist": "u2"},
Is{"album": "best of"},
},
All{
StartsWith{"comment": "this"},
InTheRange{"year": []int{1980, 1990}},
IsNot{"genre": "Rock"},
Gt{"albumrating": 3},
},
},
Sort: "title",
Order: "asc",
Limit: 20,
Offset: 10,
}
var b bytes.Buffer
err := json.Compact(&b, []byte(`
{
"all": [
{ "contains": {"title": "love"} },
{ "notContains": {"title": "hate"} },
{ "any": [
{ "isNot": {"artist": "u2"} },
{ "is": {"album": "best of"} }
]
},
{ "all": [
{ "startsWith": {"comment": "this"} },
{ "inTheRange": {"year":[1980,1990]} },
{ "isNot": { "genre": "Rock" }},
{ "gt": { "albumrating": 3 } }
]
}
],
"sort": "title",
"order": "asc",
"limit": 20,
"offset": 10
}
`))
if err != nil {
panic(err)
}
jsonObj = b.String()
})
It("marshals to JSON", func() {
j, err := json.Marshal(goObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
})
It("is reversible to/from JSON", func() {
var newObj Criteria
err := json.Unmarshal([]byte(jsonObj), &newObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
j, err := json.Marshal(newObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
})
})
Context("with both top-level 'all' and 'any'", func() {
It("returns an error instead of silently dropping one of the groups", func() {
jsonStr := `{"any":[{"inPlaylist":{"path":"a.nsp"}}],"all":[{"notInPlaylist":{"path":"b.nsp"}}]}`
var c Criteria
err := json.Unmarshal([]byte(jsonStr), &c)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.And(gomega.ContainSubstring("all"), gomega.ContainSubstring("any")))
})
DescribeTable("rejects both keys even when one group is present but empty",
func(jsonStr string) {
var c Criteria
err := json.Unmarshal([]byte(jsonStr), &c)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.And(gomega.ContainSubstring("all"), gomega.ContainSubstring("any")))
},
Entry("empty any", `{"any":[],"all":[{"is":{"loved":true}}]}`),
Entry("empty all", `{"all":[],"any":[{"is":{"loved":true}}]}`),
Entry("null any", `{"any":null,"all":[{"is":{"loved":true}}]}`),
)
})
Context("with empty groups", func() {
DescribeTable("rejects criteria with no rules other than empty groups",
func(jsonStr string) {
var c Criteria
err := json.Unmarshal([]byte(jsonStr), &c)
gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("missing rules")))
},
Entry("empty object", `{"all":[{}],"sort":"+lastplayed","limit":10}`),
Entry("empty nested all", `{"all":[{"all":[]}],"sort":"+lastplayed","limit":10}`),
Entry("empty nested any", `{"any":[{"any":[]}]}`),
Entry("nested group holding an empty object", `{"all":[{"any":[{}]}]}`),
)
It("drops empty nested groups, so the rules can be read back after saving", func() {
var c Criteria
err := json.Unmarshal([]byte(`{"all":[{"is":{"loved":true}},{"any":[]}]}`), &c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(c.Expression).To(gomega.Equal(All{Is{"loved": true}}))
j, err := json.Marshal(c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.Equal(`{"all":[{"is":{"loved":true}}]}`))
})
})
Describe("LimitPercent", func() {
Describe("JSON round-trip", func() {
It("marshals and unmarshals limitPercent", func() {
goObj := Criteria{
Expression: All{Contains{"title": "love"}},
Sort: "title",
Order: "asc",
LimitPercent: 10,
}
j, err := json.Marshal(goObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.ContainSubstring(`"limitPercent":10`))
gomega.Expect(string(j)).ToNot(gomega.ContainSubstring(`"limit"`))
var newObj Criteria
err = json.Unmarshal(j, &newObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(newObj.LimitPercent).To(gomega.Equal(10))
gomega.Expect(newObj.Limit).To(gomega.Equal(0))
})
It("does not include limitPercent when zero", func() {
goObj := Criteria{
Expression: All{Contains{"title": "love"}},
Limit: 50,
}
j, err := json.Marshal(goObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.ContainSubstring(`"limit":50`))
gomega.Expect(string(j)).ToNot(gomega.ContainSubstring(`limitPercent`))
})
It("backward compatible: JSON with only limit still works", func() {
jsonStr := `{"all":[{"contains":{"title":"love"}}],"limit":20}`
var c Criteria
err := json.Unmarshal([]byte(jsonStr), &c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(c.Limit).To(gomega.Equal(20))
gomega.Expect(c.LimitPercent).To(gomega.Equal(0))
})
})
Describe("UnmarshalJSON clamping", func() {
It("clamps values above 100 to 100", func() {
jsonStr := `{"all":[{"contains":{"title":"love"}}],"limitPercent":150}`
var c Criteria
err := json.Unmarshal([]byte(jsonStr), &c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(c.LimitPercent).To(gomega.Equal(100))
})
It("clamps negative values to 0", func() {
jsonStr := `{"all":[{"contains":{"title":"love"}}],"limitPercent":-5}`
var c Criteria
err := json.Unmarshal([]byte(jsonStr), &c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(c.LimitPercent).To(gomega.Equal(0))
})
})
Describe("EffectiveLimit", func() {
It("returns fixed limit when Limit is set", func() {
c := Criteria{Limit: 50, LimitPercent: 10}
gomega.Expect(c.EffectiveLimit(1000)).To(gomega.Equal(50))
})
It("returns percentage-based limit", func() {
c := Criteria{LimitPercent: 10}
gomega.Expect(c.EffectiveLimit(450)).To(gomega.Equal(45))
})
It("returns minimum 1 when totalCount > 0 and percentage rounds to 0", func() {
c := Criteria{LimitPercent: 1}
gomega.Expect(c.EffectiveLimit(5)).To(gomega.Equal(1))
})
It("returns 0 when totalCount is 0", func() {
c := Criteria{LimitPercent: 10}
gomega.Expect(c.EffectiveLimit(0)).To(gomega.Equal(0))
})
It("returns 0 when no limit is set", func() {
c := Criteria{}
gomega.Expect(c.EffectiveLimit(1000)).To(gomega.Equal(0))
})
It("returns full count for 100%", func() {
c := Criteria{LimitPercent: 100}
gomega.Expect(c.EffectiveLimit(450)).To(gomega.Equal(450))
})
It("returns 1 for 1% of 50 items", func() {
c := Criteria{LimitPercent: 1}
gomega.Expect(c.EffectiveLimit(50)).To(gomega.Equal(1))
})
})
Describe("ResolveLimit", func() {
It("resolves percentage to absolute limit preserving LimitPercent", func() {
c := Criteria{LimitPercent: 10}
c.ResolveLimit(450)
gomega.Expect(c.Limit).To(gomega.Equal(45))
})
It("does nothing when Limit is already set", func() {
c := Criteria{Limit: 50, LimitPercent: 10}
c.ResolveLimit(1000)
gomega.Expect(c.Limit).To(gomega.Equal(50))
})
It("does nothing when no limit is configured", func() {
c := Criteria{}
c.ResolveLimit(1000)
gomega.Expect(c.Limit).To(gomega.Equal(0))
})
It("sets minimum 1 when percentage rounds to 0 and totalCount > 0", func() {
c := Criteria{LimitPercent: 1}
c.ResolveLimit(5)
gomega.Expect(c.Limit).To(gomega.Equal(1))
})
It("is idempotent when called twice", func() {
c := Criteria{LimitPercent: 10}
c.ResolveLimit(450)
c.ResolveLimit(450)
gomega.Expect(c.Limit).To(gomega.Equal(45))
})
})
Describe("IsPercentageLimit", func() {
It("returns true when LimitPercent is set and Limit is 0", func() {
c := Criteria{LimitPercent: 10}
gomega.Expect(c.IsPercentageLimit()).To(gomega.BeTrue())
})
It("returns false when Limit is set", func() {
c := Criteria{Limit: 50, LimitPercent: 10}
gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse())
})
It("returns false when neither is set", func() {
c := Criteria{}
gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse())
})
It("returns false when LimitPercent is out of range", func() {
c := Criteria{LimitPercent: 150}
gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse())
})
})
})
Describe("refreshDelay", func() {
newCriteria := func(extra string) []byte {
return []byte(`{"all":[{"is":{"loved":true}}]` + extra + `}`)
}
It("unmarshals a valid refreshDelay", func() {
var c Criteria
gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1d"`), &c)).To(gomega.Succeed())
gomega.Expect(c.RefreshDelay).To(gomega.Equal(24 * time.Hour))
})
It("supports week units", func() {
var c Criteria
gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1w"`), &c)).To(gomega.Succeed())
gomega.Expect(c.RefreshDelay).To(gomega.Equal(7 * 24 * time.Hour))
})
It("leaves RefreshDelay zero when absent", func() {
var c Criteria
gomega.Expect(json.Unmarshal(newCriteria(``), &c)).To(gomega.Succeed())
gomega.Expect(c.RefreshDelay).To(gomega.BeZero())
})
It("rejects an invalid refreshDelay", func() {
var c Criteria
err := json.Unmarshal(newCriteria(`,"refreshDelay":"tomorrow"`), &c)
gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay")))
})
It("rejects a negative refreshDelay", func() {
var c Criteria
err := json.Unmarshal(newCriteria(`,"refreshDelay":"-1h"`), &c)
gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay")))
})
It("marshals RefreshDelay back as a duration string", func() {
c := Criteria{
Expression: All{Is{"loved": true}},
RefreshDelay: 24 * time.Hour,
}
j, err := json.Marshal(c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.ContainSubstring(`"refreshDelay":"1d"`))
})
It("omits refreshDelay from JSON when zero", func() {
c := Criteria{Expression: All{Is{"loved": true}}}
j, err := json.Marshal(c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).ToNot(gomega.ContainSubstring("refreshDelay"))
})
It("round-trips through marshal and unmarshal", func() {
c := Criteria{
Expression: All{Is{"loved": true}},
RefreshDelay: 36 * time.Hour,
}
j, err := json.Marshal(c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
var c2 Criteria
gomega.Expect(json.Unmarshal(j, &c2)).To(gomega.Succeed())
gomega.Expect(c2.RefreshDelay).To(gomega.Equal(36 * time.Hour))
})
})
Context("with child playlists", func() {
var (
topLevelInPlaylistID string
topLevelInPlaylistPath string
topLevelNotInPlaylistID string
nestedAnyInPlaylistID string
nestedAnyNotInPlaylistID string
nestedAllInPlaylistID string
nestedAllNotInPlaylistID string
nestedAnyNotInPlaylistPath string
)
BeforeEach(func() {
topLevelInPlaylistID = uuid.NewString()
topLevelInPlaylistPath = "./test.nsp"
topLevelNotInPlaylistID = uuid.NewString()
nestedAnyInPlaylistID = uuid.NewString()
nestedAnyNotInPlaylistID = uuid.NewString()
nestedAnyNotInPlaylistPath = "../not-in-playlist.m3u"
nestedAllInPlaylistID = uuid.NewString()
nestedAllNotInPlaylistID = uuid.NewString()
goObj = Criteria{
Expression: All{
InPlaylist{"id": topLevelInPlaylistID},
InPlaylist{"path": topLevelInPlaylistPath},
NotInPlaylist{"id": topLevelNotInPlaylistID},
Any{
InPlaylist{"id": nestedAnyInPlaylistID},
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
NotInPlaylist{"path": nestedAnyNotInPlaylistPath},
},
All{
InPlaylist{"id": nestedAllInPlaylistID},
NotInPlaylist{"id": nestedAllNotInPlaylistID},
},
},
}
})
It("extracts all child smart playlist IDs from expression criteria", func() {
ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
})
It("extracts all child smart playlist paths from expression criteria", func() {
paths := goObj.ChildPlaylistPaths()
gomega.Expect(paths).To(gomega.ConsistOf(topLevelInPlaylistPath, nestedAnyNotInPlaylistPath))
})
It("ignores empty child playlist paths", func() {
c := Criteria{Expression: All{InPlaylist{"path": ""}, NotInPlaylist{"path": ""}}}
gomega.Expect(c.ChildPlaylistPaths()).To(gomega.BeEmpty())
})
It("ignores empty child playlist ids", func() {
c := Criteria{Expression: All{InPlaylist{"id": ""}, NotInPlaylist{"id": ""}}}
gomega.Expect(c.ChildPlaylistIds()).To(gomega.BeEmpty())
})
It("extracts child smart playlist IDs from deeply nested expression", func() {
goObj = Criteria{
Expression: Any{
Any{
All{
Any{
InPlaylist{"id": nestedAnyInPlaylistID},
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
Any{
All{
InPlaylist{"id": nestedAllInPlaylistID},
NotInPlaylist{"id": nestedAllNotInPlaylistID},
},
},
},
},
},
},
}
ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.ConsistOf(nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
})
It("returns empty list when no child playlist IDs are present", func() {
ids := Criteria{}.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.BeEmpty())
})
It("returns empty list for leaf expressions", func() {
ids := Criteria{Expression: Is{"title": "Low Rider"}}.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.BeEmpty())
})
It("deduplicates repeated playlist IDs", func() {
sharedID := uuid.NewString()
goObj = Criteria{
Expression: All{
InPlaylist{"id": sharedID},
Any{
InPlaylist{"id": sharedID},
NotInPlaylist{"id": sharedID},
},
},
}
ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.Equal([]string{sharedID}))
})
})
})