Files
silo-server/internal/api/handlers/items_user_data_test.go
QuickandGitHub cac435c4b9 Fix watch-state unwatch sync across user data and Jellyfin mappings (#179)
* Refine playback session handling and API responses

* fix(watchstate): harden completed-history visibility
2026-06-18 19:17:22 -04:00

219 lines
7.2 KiB
Go

package handlers
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
func TestGetLeafUserDataUsesEbookReaderProgress(t *testing.T) {
handler := &ItemsHandler{
ebookProgressStore: &fakeEbookReaderProgressLister{
progress: map[string]EbookReaderProgress{
"ebook-progress": {
UserID: 7,
ProfileID: "profile-1",
ContentID: "ebook-progress",
Progress: 0.42,
UpdatedAt: time.Date(2026, 6, 5, 10, 0, 0, 0, time.UTC),
},
"ebook-complete": {
UserID: 7,
ProfileID: "profile-1",
ContentID: "ebook-complete",
Progress: 0.93,
UpdatedAt: time.Date(2026, 6, 5, 11, 0, 0, 0, time.UTC),
},
},
},
}
req := httptest.NewRequest("GET", "/items/ebook-progress", nil)
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7})
ctx = apimw.SetProfileID(ctx, "profile-1")
req = req.WithContext(ctx)
progress := handler.getLeafUserData(req, "ebook-progress", "ebook")
if progress == nil || progress.Played || !progress.IsInProgress || progress.PositionSeconds != 0.42 || progress.DurationSeconds != 1 {
t.Fatalf("partial ebook user data = %#v", progress)
}
complete := handler.getLeafUserData(req, "ebook-complete", "ebook")
if complete == nil || !complete.Played || complete.IsInProgress {
t.Fatalf("completed ebook user data = %#v", complete)
}
}
// Audiobooks resolve user_data through the same watch-progress store as
// movies/episodes (not the ebook reader store), so an in-progress audiobook
// reports its saved position_seconds — which is what lets the player resume.
func TestGetLeafUserDataReturnsAudiobookProgress(t *testing.T) {
store := newPlaybackTestStore(t)
if err := store.SetProgress(context.Background(), "profile-1", "audiobook-1", 1234, 5000, userstore.ProgressThresholds{}); err != nil {
t.Fatalf("seed progress: %v", err)
}
handler := &ItemsHandler{storeProvider: testUserStoreProvider{store: store}}
req := httptest.NewRequest("GET", "/watch/audiobook-1", nil)
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
ctx = apimw.SetProfileID(ctx, "profile-1")
req = req.WithContext(ctx)
progress := handler.getLeafUserData(req, "audiobook-1", "audiobook")
if progress == nil {
t.Fatal("audiobook user data = nil, want saved progress")
}
if progress.PositionSeconds != 1234 {
t.Fatalf("PositionSeconds = %v, want 1234", progress.PositionSeconds)
}
}
func TestGetLeafUserDataUsesCompletedHistoryWhenProgressMissing(t *testing.T) {
store := newPlaybackTestStore(t)
addCompletedHistoryForUserDataTest(t, store, "movie-history-only")
handler := &ItemsHandler{storeProvider: testUserStoreProvider{store: store}}
req := authorizedUserDataRequest()
userData := handler.getLeafUserData(req, "movie-history-only", "movie")
if userData == nil {
t.Fatal("movie user data = nil, want history-backed watched state")
}
if !userData.Played {
t.Fatalf("Played = false, want true from completed history")
}
if userData.PositionSeconds != 0 || userData.IsInProgress {
t.Fatalf("history-only user data = %+v, want no resume position", userData)
}
}
func TestGetLeafUserDataPreservesResumeWhenCompletedHistoryExists(t *testing.T) {
store := newPlaybackTestStore(t)
if err := store.SetProgressAt(
context.Background(),
"profile-1",
"movie-rewatch",
600,
7200,
false,
time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC),
); err != nil {
t.Fatalf("seed progress: %v", err)
}
addCompletedHistoryForUserDataTest(t, store, "movie-rewatch")
handler := &ItemsHandler{storeProvider: testUserStoreProvider{store: store}}
req := authorizedUserDataRequest()
userData := handler.getLeafUserData(req, "movie-rewatch", "movie")
if userData == nil {
t.Fatal("movie user data = nil, want progress-backed user data")
}
if !userData.Played {
t.Fatalf("Played = false, want true from completed history")
}
if userData.PositionSeconds != 600 || userData.DurationSeconds != 7200 || !userData.IsInProgress {
t.Fatalf("resume fields = %+v, want in-progress resume preserved", userData)
}
}
func TestGetAggregateUserDataReturnsNilWhenProgressBatchFails(t *testing.T) {
store := &failingBatchProgressStore{}
handler := &ItemsHandler{storeProvider: testUserStoreProvider{store: store}}
req := httptest.NewRequest("GET", "/series/series-1/seasons/1", nil)
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
ctx = apimw.SetProfileID(ctx, "profile-1")
req = req.WithContext(ctx)
episodes := make([]*models.Episode, 501)
for i := range episodes {
episodes[i] = &models.Episode{ContentID: "episode-" + strconv.Itoa(i+1)}
}
if userData := handler.getAggregateUserData(req, episodes); userData != nil {
t.Fatalf("getAggregateUserData() = %#v, want nil after batch failure", userData)
}
if store.calls != 2 {
t.Fatalf("ListProgressByMediaItems calls = %d, want 2", store.calls)
}
}
func TestGetAggregateUserDataCountsCompletedHistory(t *testing.T) {
store := newPlaybackTestStore(t)
if err := store.SetProgressAt(
context.Background(),
"profile-1",
"episode-progress-complete",
0,
1800,
true,
time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC),
); err != nil {
t.Fatalf("seed progress: %v", err)
}
addCompletedHistoryForUserDataTest(t, store, "episode-history-complete")
handler := &ItemsHandler{storeProvider: testUserStoreProvider{store: store}}
req := authorizedUserDataRequest()
userData := handler.getAggregateUserData(req, []*models.Episode{
{ContentID: "episode-progress-complete"},
{ContentID: "episode-history-complete"},
{ContentID: "episode-unplayed"},
})
if userData == nil {
t.Fatal("aggregate user data = nil, want counts")
}
if userData.WatchedCount != 2 || userData.UnplayedCount != 1 || userData.Played {
t.Fatalf("aggregate user data = %+v, want two watched and one unplayed", userData)
}
}
type failingBatchProgressStore struct {
userstore.UserStore
calls int
}
func (s *failingBatchProgressStore) ListProgressByMediaItems(
_ context.Context,
_ string,
mediaItemIDs []string,
) (map[string]userstore.WatchProgress, error) {
s.calls++
if s.calls == 2 {
return nil, errors.New("progress batch failed")
}
progress := make(map[string]userstore.WatchProgress, len(mediaItemIDs))
for _, id := range mediaItemIDs {
progress[id] = userstore.WatchProgress{MediaItemID: id, Completed: true}
}
return progress, nil
}
func authorizedUserDataRequest() *http.Request {
req := httptest.NewRequest("GET", "/items/movie-1", nil)
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
ctx = apimw.SetProfileID(ctx, "profile-1")
return req.WithContext(ctx)
}
func addCompletedHistoryForUserDataTest(t *testing.T, store userstore.UserStore, mediaItemID string) {
t.Helper()
if err := store.AddHistory(context.Background(), userstore.WatchHistoryEntry{
ProfileID: "profile-1",
MediaItemID: mediaItemID,
WatchedAt: "2026-05-04T12:00:00Z",
DurationSeconds: 7200,
Completed: true,
Source: userstore.WatchHistorySourceTrakt,
}); err != nil {
t.Fatalf("seed completed history: %v", err)
}
}