Files
silo-server/internal/watchlist/maintainer_test.go
QuickandClaude Fable 5 28340ad6f5 feat(watchlist): hide fully-watched series instead of removing them
Removing a series from the watchlist on full watch stranded it once new
episodes aired: nothing ever re-added it. Split the behavior by type:

- watchlist.Maintainer now auto-removes only fully-watched movies (still
  propagating removals to connected providers).
- Series stay on the watchlist; the new catalog.WatchlistVisibility
  filter hides series whose available episodes are all completed on the
  display surfaces (sections rail, catalog watchlist source, GET
  /watchlist). A newly added episode makes the series reappear on the
  next fetch, and nothing is synced upstream since the entry never
  leaves the list.

Sync, recommendations, notifications, and the watchlist check endpoint
intentionally keep seeing the full list. The filter honors the existing
per-profile remove-watched preference and uses batch lookups only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:04:42 -04:00

160 lines
5.5 KiB
Go

package watchlist
import (
"context"
"errors"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/watchsync"
)
type fakeStore struct {
removeWatched bool
watchlist map[string]bool
removed []string
}
func (f *fakeStore) RemoveWatchedFromWatchlist(context.Context, string) (bool, error) {
return f.removeWatched, nil
}
func (f *fakeStore) InWatchlist(_ context.Context, _ string, mediaItemID string) (bool, error) {
return f.watchlist[mediaItemID], nil
}
func (f *fakeStore) RemoveFromWatchlist(_ context.Context, _ string, mediaItemID string) error {
delete(f.watchlist, mediaItemID)
f.removed = append(f.removed, mediaItemID)
return nil
}
type fakeItems map[string]*models.MediaItem
func (f fakeItems) GetByIDs(_ context.Context, ids []string) ([]*models.MediaItem, error) {
var out []*models.MediaItem
seen := make(map[string]bool, len(ids))
for _, id := range ids {
if item, ok := f[id]; ok && !seen[id] {
seen[id] = true
out = append(out, item)
}
}
return out, nil
}
type fakeDispatcher struct {
events []watchsync.LocalListEvent
}
func (f *fakeDispatcher) HandleLocalListEvent(_ context.Context, event watchsync.LocalListEvent) error {
f.events = append(f.events, event)
return nil
}
func newMaintainer(store *fakeStore, items fakeItems, dispatcher *fakeDispatcher) *Maintainer {
return &Maintainer{
storeFor: func(context.Context, int) (maintainerStore, error) { return store, nil },
items: items,
dispatcher: dispatcher,
}
}
func TestMaintainerRemovesWatchedMovie(t *testing.T) {
store := &fakeStore{removeWatched: true, watchlist: map[string]bool{"movie-1": true}}
items := fakeItems{"movie-1": {ContentID: "movie-1", Type: "movie", Title: "M", ImdbID: "tt1"}}
dispatcher := &fakeDispatcher{}
m := newMaintainer(store, items, dispatcher)
if err := m.process(context.Background(), 7, "profile-1", []string{"movie-1"}); err != nil {
t.Fatalf("process: %v", err)
}
if store.watchlist["movie-1"] {
t.Fatal("watched movie should be removed from the watchlist")
}
if len(dispatcher.events) != 1 || dispatcher.events[0].List != watchsync.ListKindWatchlist ||
dispatcher.events[0].Change != watchsync.ListChangeRemoved {
t.Fatalf("expected one watchlist-removed event, got %+v", dispatcher.events)
}
}
func TestMaintainerSkipsMovieNotOnWatchlist(t *testing.T) {
store := &fakeStore{removeWatched: true, watchlist: map[string]bool{}}
items := fakeItems{"movie-1": {ContentID: "movie-1", Type: "movie"}}
dispatcher := &fakeDispatcher{}
m := newMaintainer(store, items, dispatcher)
if err := m.process(context.Background(), 7, "profile-1", []string{"movie-1"}); err != nil {
t.Fatalf("process: %v", err)
}
if len(store.removed) != 0 || len(dispatcher.events) != 0 {
t.Fatalf("nothing should happen for a movie not on the watchlist: removed=%v events=%v", store.removed, dispatcher.events)
}
}
func TestMaintainerNeverRemovesSeries(t *testing.T) {
// Even a series explicitly marked watched stays on the watchlist: the read
// paths hide it instead, so newly added episodes make it reappear.
items := fakeItems{"series-1": {ContentID: "series-1", Type: "series", TvdbID: "99"}}
store := &fakeStore{removeWatched: true, watchlist: map[string]bool{"series-1": true}}
dispatcher := &fakeDispatcher{}
m := newMaintainer(store, items, dispatcher)
if err := m.process(context.Background(), 7, "profile-1", []string{"series-1"}); err != nil {
t.Fatalf("process: %v", err)
}
if !store.watchlist["series-1"] {
t.Fatal("a fully-watched series must stay on the watchlist (hidden at read time)")
}
if len(dispatcher.events) != 0 {
t.Fatalf("no removal events expected for series, got %+v", dispatcher.events)
}
}
func TestMaintainerIgnoresEpisodeCompletions(t *testing.T) {
// Episode IDs don't resolve in the media-items table; completions must be
// ignored without error and without touching the series watchlist entry.
items := fakeItems{"series-1": {ContentID: "series-1", Type: "series"}}
store := &fakeStore{removeWatched: true, watchlist: map[string]bool{"series-1": true}}
m := newMaintainer(store, items, &fakeDispatcher{})
if err := m.process(context.Background(), 7, "profile-1", []string{"ep-1", "ep-2"}); err != nil {
t.Fatalf("process: %v", err)
}
if !store.watchlist["series-1"] || len(store.removed) != 0 {
t.Fatalf("episode completions must not remove anything: removed=%v", store.removed)
}
}
func TestMaintainerRespectsPreferenceOff(t *testing.T) {
store := &fakeStore{removeWatched: false, watchlist: map[string]bool{"movie-1": true}}
items := fakeItems{"movie-1": {ContentID: "movie-1", Type: "movie"}}
dispatcher := &fakeDispatcher{}
m := newMaintainer(store, items, dispatcher)
if err := m.process(context.Background(), 7, "profile-1", []string{"movie-1"}); err != nil {
t.Fatalf("process: %v", err)
}
if !store.watchlist["movie-1"] || len(store.removed) != 0 {
t.Fatal("preference off must leave the watchlist untouched")
}
}
type erroringItems struct{ err error }
func (e erroringItems) GetByIDs(context.Context, []string) ([]*models.MediaItem, error) {
return nil, e.err
}
func TestMaintainerPropagatesItemLookupError(t *testing.T) {
boom := errors.New("db down")
store := &fakeStore{removeWatched: true, watchlist: map[string]bool{}}
m := &Maintainer{
storeFor: func(context.Context, int) (maintainerStore, error) { return store, nil },
items: erroringItems{err: boom},
}
if err := m.process(context.Background(), 7, "profile-1", []string{"x"}); err == nil {
t.Fatal("a transient catalog lookup error must propagate, not be swallowed")
}
}