Files
silo-server/internal/userdb/progress_test.go
T

318 lines
9.6 KiB
Go

package userdb
import (
"database/sql"
"reflect"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/userstore"
)
func TestWatchHistoryIdentityRoundTrip(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer db.Close()
if err := InitSchema(db); err != nil {
t.Fatalf("InitSchema: %v", err)
}
seasonNumber := 2
episodeNumber := 7
entry := userstore.WatchHistoryEntry{
ProfileID: "profile-1",
MediaItemID: "episode-1",
WatchedAt: "2026-04-25T12:00:00Z",
DurationSeconds: 1800,
Completed: true,
Source: userstore.WatchHistorySourcePlayback,
Identity: userstore.WatchIdentity{
StableType: "episode",
SeriesProviderIDs: map[string]string{"tmdb": "123", "tvdb": "456"},
Season: &seasonNumber,
Episode: &episodeNumber,
},
}
if err := AddHistory(db, entry); err != nil {
t.Fatalf("AddHistory: %v", err)
}
history, err := ListHistory(db, "profile-1", 10, 0)
if err != nil {
t.Fatalf("ListHistory: %v", err)
}
if len(history) != 1 {
t.Fatalf("history len = %d, want 1", len(history))
}
got := history[0]
if got.Identity.StableType != "episode" {
t.Fatalf("Identity.StableType = %q, want episode", got.Identity.StableType)
}
if len(got.Identity.ProviderIDs) != 0 {
t.Fatalf("Identity.ProviderIDs = %#v, want empty", got.Identity.ProviderIDs)
}
if !reflect.DeepEqual(got.Identity.SeriesProviderIDs, entry.Identity.SeriesProviderIDs) {
t.Fatalf("Identity.SeriesProviderIDs = %#v, want %#v", got.Identity.SeriesProviderIDs, entry.Identity.SeriesProviderIDs)
}
if got.Identity.Season == nil || *got.Identity.Season != seasonNumber {
t.Fatalf("Identity.Season = %v, want %d", got.Identity.Season, seasonNumber)
}
if got.Identity.Episode == nil || *got.Identity.Episode != episodeNumber {
t.Fatalf("Identity.Episode = %v, want %d", got.Identity.Episode, episodeNumber)
}
}
func TestAddHistoryIfMissingKeepsExistingSemantics(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer db.Close()
if err := InitSchema(db); err != nil {
t.Fatalf("InitSchema: %v", err)
}
entry := userstore.WatchHistoryEntry{
ProfileID: "profile-1",
MediaItemID: "movie-1",
WatchedAt: "2026-04-25T12:00:00Z",
DurationSeconds: 7200,
Completed: true,
Source: userstore.WatchHistorySourceImport,
Identity: userstore.WatchIdentity{
StableType: "movie",
ProviderIDs: map[string]string{"tmdb": "603"},
},
}
created, err := AddHistoryIfMissing(db, entry)
if err != nil {
t.Fatalf("AddHistoryIfMissing(first): %v", err)
}
if !created {
t.Fatal("AddHistoryIfMissing(first) created = false, want true")
}
entry.Identity.ProviderIDs = map[string]string{"tmdb": "999"}
created, err = AddHistoryIfMissing(db, entry)
if err != nil {
t.Fatalf("AddHistoryIfMissing(second): %v", err)
}
if created {
t.Fatal("AddHistoryIfMissing(second) created = true, want false")
}
history, err := ListHistory(db, "profile-1", 10, 0)
if err != nil {
t.Fatalf("ListHistory: %v", err)
}
if len(history) != 1 {
t.Fatalf("history len = %d, want 1", len(history))
}
if got := history[0].Identity.ProviderIDs["tmdb"]; got != "603" {
t.Fatalf("stored tmdb id = %q, want 603", got)
}
}
func TestListCompletedHistoryAppliesScopedFilters(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer db.Close()
if err := InitSchema(db); err != nil {
t.Fatalf("InitSchema: %v", err)
}
entries := []userstore.WatchHistoryEntry{
{
ProfileID: "profile-1",
MediaItemID: "movie-1",
WatchedAt: "2026-04-25T12:00:00Z",
DurationSeconds: 7200,
Completed: true,
Source: userstore.WatchHistorySourcePlayback,
},
{
ProfileID: "profile-1",
MediaItemID: "movie-2",
WatchedAt: "2026-04-26T12:00:00Z",
DurationSeconds: 7200,
Completed: true,
Source: userstore.WatchHistorySourceTrakt,
},
{
ProfileID: "profile-1",
MediaItemID: "movie-3",
WatchedAt: "2026-04-27T12:00:00Z",
DurationSeconds: 7200,
Completed: true,
Source: userstore.WatchHistorySourceImport,
},
}
for _, entry := range entries {
if err := AddHistory(db, entry); err != nil {
t.Fatalf("AddHistory(%s): %v", entry.MediaItemID, err)
}
}
history, err := ListCompletedHistory(db, userstore.CompletedHistoryQuery{
ProfileID: "profile-1",
MediaItemIDs: []string{"movie-1", "movie-2"},
IncludeSources: []userstore.WatchHistorySource{userstore.WatchHistorySourcePlayback, userstore.WatchHistorySourceTrakt},
ExcludeSources: []userstore.WatchHistorySource{userstore.WatchHistorySourceTrakt},
Limit: 10,
})
if err != nil {
t.Fatalf("ListCompletedHistory: %v", err)
}
if len(history) != 1 {
t.Fatalf("history len = %d, want 1: %+v", len(history), history)
}
if history[0].MediaItemID != "movie-1" {
t.Fatalf("media item = %q, want movie-1", history[0].MediaItemID)
}
}
func TestMarkProgressBatch_CompactsDirtyInput(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer db.Close()
if err := InitSchema(db); err != nil {
t.Fatalf("InitSchema: %v", err)
}
at := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
if err := MarkProgressBatch(db, "p1", []string{"a", "", "a", " "}, at); err != nil {
t.Fatalf("MarkProgressBatch: %v", err)
}
wpA, err := GetProgress(db, "p1", "a")
if err != nil {
t.Fatalf("GetProgress(a): %v", err)
}
if wpA == nil || !wpA.Completed {
t.Fatalf("GetProgress(a) = %+v, want completed", wpA)
}
if wpEmpty, err := GetProgress(db, "p1", ""); err != nil {
t.Fatalf("GetProgress(empty): %v", err)
} else if wpEmpty != nil {
t.Fatalf("GetProgress(empty) = %+v, want nil — empty IDs must be compacted", wpEmpty)
}
if wpWS, err := GetProgress(db, "p1", " "); err != nil {
t.Fatalf("GetProgress(whitespace): %v", err)
} else if wpWS != nil {
t.Fatalf("GetProgress(whitespace) = %+v, want nil — whitespace IDs must be compacted", wpWS)
}
// Sanity: only one row should exist for profile p1.
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM watch_progress WHERE profile_id = ?`, "p1").Scan(&count); err != nil {
t.Fatalf("count: %v", err)
}
if count != 1 {
t.Fatalf("watch_progress row count = %d, want 1 (dirty IDs leaked into the table)", count)
}
}
// TestClearProgressBatch_ClearsPartiallyWatchedRows pins that the batch
// path resets resume position even on rows where completed=false. The
// prior single-item ClearProgress path DELETEd the row unconditionally;
// when handlePlayedMutation routes single-item unplayed actions through
// the batch path, partially-watched rows must still have their resume
// position cleared. Otherwise "mark unplayed" leaves the item in
// Continue Watching with the user's last position intact.
//
// Regression guard for the post-perf-overhaul code review (Codex P1).
func TestClearProgressBatch_ClearsPartiallyWatchedRows(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer db.Close()
if err := InitSchema(db); err != nil {
t.Fatalf("InitSchema: %v", err)
}
// Seed a partially-watched row: completed=false, position=300.
thresholds := userstore.ProgressThresholds{WatchedPct: 90}
if err := UpdateProgress(db, "p1", "m1", 300, 1800, thresholds); err != nil {
t.Fatalf("seed UpdateProgress: %v", err)
}
wp, err := GetProgress(db, "p1", "m1")
if err != nil {
t.Fatalf("seed GetProgress: %v", err)
}
if wp == nil || wp.Completed || wp.PositionSeconds == 0 {
t.Fatalf("seed expected partially-watched (completed=false, position>0); got %+v", wp)
}
// Mark unplayed via the batch path.
at := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
if err := ClearProgressBatch(db, "p1", []string{"m1"}, at); err != nil {
t.Fatalf("ClearProgressBatch: %v", err)
}
// Resume position must be cleared. Without the fix, position_seconds
// would still be 300 because the prior `AND completed = 1` filter
// excluded this row.
wp, err = GetProgress(db, "p1", "m1")
if err != nil {
t.Fatalf("post-clear GetProgress: %v", err)
}
if wp == nil {
t.Fatalf("expected row to still exist after batch clear (UPDATE, not DELETE)")
}
if wp.PositionSeconds != 0 {
t.Fatalf("position_seconds = %v, want 0 (partial-progress not cleared by batch path)", wp.PositionSeconds)
}
if wp.Completed {
t.Fatalf("completed = true, want false")
}
}
func TestClearProgressBatch_CompactsDirtyInput(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer db.Close()
if err := InitSchema(db); err != nil {
t.Fatalf("InitSchema: %v", err)
}
at := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
if err := MarkProgressBatch(db, "p1", []string{"a"}, at); err != nil {
t.Fatalf("seed MarkProgressBatch: %v", err)
}
if err := ClearProgressBatch(db, "p1", []string{"a", "", "a", " "}, at); err != nil {
t.Fatalf("ClearProgressBatch: %v", err)
}
wpA, err := GetProgress(db, "p1", "a")
if err != nil {
t.Fatalf("GetProgress(a): %v", err)
}
if wpA == nil || wpA.Completed {
t.Fatalf("GetProgress(a) = %+v, want completed=false", wpA)
}
// Sanity: still only one row, and no dirty placeholder leaked in.
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM watch_progress WHERE profile_id = ?`, "p1").Scan(&count); err != nil {
t.Fatalf("count: %v", err)
}
if count != 1 {
t.Fatalf("watch_progress row count = %d, want 1", count)
}
}