Files
silo-server/internal/catalog/favorites_browse_test.go
T

259 lines
9.1 KiB
Go

package catalog
import (
"strings"
"testing"
)
// TestBuildBrowseFavoritesSQL_JoinsUserFavorites pins the SQL shape emitted by
// buildBrowseFavoritesPlan. The plan must JOIN user_favorites with media_items
// so type/library/sort filters and pagination push into a single round trip
// (audit 2026-05-01 §3.6 / catalog SQL plan task 4.2).
func TestBuildBrowseFavoritesSQL_JoinsUserFavorites(t *testing.T) {
plan, err := buildBrowseFavoritesPlan(BrowseFavoritesFilters{
UserID: 7,
ProfileID: "p1",
ItemType: "movie",
AllowedLibraryIDs: []int{1, 2},
Limit: 20,
Offset: 0,
})
if err != nil {
t.Fatalf("buildBrowseFavoritesPlan: %v", err)
}
sql, _ := plan.pagedSQL()
if !strings.Contains(sql, "FROM user_favorites uf") {
t.Fatalf("expected user_favorites in FROM; got %s", sql)
}
if !strings.Contains(sql, "JOIN media_items mi ON mi.content_id = uf.media_item_id") {
t.Fatalf("expected JOIN to media_items; got %s", sql)
}
if !strings.Contains(sql, "uf.user_id = $") {
t.Fatalf("expected user_id filter as bound param; got %s", sql)
}
if !strings.Contains(sql, "uf.profile_id = $") {
t.Fatalf("expected profile_id filter as bound param; got %s", sql)
}
if !strings.Contains(sql, "mi.type = $") {
t.Fatalf("expected single-type filter as bound param; got %s", sql)
}
if !strings.Contains(sql, "media_folder_id = ANY($") {
t.Fatalf("expected library filter via ANY; got %s", sql)
}
if !strings.Contains(sql, "COUNT(*) OVER ()") {
t.Fatalf("expected window count; got %s", sql)
}
if !strings.Contains(sql, "ORDER BY uf.added_at") {
t.Fatalf("expected default sort by uf.added_at; got %s", sql)
}
}
func TestBuildBrowseFavoritesSQL_MultiTypeUsesIN(t *testing.T) {
plan, err := buildBrowseFavoritesPlan(BrowseFavoritesFilters{
UserID: 1,
ProfileID: "p1",
ItemType: "movie,series",
Limit: 10,
})
if err != nil {
t.Fatalf("buildBrowseFavoritesPlan: %v", err)
}
sql, _ := plan.pagedSQL()
if !strings.Contains(sql, "mi.type = ANY($") {
t.Fatalf("expected multi-type = ANY clause; got %s", sql)
}
}
func TestBuildBrowseFavoritesSQL_TitleSortUsesMediaItemsColumns(t *testing.T) {
plan, err := buildBrowseFavoritesPlan(BrowseFavoritesFilters{
UserID: 1,
ProfileID: "p1",
SortField: "sort_title",
SortOrder: "asc",
Limit: 10,
})
if err != nil {
t.Fatalf("buildBrowseFavoritesPlan: %v", err)
}
sql, _ := plan.pagedSQL()
if !strings.Contains(sql, "LOWER(COALESCE(NULLIF(BTRIM(mi.sort_title), ''), mi.title))") {
t.Fatalf("expected sort_title to fall back to mi.title; got %s", sql)
}
if !strings.Contains(sql, "ASC") {
t.Fatalf("expected ASC direction; got %s", sql)
}
}
func TestBuildBrowseFavoritesSQL_DisabledLibrariesNotExists(t *testing.T) {
plan, err := buildBrowseFavoritesPlan(BrowseFavoritesFilters{
UserID: 1,
ProfileID: "p1",
DisabledLibraryIDs: []int{99},
Limit: 10,
})
if err != nil {
t.Fatalf("buildBrowseFavoritesPlan: %v", err)
}
sql, _ := plan.pagedSQL()
if !strings.Contains(sql, "NOT EXISTS (") {
t.Fatalf("expected NOT EXISTS for disabled libraries; got %s", sql)
}
}
func TestBuildBrowseFavoritesSQL_EmptyAllowedLibrariesEarlyEmpty(t *testing.T) {
_, err := buildBrowseFavoritesPlan(BrowseFavoritesFilters{
UserID: 1,
ProfileID: "p1",
AllowedLibraryIDs: []int{},
Limit: 10,
})
if err == nil {
t.Fatalf("expected error or sentinel for empty allowed libraries; got nil")
}
if err != errBrowseFavoritesEmpty {
t.Fatalf("expected errBrowseFavoritesEmpty; got %v", err)
}
}
// TestBuildBrowseFavoritesPlan_LibraryIDIntersectsAllowedLibraries pins that a
// client request for ParentId=99 against an allowlist that does NOT contain 99
// emits BOTH predicates (LibraryID + AllowedLibraryIDs). The AND combination
// is what enforces the intersection: if a future refactor drops one
// predicate, the request would leak data from libraries the viewer cannot
// reach. (code review on 614e45ac, security-adjacent gap.)
func TestBuildBrowseFavoritesPlan_LibraryIDIntersectsAllowedLibraries(t *testing.T) {
plan, err := buildBrowseFavoritesPlan(BrowseFavoritesFilters{
UserID: 1,
ProfileID: "p1",
LibraryID: 99, // user is asking for a library...
AllowedLibraryIDs: []int{1, 2}, // ...that's not in their allowlist
Limit: 20,
})
if err != nil {
t.Fatalf("buildBrowseFavoritesPlan error: %v", err)
}
sql, args := plan.pagedSQL()
// The LibraryID predicate goes through media_folder_id = $N (bound).
if !strings.Contains(sql, "mil_one.media_folder_id = $") {
t.Fatalf("expected LibraryID predicate (mil_one.media_folder_id = $N); got %s", sql)
}
// The AllowedLibraryIDs predicate goes through media_folder_id = ANY($N).
if !strings.Contains(sql, "mil.media_folder_id = ANY($") {
t.Fatalf("expected AllowedLibraryIDs predicate (mil.media_folder_id = ANY($N)); got %s", sql)
}
// Belt-and-suspenders: both predicates must be present, so the unqualified
// substring should appear at least twice (once per EXISTS).
occurrences := strings.Count(sql, "media_folder_id")
if occurrences < 2 {
t.Fatalf("expected >= 2 media_folder_id predicates (LibraryID + AllowedLibraryIDs); got %d in:\n%s", occurrences, sql)
}
// Both args must be bound. LibraryID is int=99; AllowedLibraryIDs is []int{1,2}.
foundLibraryID, foundAllowed := false, false
for _, a := range args {
if v, ok := a.(int); ok && v == 99 {
foundLibraryID = true
}
if v, ok := a.([]int); ok && len(v) == 2 && v[0] == 1 && v[1] == 2 {
foundAllowed = true
}
}
if !foundLibraryID {
t.Fatalf("expected LibraryID=99 in bound args; got %v", args)
}
if !foundAllowed {
t.Fatalf("expected AllowedLibraryIDs=[1 2] in bound args; got %v", args)
}
}
// TestBuildBrowseFavoritesPlan_NamePrefixDualColumnLike pins the dual-column
// LIKE shape that powers /Items?Filters=IsFavorite&NameStartsWith=Star. The
// pattern must be anchored to the prefix only (no leading wildcard) so the
// idx_media_items_sort_key index can serve the lookup. (code review on
// 614e45ac, formerly uncovered.)
func TestBuildBrowseFavoritesPlan_NamePrefixDualColumnLike(t *testing.T) {
plan, err := buildBrowseFavoritesPlan(BrowseFavoritesFilters{
UserID: 1,
ProfileID: "p1",
NamePrefix: "Star",
Limit: 20,
})
if err != nil {
t.Fatalf("buildBrowseFavoritesPlan error: %v", err)
}
sql, args := plan.pagedSQL()
if !strings.Contains(sql, "LIKE") {
t.Fatalf("expected LIKE for NamePrefix; got %s", sql)
}
// The dual-column form ORs the sort-key expression against LOWER(title)
// so titles without a curated sort_title still match AND both arms are
// sargable: first arm via idx_media_items_sort_key (migration 102),
// second arm via idx_media_items_search_exact_title (migration 001).
// Pin both arms.
if !strings.Contains(sql, "LOWER(COALESCE(NULLIF(BTRIM(mi.sort_title), ''), mi.title)) LIKE") {
t.Fatalf("expected sort-key LIKE arm matching idx_media_items_sort_key; got %s", sql)
}
if !strings.Contains(sql, "LOWER(mi.title) LIKE") {
t.Fatalf("expected mi.title LIKE arm; got %s", sql)
}
// Pattern must escape backslash to defuse user-supplied %/_ wildcards.
if !strings.Contains(sql, "ESCAPE '\\'") {
t.Fatalf("expected ESCAPE '\\\\' clause; got %s", sql)
}
// Verify the bound pattern is "star%" — anchored prefix, lowercased, no
// leading wildcard. A leading "%" would defeat the index and let users
// substring-search the whole catalog.
foundPrefixArg := false
for _, a := range args {
s, ok := a.(string)
if !ok {
continue
}
if strings.HasPrefix(s, "%") {
t.Fatalf("NamePrefix arg must not have leading wildcard; got %q", s)
}
if strings.HasPrefix(strings.ToLower(s), "star") && strings.HasSuffix(s, "%") {
foundPrefixArg = true
}
}
if !foundPrefixArg {
t.Fatalf("expected 'star%%'-style arg in args slice; got %v", args)
}
}
// TestBuildBrowseFavoritesPlan_NormalizesNegativeOffset pins that
// buildBrowseFavoritesPlan floors a caller-supplied negative Offset to 0
// in the returned plan. BrowseFavorites computes HasMore from plan.offset
// (not the caller-supplied filters.Offset) so a negative offset cannot
// poison the HasMore arithmetic — without this normalization a caller
// passing Offset=-5 would see HasMore = total > -5 + len(items), which
// is true essentially always and would lie to clients about there being
// more pages when the entire result set was already returned.
//
// Regression guard for the post-perf-overhaul code review (Cursor low).
func TestBuildBrowseFavoritesPlan_NormalizesNegativeOffset(t *testing.T) {
plan, err := buildBrowseFavoritesPlan(BrowseFavoritesFilters{
UserID: 1,
ProfileID: "p1",
Limit: 20,
Offset: -5,
})
if err != nil {
t.Fatalf("buildBrowseFavoritesPlan error: %v", err)
}
if plan.offset != 0 {
t.Fatalf("expected negative offset to normalize to 0 in plan; got %d", plan.offset)
}
// pagedSQL binds plan.offset as the OFFSET argument.
_, args := plan.pagedSQL()
if len(args) < 2 {
t.Fatalf("expected at least limit + offset args; got %v", args)
}
offsetArg := args[len(args)-1]
if offsetArg != 0 {
t.Fatalf("expected OFFSET arg to be normalized 0; got %v", offsetArg)
}
}