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>
452 lines
14 KiB
Go
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}))
|
|
})
|
|
})
|
|
})
|