- Wire introdb marker fetching into playback and Jellyfin compat - Persist and expose recap/preview markers alongside intro and credits - Add new playback/profile settings for recap and preview behavior
1688 lines
54 KiB
Go
1688 lines
54 KiB
Go
// Package storetest provides a shared conformance test suite for UserStore
|
|
// implementations. Both SQLite and Postgres backends run these same tests.
|
|
package storetest
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
// RunSuite runs all conformance tests against a UserStore implementation.
|
|
// The newStore function should return a fresh, empty store for each test.
|
|
func RunSuite(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
t.Run("Profiles", func(t *testing.T) {
|
|
testProfiles(t, newStore)
|
|
})
|
|
t.Run("Progress", func(t *testing.T) {
|
|
testProgress(t, newStore)
|
|
})
|
|
t.Run("Favorites", func(t *testing.T) {
|
|
testFavorites(t, newStore)
|
|
})
|
|
t.Run("Watchlist", func(t *testing.T) {
|
|
testWatchlist(t, newStore)
|
|
})
|
|
t.Run("Collections", func(t *testing.T) {
|
|
testCollections(t, newStore)
|
|
})
|
|
t.Run("Settings", func(t *testing.T) {
|
|
testSettings(t, newStore)
|
|
})
|
|
t.Run("SubtitlePreferences", func(t *testing.T) {
|
|
testSubtitlePreferences(t, newStore)
|
|
})
|
|
t.Run("AudioPreferences", func(t *testing.T) {
|
|
testAudioPreferences(t, newStore)
|
|
})
|
|
t.Run("LibraryPlaybackPreferences", func(t *testing.T) {
|
|
testLibraryPlaybackPreferences(t, newStore)
|
|
})
|
|
t.Run("ProgressHints", func(t *testing.T) {
|
|
testProgressHints(t, newStore)
|
|
})
|
|
t.Run("SectionOverrides", func(t *testing.T) {
|
|
testSectionOverrides(t, newStore)
|
|
})
|
|
t.Run("SectionOverridesUserAddedFields", func(t *testing.T) {
|
|
testSectionOverridesUserAddedFields(t, newStore)
|
|
})
|
|
t.Run("HomeDismissals", func(t *testing.T) {
|
|
testHomeDismissals(t, newStore)
|
|
})
|
|
}
|
|
|
|
func testProfiles(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
// Create
|
|
p := userstore.Profile{
|
|
ID: "prof-1",
|
|
Name: "Alice",
|
|
Avatar: "avatar1.png",
|
|
IsChild: false,
|
|
MaxContentRating: "R",
|
|
QualityPreference: "1080p",
|
|
Language: "en",
|
|
SubtitleLanguage: "es",
|
|
SubtitleMode: "auto",
|
|
AutoSkipIntro: true,
|
|
AutoSkipCredits: false,
|
|
AutoSkipRecap: false,
|
|
AutoPlayNextPreview: false,
|
|
LibraryRestrictionsEnabled: true,
|
|
AllowedLibraryIDs: []int{1, 3},
|
|
MaxPlaybackQuality: "1080p",
|
|
}
|
|
if err := store.CreateProfile(ctx, p); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
// Get
|
|
got, err := store.GetProfile(ctx, "prof-1")
|
|
if err != nil {
|
|
t.Fatalf("GetProfile: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("GetProfile returned nil")
|
|
}
|
|
if got.Name != "Alice" {
|
|
t.Errorf("Name = %q, want %q", got.Name, "Alice")
|
|
}
|
|
if got.AutoSkipIntro != true {
|
|
t.Errorf("AutoSkipIntro = %v, want true", got.AutoSkipIntro)
|
|
}
|
|
if !got.LibraryRestrictionsEnabled {
|
|
t.Errorf("LibraryRestrictionsEnabled = %v, want true", got.LibraryRestrictionsEnabled)
|
|
}
|
|
if len(got.AllowedLibraryIDs) != 2 || got.AllowedLibraryIDs[0] != 1 || got.AllowedLibraryIDs[1] != 3 {
|
|
t.Errorf("AllowedLibraryIDs = %v, want [1 3]", got.AllowedLibraryIDs)
|
|
}
|
|
if got.MaxPlaybackQuality != "1080p" {
|
|
t.Errorf("MaxPlaybackQuality = %q, want %q", got.MaxPlaybackQuality, "1080p")
|
|
}
|
|
|
|
// Get non-existent
|
|
missing, err := store.GetProfile(ctx, "nonexistent")
|
|
if err != nil {
|
|
t.Fatalf("GetProfile(nonexistent): %v", err)
|
|
}
|
|
if missing != nil {
|
|
t.Errorf("GetProfile(nonexistent) = %v, want nil", missing)
|
|
}
|
|
|
|
// List
|
|
profiles, err := store.ListProfiles(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListProfiles: %v", err)
|
|
}
|
|
if len(profiles) != 1 {
|
|
t.Fatalf("ListProfiles returned %d profiles, want 1", len(profiles))
|
|
}
|
|
if !profiles[0].LibraryRestrictionsEnabled {
|
|
t.Errorf("ListProfiles()[0].LibraryRestrictionsEnabled = %v, want true", profiles[0].LibraryRestrictionsEnabled)
|
|
}
|
|
if len(profiles[0].AllowedLibraryIDs) != 2 || profiles[0].AllowedLibraryIDs[0] != 1 || profiles[0].AllowedLibraryIDs[1] != 3 {
|
|
t.Errorf("ListProfiles()[0].AllowedLibraryIDs = %v, want [1 3]", profiles[0].AllowedLibraryIDs)
|
|
}
|
|
if profiles[0].MaxPlaybackQuality != "1080p" {
|
|
t.Errorf("ListProfiles()[0].MaxPlaybackQuality = %q, want %q", profiles[0].MaxPlaybackQuality, "1080p")
|
|
}
|
|
|
|
// Update
|
|
newName := "Alice Updated"
|
|
skipCredits := true
|
|
restrictionsEnabled := false
|
|
allowedLibraryIDs := []int{2, 4}
|
|
maxPlaybackQuality := "720p"
|
|
if err := store.UpdateProfile(ctx, "prof-1", userstore.UpdateProfileInput{
|
|
Name: &newName,
|
|
AutoSkipCredits: &skipCredits,
|
|
LibraryRestrictionsEnabled: &restrictionsEnabled,
|
|
AllowedLibraryIDs: &allowedLibraryIDs,
|
|
MaxPlaybackQuality: &maxPlaybackQuality,
|
|
}); err != nil {
|
|
t.Fatalf("UpdateProfile: %v", err)
|
|
}
|
|
updated, _ := store.GetProfile(ctx, "prof-1")
|
|
if updated.Name != "Alice Updated" {
|
|
t.Errorf("Updated Name = %q, want %q", updated.Name, "Alice Updated")
|
|
}
|
|
if updated.AutoSkipCredits != true {
|
|
t.Errorf("Updated AutoSkipCredits = %v, want true", updated.AutoSkipCredits)
|
|
}
|
|
if updated.LibraryRestrictionsEnabled {
|
|
t.Errorf("Updated LibraryRestrictionsEnabled = %v, want false", updated.LibraryRestrictionsEnabled)
|
|
}
|
|
if len(updated.AllowedLibraryIDs) != 2 || updated.AllowedLibraryIDs[0] != 2 || updated.AllowedLibraryIDs[1] != 4 {
|
|
t.Errorf("Updated AllowedLibraryIDs = %v, want [2 4]", updated.AllowedLibraryIDs)
|
|
}
|
|
if updated.MaxPlaybackQuality != "720p" {
|
|
t.Errorf("Updated MaxPlaybackQuality = %q, want %q", updated.MaxPlaybackQuality, "720p")
|
|
}
|
|
|
|
// PIN set and verify
|
|
pin := "1234"
|
|
if err := store.UpdateProfile(ctx, "prof-1", userstore.UpdateProfileInput{PIN: &pin}); err != nil {
|
|
t.Fatalf("UpdateProfile(PIN): %v", err)
|
|
}
|
|
ok, err := store.VerifyPIN(ctx, "prof-1", "1234")
|
|
if err != nil {
|
|
t.Fatalf("VerifyPIN: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Error("VerifyPIN returned false for correct PIN")
|
|
}
|
|
ok, err = store.VerifyPIN(ctx, "prof-1", "wrong")
|
|
if err != nil {
|
|
t.Fatalf("VerifyPIN(wrong): %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("VerifyPIN returned true for wrong PIN")
|
|
}
|
|
|
|
// Delete
|
|
if err := store.DeleteProfile(ctx, "prof-1"); err != nil {
|
|
t.Fatalf("DeleteProfile: %v", err)
|
|
}
|
|
deleted, _ := store.GetProfile(ctx, "prof-1")
|
|
if deleted != nil {
|
|
t.Error("GetProfile after delete returned non-nil")
|
|
}
|
|
}
|
|
|
|
func testProgress(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
// Setup profile
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
noThreshold := userstore.ProgressThresholds{}
|
|
|
|
// SetProgress (unconditional) — position at 500/7200 = 6.9%, above min-resume default
|
|
if err := store.SetProgress(ctx, "p1", "movie-1", 500, 7200, noThreshold); err != nil {
|
|
t.Fatalf("SetProgress: %v", err)
|
|
}
|
|
wp, err := store.GetProgress(ctx, "p1", "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress: %v", err)
|
|
}
|
|
if wp == nil {
|
|
t.Fatal("GetProgress returned nil")
|
|
}
|
|
if wp.PositionSeconds != 500 {
|
|
t.Errorf("PositionSeconds = %v, want 500", wp.PositionSeconds)
|
|
}
|
|
|
|
// UpdateProgress forward-only: should advance
|
|
if err := store.UpdateProgress(ctx, "p1", "movie-1", 1000, 7200, noThreshold); err != nil {
|
|
t.Fatalf("UpdateProgress(forward): %v", err)
|
|
}
|
|
wp, _ = store.GetProgress(ctx, "p1", "movie-1")
|
|
if wp.PositionSeconds != 1000 {
|
|
t.Errorf("PositionSeconds after forward = %v, want 1000", wp.PositionSeconds)
|
|
}
|
|
|
|
// UpdateProgress backward: should NOT go backward
|
|
if err := store.UpdateProgress(ctx, "p1", "movie-1", 600, 7200, noThreshold); err != nil {
|
|
t.Fatalf("UpdateProgress(backward): %v", err)
|
|
}
|
|
wp, _ = store.GetProgress(ctx, "p1", "movie-1")
|
|
if wp.PositionSeconds != 1000 {
|
|
t.Errorf("PositionSeconds after backward = %v, want 1000 (unchanged)", wp.PositionSeconds)
|
|
}
|
|
|
|
// SetProgress backward: should work (unconditional)
|
|
if err := store.SetProgress(ctx, "p1", "movie-1", 500, 7200, noThreshold); err != nil {
|
|
t.Fatalf("SetProgress(backward): %v", err)
|
|
}
|
|
wp, _ = store.GetProgress(ctx, "p1", "movie-1")
|
|
if wp.PositionSeconds != 500 {
|
|
t.Errorf("PositionSeconds after SetProgress backward = %v, want 500", wp.PositionSeconds)
|
|
}
|
|
|
|
// Completion flag: >90%
|
|
if err := store.SetProgress(ctx, "p1", "movie-2", 6500, 7200, noThreshold); err != nil {
|
|
t.Fatalf("SetProgress(near end): %v", err)
|
|
}
|
|
wp, _ = store.GetProgress(ctx, "p1", "movie-2")
|
|
if !wp.Completed {
|
|
t.Error("Expected Completed=true for >90% progress")
|
|
}
|
|
|
|
// Min-resume threshold: below 5% should be silently discarded
|
|
if err := store.SetProgress(ctx, "p1", "movie-tiny", 10, 7200, noThreshold); err != nil {
|
|
t.Fatalf("SetProgress(below min resume): %v", err)
|
|
}
|
|
wp, _ = store.GetProgress(ctx, "p1", "movie-tiny")
|
|
if wp != nil {
|
|
t.Error("Expected nil progress for position below min-resume threshold")
|
|
}
|
|
|
|
// Zero-duration writes should not error and should remain incomplete.
|
|
if err := store.SetProgress(ctx, "p1", "movie-unknown", 0, 0, noThreshold); err != nil {
|
|
t.Fatalf("SetProgress(zero duration): %v", err)
|
|
}
|
|
if err := store.UpdateProgress(ctx, "p1", "movie-unknown", 30, 0, noThreshold); err != nil {
|
|
t.Fatalf("UpdateProgress(zero duration): %v", err)
|
|
}
|
|
wp, _ = store.GetProgress(ctx, "p1", "movie-unknown")
|
|
if wp == nil {
|
|
t.Fatal("GetProgress(zero duration) returned nil")
|
|
}
|
|
if wp.Completed {
|
|
t.Error("Expected Completed=false for zero-duration progress")
|
|
}
|
|
|
|
// ListProgress
|
|
all, err := store.ListProgress(ctx, "p1", "all", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListProgress: %v", err)
|
|
}
|
|
if len(all) != 3 {
|
|
t.Errorf("ListProgress returned %d items, want 3", len(all))
|
|
}
|
|
|
|
// GetProgress non-existent
|
|
wp, err = store.GetProgress(ctx, "p1", "nonexistent")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(nonexistent): %v", err)
|
|
}
|
|
if wp != nil {
|
|
t.Error("GetProgress(nonexistent) returned non-nil")
|
|
}
|
|
|
|
// AddHistory + ListHistory
|
|
seasonNumber := 1
|
|
episodeNumber := 2
|
|
if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{
|
|
ProfileID: "p1",
|
|
MediaItemID: "movie-1",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Identity: userstore.WatchIdentity{
|
|
StableType: "episode",
|
|
SeriesProviderIDs: map[string]string{"tmdb": "123"},
|
|
Season: &seasonNumber,
|
|
Episode: &episodeNumber,
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory: %v", err)
|
|
}
|
|
history, err := store.ListHistory(ctx, "p1", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListHistory: %v", err)
|
|
}
|
|
if len(history) != 1 {
|
|
t.Errorf("ListHistory returned %d, want 1", len(history))
|
|
}
|
|
if history[0].Source != userstore.WatchHistorySourceLegacy {
|
|
t.Errorf("ListHistory Source = %q, want %q", history[0].Source, userstore.WatchHistorySourceLegacy)
|
|
}
|
|
if history[0].Identity.StableType != "episode" {
|
|
t.Errorf("ListHistory Identity.StableType = %q, want episode", history[0].Identity.StableType)
|
|
}
|
|
if len(history[0].Identity.ProviderIDs) != 0 {
|
|
t.Errorf("ListHistory Identity.ProviderIDs = %#v, want empty", history[0].Identity.ProviderIDs)
|
|
}
|
|
if history[0].Identity.SeriesProviderIDs["tmdb"] != "123" {
|
|
t.Errorf("ListHistory Identity.SeriesProviderIDs[tmdb] = %q, want 123", history[0].Identity.SeriesProviderIDs["tmdb"])
|
|
}
|
|
if history[0].Identity.Season == nil || *history[0].Identity.Season != seasonNumber {
|
|
t.Errorf("ListHistory Identity.Season = %v, want %d", history[0].Identity.Season, seasonNumber)
|
|
}
|
|
if history[0].Identity.Episode == nil || *history[0].Identity.Episode != episodeNumber {
|
|
t.Errorf("ListHistory Identity.Episode = %v, want %d", history[0].Identity.Episode, episodeNumber)
|
|
}
|
|
|
|
if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{
|
|
ProfileID: "p1",
|
|
MediaItemID: "movie-1",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
WatchedAt: "2026-03-23T12:01:00Z",
|
|
Source: userstore.WatchHistorySourceManual,
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory(manual): %v", err)
|
|
}
|
|
if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{
|
|
ProfileID: "p1",
|
|
MediaItemID: "movie-1",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
WatchedAt: "2026-03-23T12:02:00Z",
|
|
Source: userstore.WatchHistorySourcePlayback,
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory(playback): %v", err)
|
|
}
|
|
if err := store.DeleteHistoryBySource(ctx, "p1", []string{"movie-1"}, userstore.WatchHistorySourceManual); err != nil {
|
|
t.Fatalf("DeleteHistoryBySource(manual): %v", err)
|
|
}
|
|
history, err = store.ListHistory(ctx, "p1", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListHistory(after delete): %v", err)
|
|
}
|
|
if len(history) != 2 {
|
|
t.Fatalf("ListHistory(after delete) returned %d, want 2", len(history))
|
|
}
|
|
for _, entry := range history {
|
|
if entry.Source == userstore.WatchHistorySourceManual {
|
|
t.Fatalf("DeleteHistoryBySource removed wrong rows: %+v", history)
|
|
}
|
|
}
|
|
|
|
removedAt := time.Date(2026, 3, 23, 12, 3, 0, 0, time.UTC)
|
|
if err := store.RemoveHistoryItems(ctx, "p1", []string{"movie-1"}, removedAt); err != nil {
|
|
t.Fatalf("RemoveHistoryItems: %v", err)
|
|
}
|
|
wp, err = store.GetProgress(ctx, "p1", "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(after remove): %v", err)
|
|
}
|
|
if wp != nil {
|
|
t.Fatalf("GetProgress(after remove) returned %+v, want nil", *wp)
|
|
}
|
|
history, err = store.ListHistory(ctx, "p1", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListHistory(after remove): %v", err)
|
|
}
|
|
if len(history) != 0 {
|
|
t.Fatalf("ListHistory(after remove) returned %d, want 0", len(history))
|
|
}
|
|
|
|
if err := store.SetProgressAt(
|
|
ctx,
|
|
"p1",
|
|
"movie-1",
|
|
7200,
|
|
7200,
|
|
true,
|
|
time.Date(2026, 3, 23, 12, 2, 30, 0, time.UTC),
|
|
); err != nil {
|
|
t.Fatalf("SetProgressAt(hidden import): %v", err)
|
|
}
|
|
wp, err = store.GetProgress(ctx, "p1", "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(after hidden import): %v", err)
|
|
}
|
|
if wp != nil {
|
|
t.Fatalf("GetProgress(after hidden import) returned %+v, want nil", *wp)
|
|
}
|
|
|
|
if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{
|
|
ProfileID: "p1",
|
|
MediaItemID: "movie-1",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
WatchedAt: "2026-03-23T12:02:30Z",
|
|
Source: userstore.WatchHistorySourceImport,
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory(hidden import): %v", err)
|
|
}
|
|
history, err = store.ListHistory(ctx, "p1", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListHistory(after hidden import): %v", err)
|
|
}
|
|
if len(history) != 0 {
|
|
t.Fatalf("ListHistory(after hidden import) returned %d, want 0", len(history))
|
|
}
|
|
|
|
if err := store.SetProgressAt(
|
|
ctx,
|
|
"p1",
|
|
"movie-1",
|
|
7200,
|
|
7200,
|
|
true,
|
|
time.Date(2026, 3, 23, 12, 4, 0, 0, time.UTC),
|
|
); err != nil {
|
|
t.Fatalf("SetProgressAt(after remove): %v", err)
|
|
}
|
|
if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{
|
|
ProfileID: "p1",
|
|
MediaItemID: "movie-1",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
WatchedAt: "2026-03-23T12:04:00Z",
|
|
Source: userstore.WatchHistorySourcePlayback,
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory(after remove): %v", err)
|
|
}
|
|
history, err = store.ListHistory(ctx, "p1", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListHistory(after new watch): %v", err)
|
|
}
|
|
if len(history) != 1 {
|
|
t.Fatalf("ListHistory(after new watch) returned %d, want 1", len(history))
|
|
}
|
|
if history[0].WatchedAt != "2026-03-23T12:04:00Z" {
|
|
t.Fatalf("ListHistory(after new watch) watched_at = %q, want 2026-03-23T12:04:00Z", history[0].WatchedAt)
|
|
}
|
|
wp, err = store.GetProgress(ctx, "p1", "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(after new watch): %v", err)
|
|
}
|
|
if wp == nil || !wp.Completed {
|
|
t.Fatalf("GetProgress(after new watch) = %+v, want completed progress", wp)
|
|
}
|
|
|
|
// Manual watched state helpers.
|
|
if err := store.MarkWatched(ctx, "p1", "movie-3", 5400); err != nil {
|
|
t.Fatalf("MarkWatched: %v", err)
|
|
}
|
|
wp, err = store.GetProgress(ctx, "p1", "movie-3")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(after MarkWatched): %v", err)
|
|
}
|
|
if wp == nil {
|
|
t.Fatal("GetProgress(after MarkWatched) returned nil")
|
|
}
|
|
if !wp.Completed {
|
|
t.Error("MarkWatched should create a completed progress row")
|
|
}
|
|
if wp.PositionSeconds != 5400 || wp.DurationSeconds != 5400 {
|
|
t.Errorf("MarkWatched stored position=%v duration=%v, want 5400/5400", wp.PositionSeconds, wp.DurationSeconds)
|
|
}
|
|
|
|
if err := store.ClearProgress(ctx, "p1", "movie-3"); err != nil {
|
|
t.Fatalf("ClearProgress: %v", err)
|
|
}
|
|
wp, err = store.GetProgress(ctx, "p1", "movie-3")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(after ClearProgress): %v", err)
|
|
}
|
|
if wp != nil {
|
|
t.Error("ClearProgress should remove the progress row")
|
|
}
|
|
|
|
progressMap, err := store.ListProgressByMediaItems(ctx, "p1", []string{"movie-1", "movie-2", "missing"})
|
|
if err != nil {
|
|
t.Fatalf("ListProgressByMediaItems: %v", err)
|
|
}
|
|
if len(progressMap) != 2 {
|
|
t.Fatalf("ListProgressByMediaItems returned %d items, want 2", len(progressMap))
|
|
}
|
|
if progressMap["movie-1"].MediaItemID != "movie-1" {
|
|
t.Errorf("ListProgressByMediaItems movie-1 = %+v, want media item movie-1", progressMap["movie-1"])
|
|
}
|
|
if !progressMap["movie-2"].Completed {
|
|
t.Errorf("ListProgressByMediaItems movie-2 completed = %v, want true", progressMap["movie-2"].Completed)
|
|
}
|
|
|
|
emptyProgressMap, err := store.ListProgressByMediaItems(ctx, "p1", nil)
|
|
if err != nil {
|
|
t.Fatalf("ListProgressByMediaItems(empty): %v", err)
|
|
}
|
|
if len(emptyProgressMap) != 0 {
|
|
t.Errorf("ListProgressByMediaItems(empty) returned %d items, want 0", len(emptyProgressMap))
|
|
}
|
|
|
|
// Batch helpers — exercise the unnest()/IN(...) paths used by the
|
|
// jellycompat series mark-played/unplayed handlers.
|
|
batchAt := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
|
|
if err := store.MarkProgressBatch(ctx, "p1", nil, batchAt); err != nil {
|
|
t.Fatalf("MarkProgressBatch(empty): %v", err)
|
|
}
|
|
if err := store.MarkProgressBatch(ctx, "p1", []string{"ep-a", "ep-b", "ep-c"}, batchAt); err != nil {
|
|
t.Fatalf("MarkProgressBatch: %v", err)
|
|
}
|
|
for _, id := range []string{"ep-a", "ep-b", "ep-c"} {
|
|
wp, err := store.GetProgress(ctx, "p1", id)
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(%s): %v", id, err)
|
|
}
|
|
if wp == nil || !wp.Completed {
|
|
t.Fatalf("GetProgress(%s) = %+v, want completed row", id, wp)
|
|
}
|
|
}
|
|
|
|
if err := store.ClearProgressBatch(ctx, "p1", nil, batchAt); err != nil {
|
|
t.Fatalf("ClearProgressBatch(empty): %v", err)
|
|
}
|
|
if err := store.ClearProgressBatch(ctx, "p1", []string{"ep-a", "ep-b"}, batchAt); err != nil {
|
|
t.Fatalf("ClearProgressBatch: %v", err)
|
|
}
|
|
for _, id := range []string{"ep-a", "ep-b"} {
|
|
wp, err := store.GetProgress(ctx, "p1", id)
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(after ClearProgressBatch %s): %v", id, err)
|
|
}
|
|
if wp == nil || wp.Completed {
|
|
t.Fatalf("GetProgress(after ClearProgressBatch %s) = %+v, want completed=false row", id, wp)
|
|
}
|
|
if wp.PositionSeconds != 0 {
|
|
t.Fatalf("GetProgress(after ClearProgressBatch %s) position = %v, want 0", id, wp.PositionSeconds)
|
|
}
|
|
}
|
|
wpC, err := store.GetProgress(ctx, "p1", "ep-c")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(ep-c, untouched): %v", err)
|
|
}
|
|
if wpC == nil || !wpC.Completed {
|
|
t.Fatalf("GetProgress(ep-c, untouched) = %+v, want still completed", wpC)
|
|
}
|
|
|
|
// Defense-in-depth: empty/whitespace/duplicate IDs must be compacted out
|
|
// before they hit SQL — otherwise pgstore would insert a literal '' row
|
|
// into user_watch_progress and userdb would bind '' into the IN clause.
|
|
if err := store.MarkProgressBatch(ctx, "p1", []string{"only-a", "", "only-a", " "}, batchAt); err != nil {
|
|
t.Fatalf("MarkProgressBatch(dirty): %v", err)
|
|
}
|
|
wpA, err := store.GetProgress(ctx, "p1", "only-a")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(only-a after dirty mark): %v", err)
|
|
}
|
|
if wpA == nil || !wpA.Completed {
|
|
t.Fatalf("GetProgress(only-a after dirty mark) = %+v, want completed", wpA)
|
|
}
|
|
if wpEmpty, err := store.GetProgress(ctx, "p1", ""); err != nil {
|
|
t.Fatalf("GetProgress(empty after dirty mark): %v", err)
|
|
} else if wpEmpty != nil {
|
|
t.Fatalf("GetProgress(empty after dirty mark) = %+v, want nil — empty-string IDs must be compacted", wpEmpty)
|
|
}
|
|
if wpWS, err := store.GetProgress(ctx, "p1", " "); err != nil {
|
|
t.Fatalf("GetProgress(whitespace after dirty mark): %v", err)
|
|
} else if wpWS != nil {
|
|
t.Fatalf("GetProgress(whitespace after dirty mark) = %+v, want nil — whitespace IDs must be compacted", wpWS)
|
|
}
|
|
|
|
// Same coverage for ClearProgressBatch — passing dirty input must not
|
|
// touch a literal '' row (and must not error).
|
|
if err := store.ClearProgressBatch(ctx, "p1", []string{"only-a", "", "only-a", " "}, batchAt); err != nil {
|
|
t.Fatalf("ClearProgressBatch(dirty): %v", err)
|
|
}
|
|
wpA2, err := store.GetProgress(ctx, "p1", "only-a")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(only-a after dirty clear): %v", err)
|
|
}
|
|
if wpA2 == nil || wpA2.Completed {
|
|
t.Fatalf("GetProgress(only-a after dirty clear) = %+v, want completed=false", wpA2)
|
|
}
|
|
}
|
|
|
|
func testHomeDismissals(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
continueWatching := userstore.HomeItemDismissal{
|
|
ProfileID: "p1",
|
|
Surface: userstore.HomeSurfaceContinueWatching,
|
|
MediaItemID: "ep-1",
|
|
ProgressUpdatedAt: stringPtr("2026-03-22T18:10:00Z"),
|
|
DismissedAt: "2026-03-22T18:11:00Z",
|
|
}
|
|
if err := store.UpsertHomeDismissal(ctx, continueWatching); err != nil {
|
|
t.Fatalf("UpsertHomeDismissal(continue_watching): %v", err)
|
|
}
|
|
|
|
nextUp := userstore.HomeItemDismissal{
|
|
ProfileID: "p1",
|
|
Surface: userstore.HomeSurfaceNextUp,
|
|
MediaItemID: "ep-2",
|
|
SeriesID: stringPtr("series-1"),
|
|
DismissedAt: "2026-03-22T18:12:00Z",
|
|
}
|
|
if err := store.UpsertHomeDismissal(ctx, nextUp); err != nil {
|
|
t.Fatalf("UpsertHomeDismissal(next_up): %v", err)
|
|
}
|
|
|
|
continueDismissals, err := store.ListHomeDismissals(ctx, "p1", userstore.HomeSurfaceContinueWatching)
|
|
if err != nil {
|
|
t.Fatalf("ListHomeDismissals(continue_watching): %v", err)
|
|
}
|
|
if len(continueDismissals) != 1 {
|
|
t.Fatalf("continue watching dismissals = %d, want 1", len(continueDismissals))
|
|
}
|
|
if continueDismissals[0].MediaItemID != "ep-1" {
|
|
t.Fatalf("continue watching dismissal media_item_id = %q, want ep-1", continueDismissals[0].MediaItemID)
|
|
}
|
|
if continueDismissals[0].ProgressUpdatedAt == nil || *continueDismissals[0].ProgressUpdatedAt != "2026-03-22T18:10:00Z" {
|
|
t.Fatalf("continue watching dismissal progress_updated_at = %v, want 2026-03-22T18:10:00Z", continueDismissals[0].ProgressUpdatedAt)
|
|
}
|
|
|
|
nextUpDismissals, err := store.ListHomeDismissals(ctx, "p1", userstore.HomeSurfaceNextUp)
|
|
if err != nil {
|
|
t.Fatalf("ListHomeDismissals(next_up): %v", err)
|
|
}
|
|
if len(nextUpDismissals) != 1 {
|
|
t.Fatalf("next up dismissals = %d, want 1", len(nextUpDismissals))
|
|
}
|
|
if nextUpDismissals[0].SeriesID == nil || *nextUpDismissals[0].SeriesID != "series-1" {
|
|
t.Fatalf("next up dismissal series_id = %v, want series-1", nextUpDismissals[0].SeriesID)
|
|
}
|
|
|
|
updated := continueWatching
|
|
updated.ProgressUpdatedAt = stringPtr("2026-03-23T00:00:00Z")
|
|
updated.DismissedAt = "2026-03-23T00:01:00Z"
|
|
if err := store.UpsertHomeDismissal(ctx, updated); err != nil {
|
|
t.Fatalf("UpsertHomeDismissal(update): %v", err)
|
|
}
|
|
|
|
continueDismissals, err = store.ListHomeDismissals(ctx, "p1", userstore.HomeSurfaceContinueWatching)
|
|
if err != nil {
|
|
t.Fatalf("ListHomeDismissals(after update): %v", err)
|
|
}
|
|
if len(continueDismissals) != 1 {
|
|
t.Fatalf("continue watching dismissals after update = %d, want 1", len(continueDismissals))
|
|
}
|
|
if continueDismissals[0].ProgressUpdatedAt == nil || *continueDismissals[0].ProgressUpdatedAt != "2026-03-23T00:00:00Z" {
|
|
t.Fatalf("continue watching dismissal progress_updated_at after update = %v, want 2026-03-23T00:00:00Z", continueDismissals[0].ProgressUpdatedAt)
|
|
}
|
|
|
|
if err := store.DeleteHomeDismissal(ctx, "p1", userstore.HomeSurfaceContinueWatching, "ep-1"); err != nil {
|
|
t.Fatalf("DeleteHomeDismissal: %v", err)
|
|
}
|
|
|
|
continueDismissals, err = store.ListHomeDismissals(ctx, "p1", userstore.HomeSurfaceContinueWatching)
|
|
if err != nil {
|
|
t.Fatalf("ListHomeDismissals(after delete): %v", err)
|
|
}
|
|
if len(continueDismissals) != 0 {
|
|
t.Fatalf("continue watching dismissals after delete = %d, want 0", len(continueDismissals))
|
|
}
|
|
}
|
|
|
|
func stringPtr(value string) *string {
|
|
return &value
|
|
}
|
|
|
|
func testFavorites(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
// Add
|
|
if err := store.AddFavorite(ctx, "p1", "item-1"); err != nil {
|
|
t.Fatalf("AddFavorite: %v", err)
|
|
}
|
|
if err := store.AddFavorite(ctx, "p1", "item-2"); err != nil {
|
|
t.Fatalf("AddFavorite: %v", err)
|
|
}
|
|
|
|
// Add duplicate (should be no-op)
|
|
if err := store.AddFavorite(ctx, "p1", "item-1"); err != nil {
|
|
t.Fatalf("AddFavorite(duplicate): %v", err)
|
|
}
|
|
|
|
// Check
|
|
ok, err := store.IsFavorite(ctx, "p1", "item-1")
|
|
if err != nil {
|
|
t.Fatalf("IsFavorite: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Error("IsFavorite returned false, want true")
|
|
}
|
|
|
|
ok, _ = store.IsFavorite(ctx, "p1", "nonexistent")
|
|
if ok {
|
|
t.Error("IsFavorite(nonexistent) returned true")
|
|
}
|
|
|
|
// List
|
|
favs, err := store.ListFavorites(ctx, "p1", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListFavorites: %v", err)
|
|
}
|
|
if len(favs) != 2 {
|
|
t.Errorf("ListFavorites returned %d, want 2", len(favs))
|
|
}
|
|
|
|
favoriteMap, err := store.ListFavoritesByMediaItems(ctx, "p1", []string{"item-1", "item-2", "missing"})
|
|
if err != nil {
|
|
t.Fatalf("ListFavoritesByMediaItems: %v", err)
|
|
}
|
|
if len(favoriteMap) != 2 {
|
|
t.Fatalf("ListFavoritesByMediaItems returned %d items, want 2", len(favoriteMap))
|
|
}
|
|
if !favoriteMap["item-1"] || !favoriteMap["item-2"] {
|
|
t.Errorf("ListFavoritesByMediaItems returned %v, want item-1 and item-2 true", favoriteMap)
|
|
}
|
|
|
|
emptyFavoriteMap, err := store.ListFavoritesByMediaItems(ctx, "p1", nil)
|
|
if err != nil {
|
|
t.Fatalf("ListFavoritesByMediaItems(empty): %v", err)
|
|
}
|
|
if len(emptyFavoriteMap) != 0 {
|
|
t.Errorf("ListFavoritesByMediaItems(empty) returned %d items, want 0", len(emptyFavoriteMap))
|
|
}
|
|
|
|
// Remove
|
|
if err := store.RemoveFavorite(ctx, "p1", "item-1"); err != nil {
|
|
t.Fatalf("RemoveFavorite: %v", err)
|
|
}
|
|
ok, _ = store.IsFavorite(ctx, "p1", "item-1")
|
|
if ok {
|
|
t.Error("IsFavorite after remove returned true")
|
|
}
|
|
}
|
|
|
|
func testWatchlist(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
// Add
|
|
if err := store.AddToWatchlist(ctx, "p1", "item-1"); err != nil {
|
|
t.Fatalf("AddToWatchlist: %v", err)
|
|
}
|
|
|
|
// Check
|
|
ok, err := store.InWatchlist(ctx, "p1", "item-1")
|
|
if err != nil {
|
|
t.Fatalf("InWatchlist: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Error("InWatchlist returned false, want true")
|
|
}
|
|
|
|
// List
|
|
entries, err := store.ListWatchlist(ctx, "p1", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListWatchlist: %v", err)
|
|
}
|
|
if len(entries) != 1 {
|
|
t.Errorf("ListWatchlist returned %d, want 1", len(entries))
|
|
}
|
|
|
|
watchlistMap, err := store.ListWatchlistByMediaItems(ctx, "p1", []string{"item-1", "missing"})
|
|
if err != nil {
|
|
t.Fatalf("ListWatchlistByMediaItems: %v", err)
|
|
}
|
|
if len(watchlistMap) != 1 {
|
|
t.Fatalf("ListWatchlistByMediaItems returned %d items, want 1", len(watchlistMap))
|
|
}
|
|
if !watchlistMap["item-1"] {
|
|
t.Errorf("ListWatchlistByMediaItems returned %v, want item-1 true", watchlistMap)
|
|
}
|
|
|
|
emptyWatchlistMap, err := store.ListWatchlistByMediaItems(ctx, "p1", nil)
|
|
if err != nil {
|
|
t.Fatalf("ListWatchlistByMediaItems(empty): %v", err)
|
|
}
|
|
if len(emptyWatchlistMap) != 0 {
|
|
t.Errorf("ListWatchlistByMediaItems(empty) returned %d items, want 0", len(emptyWatchlistMap))
|
|
}
|
|
|
|
// Remove
|
|
if err := store.RemoveFromWatchlist(ctx, "p1", "item-1"); err != nil {
|
|
t.Fatalf("RemoveFromWatchlist: %v", err)
|
|
}
|
|
ok, _ = store.InWatchlist(ctx, "p1", "item-1")
|
|
if ok {
|
|
t.Error("InWatchlist after remove returned true")
|
|
}
|
|
}
|
|
|
|
func testCollections(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
// Create
|
|
coll, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{
|
|
CreatorProfileID: "p1",
|
|
Name: "My Collection",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateCollection: %v", err)
|
|
}
|
|
if coll.Name != "My Collection" {
|
|
t.Errorf("Collection.Name = %q, want %q", coll.Name, "My Collection")
|
|
}
|
|
|
|
// Get
|
|
got, err := store.GetCollection(ctx, coll.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetCollection: %v", err)
|
|
}
|
|
if got.Name != "My Collection" {
|
|
t.Errorf("GetCollection Name = %q, want %q", got.Name, "My Collection")
|
|
}
|
|
|
|
// List
|
|
colls, err := store.ListCollections(ctx, "p1")
|
|
if err != nil {
|
|
t.Fatalf("ListCollections: %v", err)
|
|
}
|
|
if len(colls) != 1 {
|
|
t.Errorf("ListCollections returned %d, want 1", len(colls))
|
|
}
|
|
|
|
// Update
|
|
newName := "Renamed"
|
|
if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{
|
|
ID: coll.ID,
|
|
RequestProfileID: "p1",
|
|
Name: &newName,
|
|
}); err != nil {
|
|
t.Fatalf("UpdateCollection: %v", err)
|
|
}
|
|
got, _ = store.GetCollection(ctx, coll.ID)
|
|
if got.Name != "Renamed" {
|
|
t.Errorf("Updated Name = %q, want %q", got.Name, "Renamed")
|
|
}
|
|
|
|
// Add items
|
|
if err := store.AddCollectionItem(ctx, coll.ID, "item-1", 0); err != nil {
|
|
t.Fatalf("AddCollectionItem: %v", err)
|
|
}
|
|
if err := store.AddCollectionItem(ctx, coll.ID, "item-2", 1); err != nil {
|
|
t.Fatalf("AddCollectionItem: %v", err)
|
|
}
|
|
|
|
// List items
|
|
items, err := store.ListCollectionItems(ctx, coll.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListCollectionItems: %v", err)
|
|
}
|
|
if len(items) != 2 {
|
|
t.Errorf("ListCollectionItems returned %d, want 2", len(items))
|
|
}
|
|
|
|
// Remove item
|
|
if err := store.RemoveCollectionItem(ctx, coll.ID, "item-1"); err != nil {
|
|
t.Fatalf("RemoveCollectionItem: %v", err)
|
|
}
|
|
items, _ = store.ListCollectionItems(ctx, coll.ID)
|
|
if len(items) != 1 {
|
|
t.Errorf("After remove, ListCollectionItems returned %d, want 1", len(items))
|
|
}
|
|
|
|
// Delete collection (should also delete items)
|
|
if err := store.DeleteCollection(ctx, coll.ID); err != nil {
|
|
t.Fatalf("DeleteCollection: %v", err)
|
|
}
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p2", Name: "Viewer"}); err != nil {
|
|
t.Fatalf("CreateProfile(p2): %v", err)
|
|
}
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p3", Name: "Blocked"}); err != nil {
|
|
t.Fatalf("CreateProfile(p3): %v", err)
|
|
}
|
|
|
|
shared, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{
|
|
CreatorProfileID: "p1",
|
|
Name: "Family Action",
|
|
CollectionType: "smart",
|
|
IsShared: true,
|
|
AllowedProfileIDs: []string{"p1", "p2"},
|
|
QueryDefinition: `{"match":"all","groups":[]}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateCollection(shared): %v", err)
|
|
}
|
|
|
|
visible, err := store.ListCollections(ctx, "p2")
|
|
if err != nil {
|
|
t.Fatalf("ListCollections(p2): %v", err)
|
|
}
|
|
if len(visible) != 1 || visible[0].ID != shared.ID {
|
|
t.Fatalf("ListCollections(p2) = %+v, want shared collection", visible)
|
|
}
|
|
|
|
blocked, err := store.ListCollections(ctx, "p3")
|
|
if err != nil {
|
|
t.Fatalf("ListCollections(p3): %v", err)
|
|
}
|
|
if len(blocked) != 0 {
|
|
t.Fatalf("ListCollections(p3) returned %d collections, want 0", len(blocked))
|
|
}
|
|
|
|
rejectedName := "Not Allowed"
|
|
if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{
|
|
ID: shared.ID,
|
|
RequestProfileID: "p2",
|
|
Name: &rejectedName,
|
|
}); err == nil {
|
|
t.Fatal("expected creator-only UpdateCollection rejection")
|
|
}
|
|
}
|
|
|
|
func testSettings(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
// Get non-existent
|
|
val, err := store.GetSetting(ctx, "theme")
|
|
if err != nil {
|
|
t.Fatalf("GetSetting(nonexistent): %v", err)
|
|
}
|
|
if val != "" {
|
|
t.Errorf("GetSetting(nonexistent) = %q, want empty", val)
|
|
}
|
|
|
|
// Set
|
|
if err := store.SetSetting(ctx, "theme", "dark"); err != nil {
|
|
t.Fatalf("SetSetting: %v", err)
|
|
}
|
|
val, err = store.GetSetting(ctx, "theme")
|
|
if err != nil {
|
|
t.Fatalf("GetSetting: %v", err)
|
|
}
|
|
if val != "dark" {
|
|
t.Errorf("GetSetting = %q, want %q", val, "dark")
|
|
}
|
|
|
|
// Update (upsert)
|
|
if err := store.SetSetting(ctx, "theme", "light"); err != nil {
|
|
t.Fatalf("SetSetting(update): %v", err)
|
|
}
|
|
val, _ = store.GetSetting(ctx, "theme")
|
|
if val != "light" {
|
|
t.Errorf("GetSetting after update = %q, want %q", val, "light")
|
|
}
|
|
|
|
// List
|
|
if err := store.SetSetting(ctx, "lang", "en"); err != nil {
|
|
t.Fatalf("SetSetting(lang): %v", err)
|
|
}
|
|
entries, err := store.ListSettings(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListSettings: %v", err)
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Errorf("ListSettings returned %d, want 2", len(entries))
|
|
}
|
|
|
|
// Delete
|
|
if err := store.DeleteSetting(ctx, "theme"); err != nil {
|
|
t.Fatalf("DeleteSetting: %v", err)
|
|
}
|
|
val, _ = store.GetSetting(ctx, "theme")
|
|
if val != "" {
|
|
t.Errorf("GetSetting after delete = %q, want empty", val)
|
|
}
|
|
|
|
deviceSetting, err := store.GetDeviceSetting(ctx, "p1", "apple-tv", "subtitle_appearance")
|
|
if err != nil {
|
|
t.Fatalf("GetDeviceSetting(nonexistent): %v", err)
|
|
}
|
|
if deviceSetting != nil {
|
|
t.Fatalf("GetDeviceSetting(nonexistent) = %#v, want nil", deviceSetting)
|
|
}
|
|
|
|
if err := store.SetDeviceSetting(ctx, userstore.DeviceSettingEntry{
|
|
ProfileID: "p1",
|
|
DeviceID: "apple-tv",
|
|
DeviceName: "Living Room",
|
|
DevicePlatform: "tvOS",
|
|
Key: "subtitle_appearance",
|
|
Value: `{"fontSize":"large"}`,
|
|
}); err != nil {
|
|
t.Fatalf("SetDeviceSetting: %v", err)
|
|
}
|
|
deviceSetting, err = store.GetDeviceSetting(ctx, "p1", "apple-tv", "subtitle_appearance")
|
|
if err != nil {
|
|
t.Fatalf("GetDeviceSetting: %v", err)
|
|
}
|
|
if deviceSetting == nil || deviceSetting.Value != `{"fontSize":"large"}` || deviceSetting.DeviceName != "Living Room" {
|
|
t.Fatalf("GetDeviceSetting = %#v, want stored override", deviceSetting)
|
|
}
|
|
|
|
if err := store.SetDeviceSetting(ctx, userstore.DeviceSettingEntry{
|
|
ProfileID: "p1",
|
|
DeviceID: "iphone",
|
|
DeviceName: "iPhone",
|
|
DevicePlatform: "iOS",
|
|
Key: "subtitle_appearance",
|
|
Value: `{"fontSize":"small"}`,
|
|
}); err != nil {
|
|
t.Fatalf("SetDeviceSetting(second): %v", err)
|
|
}
|
|
deviceSettings, err := store.ListDeviceSettings(ctx, "subtitle_appearance")
|
|
if err != nil {
|
|
t.Fatalf("ListDeviceSettings: %v", err)
|
|
}
|
|
if len(deviceSettings) != 2 {
|
|
t.Fatalf("ListDeviceSettings returned %d, want 2", len(deviceSettings))
|
|
}
|
|
allDeviceSettings, err := store.ListAllDeviceSettings(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListAllDeviceSettings: %v", err)
|
|
}
|
|
if len(allDeviceSettings) != 2 {
|
|
t.Fatalf("ListAllDeviceSettings returned %d, want 2", len(allDeviceSettings))
|
|
}
|
|
|
|
if err := store.DeleteDeviceSetting(ctx, "p1", "apple-tv", "subtitle_appearance"); err != nil {
|
|
t.Fatalf("DeleteDeviceSetting: %v", err)
|
|
}
|
|
deviceSetting, err = store.GetDeviceSetting(ctx, "p1", "apple-tv", "subtitle_appearance")
|
|
if err != nil {
|
|
t.Fatalf("GetDeviceSetting after delete: %v", err)
|
|
}
|
|
if deviceSetting != nil {
|
|
t.Fatalf("GetDeviceSetting after delete = %#v, want nil", deviceSetting)
|
|
}
|
|
|
|
if err := store.SetDeviceSetting(ctx, userstore.DeviceSettingEntry{
|
|
ProfileID: "p1",
|
|
DeviceID: "apple-tv",
|
|
Key: "player.playback_speed",
|
|
Value: "1.25",
|
|
}); err != nil {
|
|
t.Fatalf("SetDeviceSetting(third): %v", err)
|
|
}
|
|
if err := store.SetDeviceSetting(ctx, userstore.DeviceSettingEntry{
|
|
ProfileID: "p1",
|
|
DeviceID: "apple-tv",
|
|
Key: "player.audio_sync_ms",
|
|
Value: "120",
|
|
}); err != nil {
|
|
t.Fatalf("SetDeviceSetting(fourth): %v", err)
|
|
}
|
|
if err := store.DeleteAllDeviceSettings(ctx, "p1", "apple-tv"); err != nil {
|
|
t.Fatalf("DeleteAllDeviceSettings: %v", err)
|
|
}
|
|
deviceSetting, err = store.GetDeviceSetting(ctx, "p1", "apple-tv", "player.playback_speed")
|
|
if err != nil {
|
|
t.Fatalf("GetDeviceSetting after delete-all-device: %v", err)
|
|
}
|
|
if deviceSetting != nil {
|
|
t.Fatalf("GetDeviceSetting after DeleteAllDeviceSettings = %#v, want nil", deviceSetting)
|
|
}
|
|
|
|
if err := store.DeleteDeviceSettingsByKey(ctx, "subtitle_appearance"); err != nil {
|
|
t.Fatalf("DeleteDeviceSettingsByKey: %v", err)
|
|
}
|
|
deviceSettings, err = store.ListDeviceSettings(ctx, "subtitle_appearance")
|
|
if err != nil {
|
|
t.Fatalf("ListDeviceSettings after delete all: %v", err)
|
|
}
|
|
if len(deviceSettings) != 0 {
|
|
t.Fatalf("ListDeviceSettings after delete all returned %d, want 0", len(deviceSettings))
|
|
}
|
|
}
|
|
|
|
func testSubtitlePreferences(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
// Get non-existent
|
|
pref, err := store.GetSubtitlePreference(ctx, "p1", "series-1")
|
|
if err != nil {
|
|
t.Fatalf("GetSubtitlePreference(nonexistent): %v", err)
|
|
}
|
|
if pref != nil {
|
|
t.Error("GetSubtitlePreference(nonexistent) returned non-nil")
|
|
}
|
|
|
|
// Set
|
|
now := "2024-01-01T00:00:00Z"
|
|
if err := store.SetSubtitlePreference(ctx, userstore.SubtitlePreference{
|
|
ProfileID: "p1",
|
|
SeriesID: "series-1",
|
|
SubtitleLanguage: "ja",
|
|
SubtitleTrackIndex: 2,
|
|
ExternalSubtitlePath: "/subs/ep1.srt",
|
|
SubtitleMode: "off",
|
|
ShowForcedSubtitles: true,
|
|
HasShowForcedSubtitles: true,
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("SetSubtitlePreference: %v", err)
|
|
}
|
|
|
|
// Get
|
|
pref, err = store.GetSubtitlePreference(ctx, "p1", "series-1")
|
|
if err != nil {
|
|
t.Fatalf("GetSubtitlePreference: %v", err)
|
|
}
|
|
if pref == nil {
|
|
t.Fatal("GetSubtitlePreference returned nil")
|
|
}
|
|
if pref.SubtitleLanguage != "ja" {
|
|
t.Errorf("SubtitleLanguage = %q, want %q", pref.SubtitleLanguage, "ja")
|
|
}
|
|
if pref.SubtitleTrackIndex != 2 {
|
|
t.Errorf("SubtitleTrackIndex = %d, want 2", pref.SubtitleTrackIndex)
|
|
}
|
|
if !pref.ShowForcedSubtitles || !pref.HasShowForcedSubtitles {
|
|
t.Errorf("ShowForcedSubtitles = (%v, %v), want (true, true)", pref.ShowForcedSubtitles, pref.HasShowForcedSubtitles)
|
|
}
|
|
|
|
// Update (upsert)
|
|
if err := store.SetSubtitlePreference(ctx, userstore.SubtitlePreference{
|
|
ProfileID: "p1",
|
|
SeriesID: "series-1",
|
|
SubtitleLanguage: "en",
|
|
SubtitleMode: "auto",
|
|
ShowForcedSubtitles: false,
|
|
HasShowForcedSubtitles: true,
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("SetSubtitlePreference(update): %v", err)
|
|
}
|
|
pref, _ = store.GetSubtitlePreference(ctx, "p1", "series-1")
|
|
if pref.SubtitleLanguage != "en" {
|
|
t.Errorf("Updated SubtitleLanguage = %q, want %q", pref.SubtitleLanguage, "en")
|
|
}
|
|
if pref.ShowForcedSubtitles || !pref.HasShowForcedSubtitles {
|
|
t.Errorf("Updated ShowForcedSubtitles = (%v, %v), want (false, true)", pref.ShowForcedSubtitles, pref.HasShowForcedSubtitles)
|
|
}
|
|
|
|
// Delete
|
|
if err := store.DeleteSubtitlePreference(ctx, "p1", "series-1"); err != nil {
|
|
t.Fatalf("DeleteSubtitlePreference: %v", err)
|
|
}
|
|
pref, _ = store.GetSubtitlePreference(ctx, "p1", "series-1")
|
|
if pref != nil {
|
|
t.Error("GetSubtitlePreference after delete returned non-nil")
|
|
}
|
|
}
|
|
|
|
func testAudioPreferences(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
// Create required profile first.
|
|
if err := store.CreateProfile(ctx, userstore.Profile{
|
|
ID: "p1", Name: "Test", CreatedAt: "2024-01-01T00:00:00Z", UpdatedAt: "2024-01-01T00:00:00Z",
|
|
}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
// Get non-existent
|
|
ap, err := store.GetAudioPreference(ctx, "p1", "series-1")
|
|
if err != nil {
|
|
t.Fatalf("GetAudioPreference(nonexistent): %v", err)
|
|
}
|
|
if ap != nil {
|
|
t.Error("GetAudioPreference(nonexistent) returned non-nil")
|
|
}
|
|
|
|
// Set
|
|
now := "2024-01-01T00:00:00Z"
|
|
if err := store.SetAudioPreference(ctx, userstore.AudioPreference{
|
|
ProfileID: "p1",
|
|
SeriesID: "series-1",
|
|
AudioTrackIndex: 2,
|
|
AudioLanguage: "ja",
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("SetAudioPreference: %v", err)
|
|
}
|
|
|
|
// Get
|
|
ap, err = store.GetAudioPreference(ctx, "p1", "series-1")
|
|
if err != nil {
|
|
t.Fatalf("GetAudioPreference: %v", err)
|
|
}
|
|
if ap == nil {
|
|
t.Fatal("GetAudioPreference returned nil")
|
|
}
|
|
if ap.AudioLanguage != "ja" {
|
|
t.Errorf("AudioLanguage = %q, want %q", ap.AudioLanguage, "ja")
|
|
}
|
|
if ap.AudioTrackIndex != 2 {
|
|
t.Errorf("AudioTrackIndex = %d, want %d", ap.AudioTrackIndex, 2)
|
|
}
|
|
|
|
// Update (upsert)
|
|
if err := store.SetAudioPreference(ctx, userstore.AudioPreference{
|
|
ProfileID: "p1",
|
|
SeriesID: "series-1",
|
|
AudioTrackIndex: 0,
|
|
AudioLanguage: "en",
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("SetAudioPreference(update): %v", err)
|
|
}
|
|
ap, _ = store.GetAudioPreference(ctx, "p1", "series-1")
|
|
if ap.AudioLanguage != "en" {
|
|
t.Errorf("Updated AudioLanguage = %q, want %q", ap.AudioLanguage, "en")
|
|
}
|
|
if ap.AudioTrackIndex != 0 {
|
|
t.Errorf("Updated AudioTrackIndex = %d, want %d", ap.AudioTrackIndex, 0)
|
|
}
|
|
|
|
// Delete
|
|
if err := store.DeleteAudioPreference(ctx, "p1", "series-1"); err != nil {
|
|
t.Fatalf("DeleteAudioPreference: %v", err)
|
|
}
|
|
ap, _ = store.GetAudioPreference(ctx, "p1", "series-1")
|
|
if ap != nil {
|
|
t.Error("GetAudioPreference after delete returned non-nil")
|
|
}
|
|
}
|
|
|
|
func testLibraryPlaybackPreferences(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
pref, err := store.GetLibraryPlaybackPreference(ctx, "p1", 1)
|
|
if err != nil {
|
|
t.Fatalf("GetLibraryPlaybackPreference(nonexistent): %v", err)
|
|
}
|
|
if pref != nil {
|
|
t.Error("GetLibraryPlaybackPreference(nonexistent) returned non-nil")
|
|
}
|
|
|
|
now := "2024-01-01T00:00:00Z"
|
|
if err := store.UpsertLibraryPlaybackPreference(ctx, userstore.LibraryPlaybackPreference{
|
|
ProfileID: "p1",
|
|
LibraryID: 1,
|
|
AudioLanguage: "ja",
|
|
SubtitleLanguage: "en",
|
|
SubtitleMode: "always",
|
|
ShowForcedSubtitles: true,
|
|
HasShowForcedSubtitles: true,
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("UpsertLibraryPlaybackPreference: %v", err)
|
|
}
|
|
|
|
pref, err = store.GetLibraryPlaybackPreference(ctx, "p1", 1)
|
|
if err != nil {
|
|
t.Fatalf("GetLibraryPlaybackPreference: %v", err)
|
|
}
|
|
if pref == nil {
|
|
t.Fatal("GetLibraryPlaybackPreference returned nil")
|
|
}
|
|
if pref.AudioLanguage != "ja" {
|
|
t.Errorf("AudioLanguage = %q, want %q", pref.AudioLanguage, "ja")
|
|
}
|
|
if pref.SubtitleLanguage != "en" {
|
|
t.Errorf("SubtitleLanguage = %q, want %q", pref.SubtitleLanguage, "en")
|
|
}
|
|
if pref.SubtitleMode != "always" {
|
|
t.Errorf("SubtitleMode = %q, want %q", pref.SubtitleMode, "always")
|
|
}
|
|
if !pref.ShowForcedSubtitles || !pref.HasShowForcedSubtitles {
|
|
t.Errorf("ShowForcedSubtitles = (%v, %v), want (true, true)", pref.ShowForcedSubtitles, pref.HasShowForcedSubtitles)
|
|
}
|
|
|
|
if err := store.UpsertLibraryPlaybackPreference(ctx, userstore.LibraryPlaybackPreference{
|
|
ProfileID: "p1",
|
|
LibraryID: 1,
|
|
AudioLanguage: "en",
|
|
SubtitleLanguage: "es",
|
|
SubtitleMode: "auto",
|
|
ShowForcedSubtitles: false,
|
|
HasShowForcedSubtitles: true,
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("UpsertLibraryPlaybackPreference(update): %v", err)
|
|
}
|
|
pref, _ = store.GetLibraryPlaybackPreference(ctx, "p1", 1)
|
|
if pref.AudioLanguage != "en" {
|
|
t.Errorf("Updated AudioLanguage = %q, want %q", pref.AudioLanguage, "en")
|
|
}
|
|
if pref.SubtitleLanguage != "es" {
|
|
t.Errorf("Updated SubtitleLanguage = %q, want %q", pref.SubtitleLanguage, "es")
|
|
}
|
|
if pref.SubtitleMode != "auto" {
|
|
t.Errorf("Updated SubtitleMode = %q, want %q", pref.SubtitleMode, "auto")
|
|
}
|
|
if pref.ShowForcedSubtitles || !pref.HasShowForcedSubtitles {
|
|
t.Errorf("Updated ShowForcedSubtitles = (%v, %v), want (false, true)", pref.ShowForcedSubtitles, pref.HasShowForcedSubtitles)
|
|
}
|
|
|
|
if err := store.UpsertLibraryPlaybackPreference(ctx, userstore.LibraryPlaybackPreference{
|
|
ProfileID: "p1",
|
|
LibraryID: 2,
|
|
AudioLanguage: "de",
|
|
SubtitleLanguage: "fr",
|
|
SubtitleMode: "off",
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("UpsertLibraryPlaybackPreference(second): %v", err)
|
|
}
|
|
|
|
list, err := store.ListLibraryPlaybackPreferences(ctx, "p1")
|
|
if err != nil {
|
|
t.Fatalf("ListLibraryPlaybackPreferences: %v", err)
|
|
}
|
|
if len(list) != 2 {
|
|
t.Fatalf("ListLibraryPlaybackPreferences returned %d items, want 2", len(list))
|
|
}
|
|
|
|
if err := store.DeleteLibraryPlaybackPreference(ctx, "p1", 1); err != nil {
|
|
t.Fatalf("DeleteLibraryPlaybackPreference: %v", err)
|
|
}
|
|
pref, _ = store.GetLibraryPlaybackPreference(ctx, "p1", 1)
|
|
if pref != nil {
|
|
t.Error("GetLibraryPlaybackPreference after delete returned non-nil")
|
|
}
|
|
|
|
if err := store.UpsertLibraryPlaybackPreference(ctx, userstore.LibraryPlaybackPreference{
|
|
ProfileID: "p1",
|
|
LibraryID: 2,
|
|
AudioLanguage: "de",
|
|
SubtitleLanguage: "fr",
|
|
SubtitleMode: "off",
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("UpsertLibraryPlaybackPreference(lifecycle): %v", err)
|
|
}
|
|
if err := store.DeleteProfile(ctx, "p1"); err != nil {
|
|
t.Fatalf("DeleteProfile: %v", err)
|
|
}
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Recreated"}); err != nil {
|
|
t.Fatalf("CreateProfile(recreate): %v", err)
|
|
}
|
|
pref, err = store.GetLibraryPlaybackPreference(ctx, "p1", 2)
|
|
if err != nil {
|
|
t.Fatalf("GetLibraryPlaybackPreference(recreated profile): %v", err)
|
|
}
|
|
if pref != nil {
|
|
t.Fatal("GetLibraryPlaybackPreference after profile recreate returned stale preference")
|
|
}
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p2", Name: "Other"}); err != nil {
|
|
t.Fatalf("CreateProfile(p2): %v", err)
|
|
}
|
|
if err := store.UpsertLibraryPlaybackPreference(ctx, userstore.LibraryPlaybackPreference{
|
|
ProfileID: "p2",
|
|
LibraryID: 1,
|
|
AudioLanguage: "it",
|
|
SubtitleLanguage: "pt",
|
|
SubtitleMode: "auto",
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("UpsertLibraryPlaybackPreference(p2): %v", err)
|
|
}
|
|
pref, err = store.GetLibraryPlaybackPreference(ctx, "p2", 1)
|
|
if err != nil {
|
|
t.Fatalf("GetLibraryPlaybackPreference(p2): %v", err)
|
|
}
|
|
if pref == nil {
|
|
t.Fatal("GetLibraryPlaybackPreference(p2) returned nil")
|
|
}
|
|
if err := store.DeleteProfile(ctx, "p1"); err != nil {
|
|
t.Fatalf("DeleteProfile(recreated): %v", err)
|
|
}
|
|
pref, err = store.GetLibraryPlaybackPreference(ctx, "p2", 1)
|
|
if err != nil {
|
|
t.Fatalf("GetLibraryPlaybackPreference(p2 after p1 delete): %v", err)
|
|
}
|
|
if pref == nil {
|
|
t.Fatal("GetLibraryPlaybackPreference(p2 after p1 delete) returned nil")
|
|
}
|
|
}
|
|
|
|
func testProgressHints(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
noThreshold := userstore.ProgressThresholds{}
|
|
|
|
// Must have progress row first (hints are advisory — UPDATE only).
|
|
if err := store.SetProgress(ctx, "p1", "movie-1", 500, 7200, noThreshold); err != nil {
|
|
t.Fatalf("SetProgress: %v", err)
|
|
}
|
|
|
|
// Set hints.
|
|
hints := userstore.VersionHints{
|
|
FileID: 42,
|
|
Resolution: "2160p",
|
|
HDR: true,
|
|
CodecVideo: "hevc",
|
|
}
|
|
if err := store.UpdateProgressHints(ctx, "p1", "movie-1", hints); err != nil {
|
|
t.Fatalf("UpdateProgressHints: %v", err)
|
|
}
|
|
|
|
// Verify hints are returned by GetProgress.
|
|
wp, err := store.GetProgress(ctx, "p1", "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress: %v", err)
|
|
}
|
|
if wp == nil {
|
|
t.Fatal("GetProgress returned nil")
|
|
}
|
|
if wp.LastFileID == nil || *wp.LastFileID != 42 {
|
|
t.Errorf("LastFileID = %v, want 42", wp.LastFileID)
|
|
}
|
|
if wp.LastResolution == nil || *wp.LastResolution != "2160p" {
|
|
t.Errorf("LastResolution = %v, want %q", wp.LastResolution, "2160p")
|
|
}
|
|
if wp.LastHDR == nil || *wp.LastHDR != true {
|
|
t.Errorf("LastHDR = %v, want true", wp.LastHDR)
|
|
}
|
|
if wp.LastCodecVideo == nil || *wp.LastCodecVideo != "hevc" {
|
|
t.Errorf("LastCodecVideo = %v, want %q", wp.LastCodecVideo, "hevc")
|
|
}
|
|
|
|
// Overwrite hints.
|
|
hints2 := userstore.VersionHints{
|
|
FileID: 99,
|
|
Resolution: "1080p",
|
|
HDR: false,
|
|
CodecVideo: "h264",
|
|
}
|
|
if err := store.UpdateProgressHints(ctx, "p1", "movie-1", hints2); err != nil {
|
|
t.Fatalf("UpdateProgressHints(overwrite): %v", err)
|
|
}
|
|
wp, _ = store.GetProgress(ctx, "p1", "movie-1")
|
|
if wp.LastFileID == nil || *wp.LastFileID != 99 {
|
|
t.Errorf("LastFileID after overwrite = %v, want 99", wp.LastFileID)
|
|
}
|
|
if wp.LastResolution == nil || *wp.LastResolution != "1080p" {
|
|
t.Errorf("LastResolution after overwrite = %v, want %q", wp.LastResolution, "1080p")
|
|
}
|
|
|
|
// Hints for nonexistent progress row should be a no-op (not an error).
|
|
if err := store.UpdateProgressHints(ctx, "p1", "nonexistent", hints); err != nil {
|
|
t.Fatalf("UpdateProgressHints(nonexistent): %v", err)
|
|
}
|
|
|
|
// Verify hints survive in ListProgress.
|
|
all, err := store.ListProgress(ctx, "p1", "all", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListProgress: %v", err)
|
|
}
|
|
found := false
|
|
for _, wp := range all {
|
|
if wp.MediaItemID == "movie-1" {
|
|
found = true
|
|
if wp.LastFileID == nil || *wp.LastFileID != 99 {
|
|
t.Errorf("ListProgress LastFileID = %v, want 99", wp.LastFileID)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("movie-1 not found in ListProgress results")
|
|
}
|
|
|
|
// Verify hints survive UpdateProgress (position-only update).
|
|
if err := store.UpdateProgress(ctx, "p1", "movie-1", 600, 7200, noThreshold); err != nil {
|
|
t.Fatalf("UpdateProgress: %v", err)
|
|
}
|
|
wp, _ = store.GetProgress(ctx, "p1", "movie-1")
|
|
if wp.LastFileID == nil || *wp.LastFileID != 99 {
|
|
t.Errorf("LastFileID after UpdateProgress = %v, want 99 (preserved)", wp.LastFileID)
|
|
}
|
|
|
|
// Verify hints survive SetProgress (unconditional position update).
|
|
if err := store.SetProgress(ctx, "p1", "movie-1", 400, 7200, noThreshold); err != nil {
|
|
t.Fatalf("SetProgress: %v", err)
|
|
}
|
|
wp, _ = store.GetProgress(ctx, "p1", "movie-1")
|
|
if wp.LastFileID == nil || *wp.LastFileID != 99 {
|
|
t.Errorf("LastFileID after SetProgress = %v, want 99 (preserved)", wp.LastFileID)
|
|
}
|
|
|
|
// Verify pre-migration rows return nil hints.
|
|
if err := store.SetProgress(ctx, "p1", "movie-no-hints", 400, 3600, noThreshold); err != nil {
|
|
t.Fatalf("SetProgress: %v", err)
|
|
}
|
|
wp, err = store.GetProgress(ctx, "p1", "movie-no-hints")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(movie-no-hints): %v", err)
|
|
}
|
|
if wp == nil {
|
|
t.Fatal("GetProgress(movie-no-hints) returned nil")
|
|
}
|
|
if wp.LastFileID != nil {
|
|
t.Errorf("Expected nil LastFileID for row without hints, got %v", wp.LastFileID)
|
|
}
|
|
if wp.LastHDR != nil {
|
|
t.Errorf("Expected nil LastHDR for row without hints, got %v", wp.LastHDR)
|
|
}
|
|
}
|
|
|
|
func testSectionOverrides(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
if err := store.SaveSectionOverrides(ctx, "profile-1", "home", "", []userstore.SectionOverride{
|
|
{
|
|
SectionID: "section-1",
|
|
Removed: true,
|
|
Title: "Recently Added",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("SaveSectionOverrides: %v", err)
|
|
}
|
|
|
|
got, err := store.ListSectionOverrides(ctx, "profile-1", "home", "")
|
|
if err != nil {
|
|
t.Fatalf("ListSectionOverrides: %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("ListSectionOverrides returned %d overrides, want 1", len(got))
|
|
}
|
|
if !got[0].Removed {
|
|
t.Fatalf("Removed = %v, want true", got[0].Removed)
|
|
}
|
|
if got[0].SectionID != "section-1" {
|
|
t.Fatalf("SectionID = %q, want %q", got[0].SectionID, "section-1")
|
|
}
|
|
|
|
if err := store.SaveSectionOverrides(ctx, "profile-2", "home", "", []userstore.SectionOverride{
|
|
{
|
|
SectionID: "section-2",
|
|
Hidden: true,
|
|
Title: "Continue Watching",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("SaveSectionOverrides(profile-2): %v", err)
|
|
}
|
|
|
|
if err := store.SaveSectionOverrides(ctx, "profile-1", "home", "", []userstore.SectionOverride{
|
|
{
|
|
SectionID: "section-3",
|
|
Removed: true,
|
|
Title: "Top Picks",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("SaveSectionOverrides(profile-1 replacement): %v", err)
|
|
}
|
|
|
|
got, err = store.ListSectionOverrides(ctx, "profile-1", "home", "")
|
|
if err != nil {
|
|
t.Fatalf("ListSectionOverrides(profile-1 after replacement): %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("ListSectionOverrides(profile-1 after replacement) returned %d overrides, want 1", len(got))
|
|
}
|
|
if got[0].SectionID != "section-3" {
|
|
t.Fatalf("profile-1 SectionID after replacement = %q, want %q", got[0].SectionID, "section-3")
|
|
}
|
|
|
|
got, err = store.ListSectionOverrides(ctx, "profile-2", "home", "")
|
|
if err != nil {
|
|
t.Fatalf("ListSectionOverrides(profile-2): %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("ListSectionOverrides(profile-2) returned %d overrides, want 1", len(got))
|
|
}
|
|
if got[0].SectionID != "section-2" {
|
|
t.Fatalf("profile-2 SectionID = %q, want %q", got[0].SectionID, "section-2")
|
|
}
|
|
|
|
if err := store.ResetSectionOverrides(ctx, "profile-1", "home", ""); err != nil {
|
|
t.Fatalf("ResetSectionOverrides(profile-1): %v", err)
|
|
}
|
|
|
|
got, err = store.ListSectionOverrides(ctx, "profile-1", "home", "")
|
|
if err != nil {
|
|
t.Fatalf("ListSectionOverrides(profile-1 after reset): %v", err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Fatalf("ListSectionOverrides(profile-1 after reset) returned %d overrides, want 0", len(got))
|
|
}
|
|
|
|
got, err = store.ListSectionOverrides(ctx, "profile-2", "home", "")
|
|
if err != nil {
|
|
t.Fatalf("ListSectionOverrides(profile-2 after profile-1 reset): %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("ListSectionOverrides(profile-2 after profile-1 reset) returned %d overrides, want 1", len(got))
|
|
}
|
|
if got[0].SectionID != "section-2" {
|
|
t.Fatalf("profile-2 SectionID after profile-1 reset = %q, want %q", got[0].SectionID, "section-2")
|
|
}
|
|
}
|
|
|
|
func testSectionOverridesUserAddedFields(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
input := userstore.SectionOverride{
|
|
SectionID: "",
|
|
IsUserAdded: true,
|
|
UserSectionType: "hidden_gems",
|
|
UserConfig: `{"min_rating":7.5}`,
|
|
UserTitle: "Hidden Gems",
|
|
}
|
|
|
|
if err := store.SaveSectionOverrides(ctx, "profile-ua", "home", "", []userstore.SectionOverride{input}); err != nil {
|
|
t.Fatalf("SaveSectionOverrides: %v", err)
|
|
}
|
|
|
|
got, err := store.ListSectionOverrides(ctx, "profile-ua", "home", "")
|
|
if err != nil {
|
|
t.Fatalf("ListSectionOverrides: %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("ListSectionOverrides returned %d overrides, want 1", len(got))
|
|
}
|
|
|
|
o := got[0]
|
|
if !o.IsUserAdded {
|
|
t.Error("IsUserAdded = false, want true")
|
|
}
|
|
if o.UserSectionType != "hidden_gems" {
|
|
t.Errorf("UserSectionType = %q, want %q", o.UserSectionType, "hidden_gems")
|
|
}
|
|
if o.UserConfig != `{"min_rating":7.5}` {
|
|
t.Errorf("UserConfig = %q, want %q", o.UserConfig, `{"min_rating":7.5}`)
|
|
}
|
|
if o.UserTitle != "Hidden Gems" {
|
|
t.Errorf("UserTitle = %q, want %q", o.UserTitle, "Hidden Gems")
|
|
}
|
|
}
|