Browsing into a smart (live-query) collection via /Items?ParentId={boxsetId}
returned 0 children while the BoxSet's ChildCount showed a non-zero count:
handleBoxSetChildren only read materialized library_collection_items via
ListItems, which is empty for smart collections whose membership is a query
evaluated at read time. Confirmed live — smart "Directed by ..." collections
returned TotalRecordCount 0 while materialized collections returned their items.
Branch handleBoxSetChildren on catalog.IsLiveQueryType: smart collections
resolve their members through the query executor (mirroring the web API's
loadLiveCollectionItems — Normalize/Validate/ApplySmartCollectionItemLimit,
library-scope intersection, access-filtered Preview), then feed the resulting
content IDs into the same hydration path as stored collections. A malformed or
invalid query definition degrades to an empty page (never a 500).
Hoist the duplicated intersectCollectionLibraryIDs helper into
catalog.IntersectCollectionLibraryIDs so the web and compat resolvers share one
implementation.
Adds smart_boxset_test.go (smart children resolve in query order, library-scope
intersection reaches the executor, malformed-query and no-executor degrade to
empty).
137 lines
6.1 KiB
Go
137 lines
6.1 KiB
Go
package jellycompat
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
// fakeSmartExecutor is a smartCollectionQueryExecutor double returning a fixed
|
|
// member list, mimicking catalog.QueryExecutor.Preview's two-call contract
|
|
// (limit=1 probe, then limit=total).
|
|
type fakeSmartExecutor struct {
|
|
items []*models.MediaItem
|
|
previewCalls int
|
|
gotDef catalog.QueryDefinition
|
|
}
|
|
|
|
func (f *fakeSmartExecutor) Preview(_ context.Context, def catalog.QueryDefinition, _ catalog.AccessFilter, limit int) ([]*models.MediaItem, int, error) {
|
|
f.previewCalls++
|
|
f.gotDef = def
|
|
total := len(f.items)
|
|
if limit < total {
|
|
return f.items[:limit], total, nil
|
|
}
|
|
return f.items, total, nil
|
|
}
|
|
|
|
// TestHandleItems_SmartBoxSetChildrenResolveViaQuery pins the Wholphin fix: a
|
|
// smart (live-query) collection's BoxSet children resolve through the query
|
|
// executor in the collection's own order, instead of returning empty because
|
|
// nothing is materialized in library_collection_items.
|
|
func TestHandleItems_SmartBoxSetChildrenResolveViaQuery(t *testing.T) {
|
|
collections := &fakeCollectionSource{
|
|
collections: []*models.LibraryCollection{
|
|
{ID: "201", LibraryID: 1, Title: "Directed by X", Visibility: "visible", CollectionType: "smart", ItemCount: 2, QueryDefinition: json.RawMessage(`{}`)},
|
|
},
|
|
// No materialized items: smart collections store none.
|
|
}
|
|
exec := &fakeSmartExecutor{items: []*models.MediaItem{
|
|
{ContentID: "m-1", Type: "movie", Title: "Kill Bill"},
|
|
{ContentID: "m-2", Type: "movie", Title: "Pulp Fiction"},
|
|
}}
|
|
itemRepo := &fakeBatchItemRepo{items: map[string]*models.MediaItem{
|
|
"m-1": {ContentID: "m-1", Type: "movie", Title: "Kill Bill"},
|
|
"m-2": {ContentID: "m-2", Type: "movie", Title: "Pulp Fiction"},
|
|
}}
|
|
h := newCollectionsTestHandler(collections, []upstreamUserLibrary{{ID: 1, Name: "Movies", Type: "movies"}}, itemRepo)
|
|
h.queryExecutor = exec
|
|
|
|
parentID := h.codec.EncodeStringID(EncodedIDCollection, "201")
|
|
result := performItemsRequest(t, h, "/Items?ParentId="+parentID)
|
|
if len(result.Items) != 2 {
|
|
t.Fatalf("expected 2 smart-collection children, got %d: %+v", len(result.Items), result.Items)
|
|
}
|
|
if result.Items[0].Name != "Kill Bill" || result.Items[1].Name != "Pulp Fiction" {
|
|
t.Fatalf("expected smart query order, got %q,%q", result.Items[0].Name, result.Items[1].Name)
|
|
}
|
|
if result.Items[0].ParentID != parentID {
|
|
t.Fatalf("expected ParentId %s, got %s", parentID, result.Items[0].ParentID)
|
|
}
|
|
if exec.previewCalls == 0 {
|
|
t.Fatal("expected the query executor to be consulted for a smart collection")
|
|
}
|
|
}
|
|
|
|
// TestHandleItems_SmartBoxSetChildrenWithoutExecutorEmpty ensures a smart
|
|
// collection degrades to an empty page (never a 500) when no executor is wired.
|
|
func TestHandleItems_SmartBoxSetChildrenWithoutExecutorEmpty(t *testing.T) {
|
|
collections := &fakeCollectionSource{
|
|
collections: []*models.LibraryCollection{
|
|
{ID: "201", LibraryID: 1, Title: "Directed by X", Visibility: "visible", CollectionType: "smart", ItemCount: 5, QueryDefinition: json.RawMessage(`{}`)},
|
|
},
|
|
}
|
|
h := newCollectionsTestHandler(collections, []upstreamUserLibrary{{ID: 1, Name: "Movies", Type: "movies"}}, nil)
|
|
// queryExecutor intentionally left nil.
|
|
|
|
parentID := h.codec.EncodeStringID(EncodedIDCollection, "201")
|
|
result := performItemsRequest(t, h, "/Items?ParentId="+parentID)
|
|
if result.TotalRecordCount != 0 || len(result.Items) != 0 {
|
|
t.Fatalf("expected graceful empty result without executor, got %+v", result.Items)
|
|
}
|
|
}
|
|
|
|
// TestHandleItems_SmartBoxSetChildrenIntersectLibraryScope asserts the query's
|
|
// own library_ids are intersected with the collection's bound libraries before
|
|
// the executor runs, so a restricted collection cannot widen its scope.
|
|
func TestHandleItems_SmartBoxSetChildrenIntersectLibraryScope(t *testing.T) {
|
|
collections := &fakeCollectionSource{
|
|
collections: []*models.LibraryCollection{
|
|
{ID: "202", LibraryIDs: []int{1, 2}, Title: "Scoped", Visibility: "visible", CollectionType: "smart", ItemCount: 1, QueryDefinition: json.RawMessage(`{"library_ids":[2,3]}`)},
|
|
},
|
|
}
|
|
exec := &fakeSmartExecutor{items: []*models.MediaItem{{ContentID: "m-1", Type: "movie", Title: "Only Two"}}}
|
|
itemRepo := &fakeBatchItemRepo{items: map[string]*models.MediaItem{"m-1": {ContentID: "m-1", Type: "movie", Title: "Only Two"}}}
|
|
h := newCollectionsTestHandler(collections, []upstreamUserLibrary{
|
|
{ID: 1, Name: "Movies", Type: "movies"},
|
|
{ID: 2, Name: "Foreign", Type: "movies"},
|
|
}, itemRepo)
|
|
h.queryExecutor = exec
|
|
|
|
parentID := h.codec.EncodeStringID(EncodedIDCollection, "202")
|
|
result := performItemsRequest(t, h, "/Items?ParentId="+parentID)
|
|
if len(result.Items) != 1 || result.Items[0].Name != "Only Two" {
|
|
t.Fatalf("expected the single resolved child, got %+v", result.Items)
|
|
}
|
|
// query library_ids [2,3] ∩ collection [1,2] = [2].
|
|
if len(exec.gotDef.LibraryIDs) != 1 || exec.gotDef.LibraryIDs[0] != 2 {
|
|
t.Fatalf("expected executor to receive intersected library scope [2], got %v", exec.gotDef.LibraryIDs)
|
|
}
|
|
}
|
|
|
|
// TestHandleItems_SmartBoxSetMalformedQueryEmpty asserts a malformed query
|
|
// definition degrades to an empty page and never consults the executor (so it
|
|
// can never 500 a browse).
|
|
func TestHandleItems_SmartBoxSetMalformedQueryEmpty(t *testing.T) {
|
|
collections := &fakeCollectionSource{
|
|
collections: []*models.LibraryCollection{
|
|
{ID: "203", LibraryID: 1, Title: "Broken", Visibility: "visible", CollectionType: "smart", ItemCount: 9, QueryDefinition: json.RawMessage(`{bad`)},
|
|
},
|
|
}
|
|
exec := &fakeSmartExecutor{items: []*models.MediaItem{{ContentID: "m-1", Type: "movie", Title: "Nope"}}}
|
|
h := newCollectionsTestHandler(collections, []upstreamUserLibrary{{ID: 1, Name: "Movies", Type: "movies"}}, nil)
|
|
h.queryExecutor = exec
|
|
|
|
parentID := h.codec.EncodeStringID(EncodedIDCollection, "203")
|
|
result := performItemsRequest(t, h, "/Items?ParentId="+parentID)
|
|
if result.TotalRecordCount != 0 || len(result.Items) != 0 {
|
|
t.Fatalf("expected empty result for malformed query definition, got %+v", result.Items)
|
|
}
|
|
if exec.previewCalls != 0 {
|
|
t.Fatalf("executor must not run for a malformed query definition; got %d calls", exec.previewCalls)
|
|
}
|
|
}
|