package watchsync import ( "context" "database/sql" "errors" "strconv" "sync" "testing" "time" "github.com/Silo-Server/silo-server/internal/historyimport" "github.com/Silo-Server/silo-server/internal/userdb" "github.com/Silo-Server/silo-server/internal/userstore" ) type serviceFakeRepo struct { connections map[string]Connection sessions map[string]DeviceAuthSession settings map[string]string syncRuns []SyncRun historyExports []HistoryExport favoriteStates []FavoriteState scrobbleConnections []Connection scrobbleSessions []ScrobbleSession scrobbleUpdates []scrobbleUpdate } type scrobbleUpdate struct { playbackSessionID string connectionID string action string positionSeconds float64 historyID string lastError string stopSentAt *time.Time } func newServiceFakeRepo() *serviceFakeRepo { return &serviceFakeRepo{ connections: make(map[string]Connection), sessions: make(map[string]DeviceAuthSession), settings: map[string]string{ "watchsync.trakt.client_id": "client-id", "watchsync.trakt.client_secret": "client-secret", "watchsync.simkl.client_id": "client-id", "watchsync.simkl.client_secret": "client-secret", }, } } func (r *serviceFakeRepo) GetServerSetting(_ context.Context, key string) (string, error) { return r.settings[key], nil } func (r *serviceFakeRepo) UpsertAuthSession( _ context.Context, session DeviceAuthSession, ) (DeviceAuthSession, error) { if session.ID == "" { session.ID = "auth-1" } r.sessions[session.ID] = session return session, nil } func (r *serviceFakeRepo) GetAuthSession(_ context.Context, id string) (DeviceAuthSession, error) { session, ok := r.sessions[id] if !ok { return DeviceAuthSession{}, errors.New("missing auth session") } return session, nil } func (r *serviceFakeRepo) UpsertConnection( _ context.Context, conn Connection, ) (Connection, error) { if conn.ID == "" { conn.ID = "conn-1" } conn = cloneConnectionForTest(conn) r.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)] = conn return cloneConnectionForTest(conn), nil } func (r *serviceFakeRepo) GetConnection( _ context.Context, provider string, userID int, profileID string, ) (Connection, bool, error) { conn, ok := r.connections[connectionKey(provider, userID, profileID)] return cloneConnectionForTest(conn), ok, nil } func (r *serviceFakeRepo) GetConnectionByID(_ context.Context, id string) (Connection, bool, error) { for _, conn := range r.connections { if conn.ID == id { return cloneConnectionForTest(conn), true, nil } } for _, conn := range r.scrobbleConnections { if conn.ID == id { return cloneConnectionForTest(conn), true, nil } } return Connection{}, false, nil } func (r *serviceFakeRepo) DeleteConnection( _ context.Context, provider string, userID int, profileID string, ) error { delete(r.connections, connectionKey(provider, userID, profileID)) return nil } func (r *serviceFakeRepo) ListConnectionsDueForSync( _ context.Context, _ time.Time, ) ([]Connection, error) { return nil, nil } func (r *serviceFakeRepo) CreateSyncRun(_ context.Context, run SyncRun) (SyncRun, error) { if run.ID == "" { run.ID = "run-" + strconv.Itoa(len(r.syncRuns)+1) } if run.Status == "" { run.Status = string(SyncRunStatusRunning) } if run.StartedAt.IsZero() { run.StartedAt = time.Now() } if run.CreatedAt.IsZero() { run.CreatedAt = run.StartedAt } r.syncRuns = append(r.syncRuns, run) return run, nil } func (r *serviceFakeRepo) CompleteSyncRun(_ context.Context, run SyncRun) (SyncRun, error) { for i := range r.syncRuns { if r.syncRuns[i].ID == run.ID { if run.CreatedAt.IsZero() { run.CreatedAt = r.syncRuns[i].CreatedAt } if run.StartedAt.IsZero() { run.StartedAt = r.syncRuns[i].StartedAt } r.syncRuns[i] = run return run, nil } } return SyncRun{}, errors.New("missing sync run") } func (r *serviceFakeRepo) GetLatestSyncRun(_ context.Context, connectionID string) (SyncRun, bool, error) { for i := len(r.syncRuns) - 1; i >= 0; i-- { if r.syncRuns[i].ConnectionID == connectionID { return r.syncRuns[i], true, nil } } return SyncRun{}, false, nil } func (r *serviceFakeRepo) GetActiveSyncRun(_ context.Context, connectionID string) (SyncRun, bool, error) { for i := len(r.syncRuns) - 1; i >= 0; i-- { run := r.syncRuns[i] if run.ConnectionID == connectionID && (run.Status == string(SyncRunStatusQueued) || run.Status == string(SyncRunStatusRunning)) { return run, true, nil } } return SyncRun{}, false, nil } func (r *serviceFakeRepo) ListSyncRuns(_ context.Context, connectionID string, limit int) ([]SyncRun, error) { if limit <= 0 || limit > 50 { limit = 10 } var runs []SyncRun for i := len(r.syncRuns) - 1; i >= 0 && len(runs) < limit; i-- { if r.syncRuns[i].ConnectionID == connectionID { runs = append(runs, r.syncRuns[i]) } } return runs, nil } func (r *serviceFakeRepo) ListLocalWatchEventConnections(_ context.Context, userID int, profileID string, kind LocalWatchEventKind) ([]Connection, error) { var conns []Connection for _, conn := range r.connections { if conn.UserID != userID || conn.ProfileID != profileID { continue } switch kind { case LocalWatchEventMarkedWatched: if conn.ExportWatchedEnabled { conns = append(conns, cloneConnectionForTest(conn)) } case LocalWatchEventMarkedUnwatched: if conn.ExportUnwatchedEnabled { conns = append(conns, cloneConnectionForTest(conn)) } } } return conns, nil } func (r *serviceFakeRepo) ListFavoriteEventConnections(_ context.Context, userID int, profileID string, kind LocalFavoriteEventKind) ([]Connection, error) { var conns []Connection for _, conn := range r.connections { if conn.UserID != userID || conn.ProfileID != profileID { continue } switch kind { case LocalFavoriteEventAdded: if conn.ExportFavoritesEnabled { conns = append(conns, cloneConnectionForTest(conn)) } case LocalFavoriteEventRemoved: if conn.ExportFavoritesEnabled { conns = append(conns, cloneConnectionForTest(conn)) } } } return conns, nil } func (r *serviceFakeRepo) UpsertHistoryExports(_ context.Context, exports []HistoryExport) error { for _, export := range exports { if export.ID == "" { export.ID = "export-" + strconv.Itoa(len(r.historyExports)+1) } replaced := false for i := range r.historyExports { if r.historyExports[i].HistoryID == export.HistoryID && r.historyExports[i].ConnectionID == export.ConnectionID { export.ID = r.historyExports[i].ID r.historyExports[i] = export replaced = true break } } if !replaced { r.historyExports = append(r.historyExports, export) } } return nil } func (r *serviceFakeRepo) ListPendingHistoryExports(_ context.Context, connectionID string, limit int) ([]HistoryExport, error) { var exports []HistoryExport for _, export := range r.historyExports { if export.ConnectionID == connectionID && export.Status == "pending" { exports = append(exports, export) if limit > 0 && len(exports) >= limit { break } } } return exports, nil } func (r *serviceFakeRepo) MarkHistoryExportStatus(_ context.Context, id string, status string, lastError string) error { for i := range r.historyExports { if r.historyExports[i].ID == id { r.historyExports[i].Status = status r.historyExports[i].LastError = lastError return nil } } return nil } func (r *serviceFakeRepo) UpsertFavoriteStates(_ context.Context, states []FavoriteState) error { for _, state := range states { replaced := false for i := range r.favoriteStates { if r.favoriteStates[i].ConnectionID == state.ConnectionID && r.favoriteStates[i].MediaItemID == state.MediaItemID { if state.ID == "" { state.ID = r.favoriteStates[i].ID } r.favoriteStates[i] = state replaced = true break } } if !replaced { if state.ID == "" { state.ID = "favorite-" + strconv.Itoa(len(r.favoriteStates)+1) } r.favoriteStates = append(r.favoriteStates, state) } } return nil } func (r *serviceFakeRepo) ListFavoriteStates(_ context.Context, connectionID string) ([]FavoriteState, error) { var states []FavoriteState for _, state := range r.favoriteStates { if state.ConnectionID == connectionID { states = append(states, state) } } return states, nil } func (r *serviceFakeRepo) ListPendingFavoriteExports(_ context.Context, connectionID string, limit int) ([]FavoriteState, error) { var states []FavoriteState for _, state := range r.favoriteStates { if state.ConnectionID == connectionID && state.LocalPresent && !state.RemotePresent && state.LastError == "" { states = append(states, state) if limit > 0 && len(states) >= limit { break } } } return states, nil } func (r *serviceFakeRepo) ListPendingFavoriteRemovals(_ context.Context, connectionID string, limit int) ([]FavoriteState, error) { var states []FavoriteState for _, state := range r.favoriteStates { if state.ConnectionID == connectionID && !state.LocalPresent && state.RemotePresent && state.LastError == "" { states = append(states, state) if limit > 0 && len(states) >= limit { break } } } return states, nil } func (r *serviceFakeRepo) MarkFavoriteExported(_ context.Context, connectionID, mediaItemID string, exportedAt time.Time) error { for i := range r.favoriteStates { if r.favoriteStates[i].ConnectionID == connectionID && r.favoriteStates[i].MediaItemID == mediaItemID { r.favoriteStates[i].RemotePresent = true r.favoriteStates[i].LocalPresent = true r.favoriteStates[i].LastExportedAt = &exportedAt } } return nil } func (r *serviceFakeRepo) MarkFavoriteRemoteRemoved(_ context.Context, connectionID, mediaItemID string, removedAt time.Time) error { for i := range r.favoriteStates { if r.favoriteStates[i].ConnectionID == connectionID && r.favoriteStates[i].MediaItemID == mediaItemID { r.favoriteStates[i].RemotePresent = false r.favoriteStates[i].LastRemovedRemoteAt = &removedAt } } return nil } func (r *serviceFakeRepo) MarkFavoriteLocalRemoved(_ context.Context, connectionID, mediaItemID string, removedAt time.Time) error { for i := range r.favoriteStates { if r.favoriteStates[i].ConnectionID == connectionID && r.favoriteStates[i].MediaItemID == mediaItemID { r.favoriteStates[i].LocalPresent = false r.favoriteStates[i].LastRemovedLocalAt = &removedAt } } return nil } func (r *serviceFakeRepo) MarkFavoriteError(_ context.Context, connectionID, mediaItemID, lastError string) error { for i := range r.favoriteStates { if r.favoriteStates[i].ConnectionID == connectionID && r.favoriteStates[i].MediaItemID == mediaItemID { r.favoriteStates[i].LastError = lastError } } return nil } func (r *serviceFakeRepo) ListScrobbleConnections(_ context.Context, _ int, _ string) ([]Connection, error) { conns := make([]Connection, 0, len(r.scrobbleConnections)) for _, conn := range r.scrobbleConnections { conns = append(conns, cloneConnectionForTest(conn)) } return conns, nil } func (r *serviceFakeRepo) UpsertScrobbleSession(_ context.Context, _ ScrobbleEvent, _ string, _ string) error { return nil } func (r *serviceFakeRepo) UpdateScrobbleSession(_ context.Context, playbackSessionID string, connectionID string, action string, positionSeconds float64, historyID string, lastError string, stopSentAt *time.Time) error { r.scrobbleUpdates = append(r.scrobbleUpdates, scrobbleUpdate{ playbackSessionID: playbackSessionID, connectionID: connectionID, action: action, positionSeconds: positionSeconds, historyID: historyID, lastError: lastError, stopSentAt: stopSentAt, }) return nil } func (r *serviceFakeRepo) ListOpenScrobbleSessions(_ context.Context) ([]ScrobbleSession, error) { return r.scrobbleSessions, nil } func connectionKey(provider string, userID int, profileID string) string { return provider + "|" + strconv.Itoa(userID) + "|" + profileID } func cloneConnectionForTest(conn Connection) Connection { conn.SyncCursors = cloneStringMapForTest(conn.SyncCursors) return conn } func cloneStringMapForTest(values map[string]string) map[string]string { if len(values) == 0 { return map[string]string{} } clone := make(map[string]string, len(values)) for key, value := range values { clone[key] = value } return clone } type authProviderStub struct { started bool polled bool refreshed bool refreshTokens TokenSet refreshErr error } func (p *authProviderStub) Key() string { return "trakt" } func (p *authProviderStub) DisplayName() string { return "Trakt" } func (p *authProviderStub) Capabilities() Capabilities { return Capabilities{} } func (p *authProviderStub) StartDeviceAuth( context.Context, ServerConfig, ) (DeviceAuthSession, error) { p.started = true return DeviceAuthSession{ Provider: "trakt", DeviceCode: "device", UserCode: "CODE", VerificationURL: "https://trakt.tv/activate", IntervalSeconds: 5, ExpiresAt: time.Now().Add(time.Minute), }, nil } func (p *authProviderStub) PollDeviceAuth( context.Context, ServerConfig, DeviceAuthSession, ) (TokenSet, error) { p.polled = true expires := time.Now().Add(time.Hour) return TokenSet{AccessToken: "access", RefreshToken: "refresh", TokenExpiresAt: &expires}, nil } func (p *authProviderStub) RefreshToken(context.Context, ServerConfig, Connection) (TokenSet, error) { p.refreshed = true if p.refreshErr != nil { return TokenSet{}, p.refreshErr } return p.refreshTokens, nil } func (p *authProviderStub) LookupAccount( context.Context, ServerConfig, Connection, ) (ProviderAccount, error) { return ProviderAccount{ID: "trakt-user-1", Username: "alex"}, nil } type watchedImporterStub struct { key string source userstore.WatchHistorySource rows []RemoteWatch } func (p watchedImporterStub) Key() string { if p.key != "" { return p.key } return "trakt" } func (p watchedImporterStub) DisplayName() string { return "Trakt" } func (p watchedImporterStub) Capabilities() Capabilities { return Capabilities{ImportWatched: true} } func (p watchedImporterStub) FetchWatched(context.Context, ServerConfig, Connection) ([]RemoteWatch, error) { return p.rows, nil } func (p watchedImporterStub) HistorySource() userstore.WatchHistorySource { if p.source == "" { return userstore.WatchHistorySourceTrakt } return p.source } type watchedBatchImporterStub struct { watchedImporterStub batch WatchedImportBatch } func (p watchedBatchImporterStub) FetchWatchedBatch(context.Context, ServerConfig, Connection) (WatchedImportBatch, error) { return p.batch, nil } type progressImporterStub struct { rows []RemoteProgress } func (p progressImporterStub) FetchProgress(context.Context, ServerConfig, Connection) ([]RemoteProgress, error) { return p.rows, nil } type progressBatchImporterStub struct { progressImporterStub batch ProgressImportBatch } func (p progressBatchImporterStub) FetchProgressBatch(context.Context, ServerConfig, Connection) (ProgressImportBatch, error) { return p.batch, nil } type watchedExporterStub struct { exportErr error key string source userstore.WatchHistorySource } func (p watchedExporterStub) Key() string { if p.key != "" { return p.key } return "trakt" } func (p watchedExporterStub) DisplayName() string { return "Trakt" } func (p watchedExporterStub) Capabilities() Capabilities { return Capabilities{ExportWatched: true} } func (p watchedExporterStub) FetchHistory(context.Context, ServerConfig, Connection) ([]RemotePlay, error) { return nil, nil } func (p watchedExporterStub) ExportHistory(context.Context, ServerConfig, Connection, []LocalPlay) (ExportResult, error) { if p.exportErr != nil { return ExportResult{}, p.exportErr } return ExportResult{}, nil } func (p watchedExporterStub) HistorySource() userstore.WatchHistorySource { if p.source == "" { return userstore.WatchHistorySourceTrakt } return p.source } type watchedImportExportStub struct { key string source userstore.WatchHistorySource rows []RemoteWatch exportErr error } func (p watchedImportExportStub) Key() string { if p.key != "" { return p.key } return "trakt" } func (p watchedImportExportStub) DisplayName() string { return "Trakt" } func (p watchedImportExportStub) Capabilities() Capabilities { return Capabilities{ImportWatched: true, ExportWatched: true} } func (p watchedImportExportStub) FetchWatched(context.Context, ServerConfig, Connection) ([]RemoteWatch, error) { return p.rows, nil } func (p watchedImportExportStub) FetchHistory(context.Context, ServerConfig, Connection) ([]RemotePlay, error) { return nil, nil } func (p watchedImportExportStub) ExportHistory(_ context.Context, _ ServerConfig, _ Connection, plays []LocalPlay) (ExportResult, error) { if p.exportErr != nil { return ExportResult{}, p.exportErr } result := ExportResult{Sent: make([]string, 0, len(plays))} for _, play := range plays { result.Sent = append(result.Sent, play.HistoryID) } return result, nil } func (p watchedImportExportStub) HistorySource() userstore.WatchHistorySource { if p.source == "" { return userstore.WatchHistorySourceTrakt } return p.source } type scrobblerStub struct { stopErr error refreshed bool refreshTokens TokenSet refreshErr error stopConns chan Connection stopEvents chan ScrobbleEvent } func (p scrobblerStub) Key() string { return "trakt" } func (p scrobblerStub) DisplayName() string { return "Trakt" } func (p scrobblerStub) Capabilities() Capabilities { return Capabilities{ScrobblePlayback: true} } func (p scrobblerStub) Start(context.Context, ServerConfig, Connection, ScrobbleEvent) error { return nil } func (p scrobblerStub) Pause(context.Context, ServerConfig, Connection, ScrobbleEvent) error { return nil } func (p scrobblerStub) Stop(_ context.Context, _ ServerConfig, conn Connection, event ScrobbleEvent) error { if p.stopConns != nil { p.stopConns <- conn } if p.stopEvents != nil { p.stopEvents <- event } return p.stopErr } func (p *scrobblerStub) RefreshToken(context.Context, ServerConfig, Connection) (TokenSet, error) { p.refreshed = true if p.refreshErr != nil { return TokenSet{}, p.refreshErr } return p.refreshTokens, nil } func (p *scrobblerStub) StartDeviceAuth(context.Context, ServerConfig) (DeviceAuthSession, error) { return DeviceAuthSession{}, nil } func (p *scrobblerStub) PollDeviceAuth(context.Context, ServerConfig, DeviceAuthSession) (TokenSet, error) { return TokenSet{}, nil } func (p *scrobblerStub) LookupAccount(context.Context, ServerConfig, Connection) (ProviderAccount, error) { return ProviderAccount{}, nil } type orderedScrobblerStub struct { mu sync.Mutex calls []string started chan string release chan struct{} } func newOrderedScrobblerStub() *orderedScrobblerStub { stub := &orderedScrobblerStub{ started: make(chan string, 3), release: make(chan struct{}), } return stub } func (p *orderedScrobblerStub) Key() string { return "simkl" } func (p *orderedScrobblerStub) DisplayName() string { return "Simkl" } func (p *orderedScrobblerStub) Capabilities() Capabilities { return Capabilities{ScrobblePlayback: true} } func (p *orderedScrobblerStub) ScrobbleOrderingKey(conn Connection, _ ScrobbleEvent) string { return "simkl:" + conn.ID } func (p *orderedScrobblerStub) Start(context.Context, ServerConfig, Connection, ScrobbleEvent) error { return p.record("start") } func (p *orderedScrobblerStub) Pause(context.Context, ServerConfig, Connection, ScrobbleEvent) error { return p.record("pause") } func (p *orderedScrobblerStub) Stop(context.Context, ServerConfig, Connection, ScrobbleEvent) error { return p.record("stop") } func (p *orderedScrobblerStub) record(action string) error { p.started <- action <-p.release p.mu.Lock() p.calls = append(p.calls, action) p.mu.Unlock() return nil } func (p *orderedScrobblerStub) waitCalls(t *testing.T, count int) []string { t.Helper() deadline := time.Now().Add(time.Second) for { p.mu.Lock() if len(p.calls) >= count { calls := append([]string{}, p.calls...) p.mu.Unlock() return calls } calls := append([]string{}, p.calls...) p.mu.Unlock() if time.Now().After(deadline) { t.Fatalf("calls = %+v, want %d", calls, count) } time.Sleep(10 * time.Millisecond) } } type staticStoreProvider struct { store userstore.UserStore } func (p staticStoreProvider) ForUser(context.Context, int) (userstore.UserStore, error) { return p.store, nil } func (p staticStoreProvider) Close() error { return nil } type unmatchedMatcherStub struct { reason string } func (m unmatchedMatcherStub) Match(context.Context, historyimport.Record) (*historyimport.Match, string, error) { return nil, m.reason, nil } type matchedMatcherStub struct { mediaItemID string } func (m matchedMatcherStub) Match(context.Context, historyimport.Record) (*historyimport.Match, string, error) { return &historyimport.Match{MediaItemID: m.mediaItemID}, "", nil } type noOpWatchState struct{} func (noOpWatchState) RecordImportedHistoryWithSource( context.Context, int, string, string, float64, bool, *time.Time, userstore.WatchHistorySource, ) (bool, error) { return false, nil } type recordingWatchState struct { sources []userstore.WatchHistorySource } func (s *recordingWatchState) RecordImportedHistoryWithSource( _ context.Context, _ int, _ string, _ string, _ float64, _ bool, _ *time.Time, source userstore.WatchHistorySource, ) (bool, error) { s.sources = append(s.sources, source) return true, nil } func TestServiceStartsAndPollsDeviceAuth(t *testing.T) { repo := newServiceFakeRepo() provider := &authProviderStub{} reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } service := NewService(repo, reg) session, err := service.StartDeviceAuth(context.Background(), 7, "profile-1", "trakt") if err != nil { t.Fatalf("StartDeviceAuth: %v", err) } if !provider.started || session.ID != "auth-1" { t.Fatalf("session = %+v started=%v", session, provider.started) } conn, err := service.PollDeviceAuth(context.Background(), 7, "profile-1", "trakt", session.ID) if err != nil { t.Fatalf("PollDeviceAuth: %v", err) } if conn.ProviderUsername != "alex" || conn.AccessToken != "access" { t.Fatalf("connection = %+v", conn) } if !conn.ImportWatchedEnabled || !conn.ImportProgressEnabled || !conn.ExportWatchedEnabled || !conn.ScrobbleEnabled { t.Fatalf("default toggles were not enabled: %+v", conn) } storedSession := repo.sessions[session.ID] if storedSession.CompletedAt == nil { t.Fatalf("auth session was not marked completed: %+v", storedSession) } } func TestServiceStartsAndPollsDeviceAuthRejectsMismatchedSession(t *testing.T) { repo := newServiceFakeRepo() provider := &authProviderStub{} reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } service := NewService(repo, reg) session, err := service.StartDeviceAuth(context.Background(), 7, "profile-1", "trakt") if err != nil { t.Fatalf("StartDeviceAuth: %v", err) } _, err = service.PollDeviceAuth(context.Background(), 7, "profile-2", "trakt", session.ID) if err == nil { t.Fatal("expected mismatched profile to be rejected") } if provider.polled { t.Fatal("provider was polled before session ownership was verified") } } func TestServiceRejectsMissingProfileScope(t *testing.T) { repo := newServiceFakeRepo() provider := &authProviderStub{} reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } service := NewService(repo, reg) if _, err := service.StartDeviceAuth(context.Background(), 0, "profile-1", "trakt"); err == nil { t.Fatal("expected missing user id to be rejected") } if _, err := service.StartDeviceAuth(context.Background(), 7, "", "trakt"); err == nil { t.Fatal("expected missing profile id to be rejected") } if _, err := service.PollDeviceAuth(context.Background(), 7, "profile-1", "trakt", ""); err == nil { t.Fatal("expected missing auth session id to be rejected") } if provider.started || provider.polled { t.Fatal("provider was called for invalid profile scope") } } func TestServicePollDeviceAuthRejectsExpiredOrCompletedSession(t *testing.T) { repo := newServiceFakeRepo() provider := &authProviderStub{} reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } service := NewService(repo, reg) now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC) service.now = func() time.Time { return now } repo.sessions["expired"] = DeviceAuthSession{ ID: "expired", Provider: "trakt", UserID: 7, ProfileID: "profile-1", ExpiresAt: now.Add(-time.Second), } if _, err := service.PollDeviceAuth(context.Background(), 7, "profile-1", "trakt", "expired"); err == nil { t.Fatal("expected expired session to be rejected") } completedAt := now.Add(-time.Minute) repo.sessions["completed"] = DeviceAuthSession{ ID: "completed", Provider: "trakt", UserID: 7, ProfileID: "profile-1", ExpiresAt: now.Add(time.Minute), CompletedAt: &completedAt, } if _, err := service.PollDeviceAuth(context.Background(), 7, "profile-1", "trakt", "completed"); err == nil { t.Fatal("expected completed session to be rejected") } if provider.polled { t.Fatal("provider was polled for expired or completed session") } } func TestServicePollDeviceAuthPreservesExistingConnectionToggles(t *testing.T) { repo := newServiceFakeRepo() provider := &authProviderStub{} reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } service := NewService(repo, reg) existing := Connection{ ID: "existing-conn", Provider: "trakt", UserID: 7, ProfileID: "profile-1", ImportWatchedEnabled: false, ImportProgressEnabled: false, ExportWatchedEnabled: true, ScrobbleEnabled: true, } repo.connections[connectionKey("trakt", 7, "profile-1")] = existing repo.sessions["auth-1"] = DeviceAuthSession{ ID: "auth-1", Provider: "trakt", UserID: 7, ProfileID: "profile-1", ExpiresAt: time.Now().Add(time.Minute), } conn, err := service.PollDeviceAuth(context.Background(), 7, "profile-1", "trakt", "auth-1") if err != nil { t.Fatalf("PollDeviceAuth: %v", err) } if conn.ID != existing.ID { t.Fatalf("connection ID = %q, want existing ID %q", conn.ID, existing.ID) } if conn.ImportWatchedEnabled || conn.ImportProgressEnabled || !conn.ExportWatchedEnabled || !conn.ScrobbleEnabled { t.Fatalf("connection toggles were not preserved: %+v", conn) } if conn.AccessToken != "access" || conn.ProviderUsername != "alex" { t.Fatalf("connection credentials/account were not refreshed: %+v", conn) } } func TestServiceRequestManualSyncCreatesAsyncRun(t *testing.T) { repo := newServiceFakeRepo() provider := &authProviderStub{} reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } service := NewService(repo, reg) now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC) service.now = func() time.Time { return now } repo.connections[connectionKey("trakt", 7, "profile-1")] = Connection{ ID: "conn-1", Provider: "trakt", UserID: 7, ProfileID: "profile-1", } result, err := service.RequestManualSync(context.Background(), 7, "profile-1", "trakt") if err != nil { t.Fatalf("RequestManualSync: %v", err) } if result.Run.ID == "" || result.Run.Status != string(SyncRunStatusRunning) { t.Fatalf("run = %+v, want running run", result.Run) } if result.RetryAfterSeconds != 0 { t.Fatalf("retry after = %d, want 0", result.RetryAfterSeconds) } deadline := time.Now().Add(time.Second) for { latest, ok, err := repo.GetLatestSyncRun(context.Background(), "conn-1") if err != nil { t.Fatalf("GetLatestSyncRun: %v", err) } if ok && latest.Status == string(SyncRunStatusSuccess) { return } if time.Now().After(deadline) { t.Fatalf("sync run did not complete: %+v", repo.syncRuns) } time.Sleep(10 * time.Millisecond) } } func TestServiceRequestManualSyncReturnsActiveRun(t *testing.T) { repo := newServiceFakeRepo() provider := &authProviderStub{} reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } service := NewService(repo, reg) repo.connections[connectionKey("trakt", 7, "profile-1")] = Connection{ ID: "conn-1", Provider: "trakt", UserID: 7, ProfileID: "profile-1", } repo.syncRuns = append(repo.syncRuns, SyncRun{ ID: "active-run", ConnectionID: "conn-1", Provider: "trakt", Trigger: "manual", Status: string(SyncRunStatusRunning), StartedAt: time.Now(), CreatedAt: time.Now(), }) result, err := service.RequestManualSync(context.Background(), 7, "profile-1", "trakt") if err != nil { t.Fatalf("RequestManualSync: %v", err) } if result.Run.ID != "active-run" { t.Fatalf("run ID = %q, want active-run", result.Run.ID) } if len(repo.syncRuns) != 1 { t.Fatalf("sync runs = %d, want 1", len(repo.syncRuns)) } } func TestServiceRequestManualSyncCooldown(t *testing.T) { repo := newServiceFakeRepo() provider := &authProviderStub{} reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } service := NewService(repo, reg) now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC) service.now = func() time.Time { return now } completedAt := now.Add(-30 * time.Minute) repo.connections[connectionKey("trakt", 7, "profile-1")] = Connection{ ID: "conn-1", Provider: "trakt", UserID: 7, ProfileID: "profile-1", } repo.syncRuns = append(repo.syncRuns, SyncRun{ ID: "recent-run", ConnectionID: "conn-1", Provider: "trakt", Trigger: "scheduled", Status: string(SyncRunStatusSuccess), StartedAt: completedAt.Add(-time.Minute), CompletedAt: &completedAt, CreatedAt: completedAt.Add(-time.Minute), }) _, err := service.RequestManualSync(context.Background(), 7, "profile-1", "trakt") var cooldown SyncCooldownError if !errors.As(err, &cooldown) { t.Fatalf("error = %v, want SyncCooldownError", err) } if cooldown.RetryAfterSeconds != 30*60 { t.Fatalf("retry after = %d, want %d", cooldown.RetryAfterSeconds, 30*60) } } func TestServiceConnectionStatusRejectsBlankAccessToken(t *testing.T) { repo := newServiceFakeRepo() provider := &authProviderStub{} reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } repo.connections[connectionKey("trakt", 7, "profile-1")] = Connection{ ID: "conn-1", Provider: "trakt", UserID: 7, ProfileID: "profile-1", AccessToken: " ", } status, err := NewService(repo, reg).GetConnectionStatus(context.Background(), 7, "profile-1", "trakt") if err != nil { t.Fatalf("GetConnectionStatus: %v", err) } if status.Connected { t.Fatalf("status = %+v, want disconnected for blank access token", status) } if status.LastError == "" { t.Fatalf("status = %+v, want reconnect error", status) } } func TestServiceSyncConnectionRejectsBlankAccessToken(t *testing.T) { repo := newServiceFakeRepo() provider := watchedExporterStub{} reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } conn := Connection{ ID: "conn-1", Provider: "trakt", UserID: 7, ProfileID: "profile-1", AccessToken: "", ExportWatchedEnabled: true, } repo.connections[connectionKey("trakt", 7, "profile-1")] = conn err := NewService(repo, reg).SyncConnection(context.Background(), conn, "scheduled") if err == nil { t.Fatal("SyncConnection error = nil, want blank token error") } latest, ok, err := repo.GetLatestSyncRun(context.Background(), "conn-1") if err != nil { t.Fatalf("GetLatestSyncRun: %v", err) } if !ok || latest.Status != string(SyncRunStatusFailed) || latest.Error == "" { t.Fatalf("latest run = %+v, want failed blank token run", latest) } } func TestServiceSyncConnectionRefreshesExpiredToken(t *testing.T) { repo := newServiceFakeRepo() now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC) expiresAt := now.Add(-time.Minute) refreshedExpiresAt := now.Add(time.Hour) provider := &authProviderStub{ refreshTokens: TokenSet{ AccessToken: "new-access", RefreshToken: "new-refresh", TokenExpiresAt: &refreshedExpiresAt, }, } reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } service := NewService(repo, reg) service.now = func() time.Time { return now } conn := Connection{ ID: "conn-1", Provider: "trakt", UserID: 7, ProfileID: "profile-1", AccessToken: "old-access", RefreshToken: "old-refresh", TokenExpiresAt: &expiresAt, } repo.connections[connectionKey("trakt", 7, "profile-1")] = conn if err := service.SyncConnection(context.Background(), conn, "scheduled"); err != nil { t.Fatalf("SyncConnection: %v", err) } if !provider.refreshed { t.Fatal("provider was not asked to refresh the expired token") } updated := repo.connections[connectionKey("trakt", 7, "profile-1")] if updated.AccessToken != "new-access" || updated.RefreshToken != "new-refresh" { t.Fatalf("connection tokens = %q/%q, want refreshed tokens", updated.AccessToken, updated.RefreshToken) } if updated.TokenExpiresAt == nil || !updated.TokenExpiresAt.Equal(refreshedExpiresAt) { t.Fatalf("token expiry = %v, want %v", updated.TokenExpiresAt, refreshedExpiresAt) } } func TestServiceSyncConnectionTreatsMatcherWarningsAsSuccess(t *testing.T) { repo := newServiceFakeRepo() watchedAt := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC) provider := watchedImporterStub{rows: []RemoteWatch{{ Provider: "trakt", Kind: "movie", Title: "Ghost Hunters", Year: 2019, LastWatchedAt: &watchedAt, }}} reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } service := NewService(repo, reg). WithMatcher(unmatchedMatcherStub{reason: `no tmdb_id match for "92820"`}). WithWatchState(noOpWatchState{}) conn := Connection{ ID: "conn-1", Provider: "trakt", UserID: 7, ProfileID: "profile-1", AccessToken: "access", ImportWatchedEnabled: true, } if err := service.SyncConnection(context.Background(), conn, "scheduled"); err != nil { t.Fatalf("SyncConnection: %v", err) } latest, ok, err := repo.GetLatestSyncRun(context.Background(), "conn-1") if err != nil { t.Fatalf("GetLatestSyncRun: %v", err) } if !ok || latest.Status != string(SyncRunStatusSuccess) { t.Fatalf("latest run = %+v, want success run", latest) } if latest.Warning == "" { t.Fatalf("latest run warning is empty: %+v", latest) } } func TestServiceImportWatchedUsesProviderHistorySource(t *testing.T) { repo := newServiceFakeRepo() watchedAt := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC) provider := watchedImporterStub{ key: "simkl", source: userstore.WatchHistorySourceSimkl, rows: []RemoteWatch{{ Provider: "simkl", Kind: historyimport.KindMovie, Title: "Inception", Year: 2010, LastWatchedAt: &watchedAt, }}, } watchState := &recordingWatchState{} service := NewService(repo, NewRegistry()). WithMatcher(matchedMatcherStub{mediaItemID: "movie-1"}). WithWatchState(watchState) result, err := service.ImportWatched(context.Background(), Connection{ ID: "conn-1", Provider: "simkl", UserID: 7, ProfileID: "profile-1", }, ServerConfig{}, provider) if err != nil { t.Fatalf("ImportWatched: %v", err) } if result.Imported != 1 { t.Fatalf("imported = %d, want 1", result.Imported) } if len(watchState.sources) != 1 || watchState.sources[0] != userstore.WatchHistorySourceSimkl { t.Fatalf("recorded sources = %+v, want simkl", watchState.sources) } } func TestServiceImportWatchedPersistsBatchCursorsAndWarnings(t *testing.T) { repo := newServiceFakeRepo() now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC) provider := watchedBatchImporterStub{ watchedImporterStub: watchedImporterStub{key: "simkl", source: userstore.WatchHistorySourceSimkl}, batch: WatchedImportBatch{ UpdatedCursors: map[string]string{"simkl.inbound.movies.completed": "2026-05-04T11:00:00Z"}, Warnings: []string{"simkl removed_from_list changed; removals are not imported"}, }, } service := NewService(repo, NewRegistry()). WithMatcher(unmatchedMatcherStub{}). WithWatchState(noOpWatchState{}) service.now = func() time.Time { return now } result, err := service.ImportWatched(context.Background(), Connection{ ID: "conn-1", Provider: "simkl", UserID: 7, ProfileID: "profile-1", SyncCursors: map[string]string{"existing": "cursor"}, }, ServerConfig{}, provider) if err != nil { t.Fatalf("ImportWatched: %v", err) } if len(result.Warnings) != 1 || result.Warnings[0] != "simkl removed_from_list changed; removals are not imported" { t.Fatalf("warnings = %+v", result.Warnings) } updated := repo.connections[connectionKey("simkl", 7, "profile-1")] if updated.LastInboundSyncAt == nil || !updated.LastInboundSyncAt.Equal(now) { t.Fatalf("last inbound sync = %v, want %v", updated.LastInboundSyncAt, now) } if updated.SyncCursors["existing"] != "cursor" || updated.SyncCursors["simkl.inbound.movies.completed"] != "2026-05-04T11:00:00Z" { t.Fatalf("sync cursors = %+v", updated.SyncCursors) } } func TestServiceImportWatchedLegacyImporterStillSetsLastSyncTimestamp(t *testing.T) { repo := newServiceFakeRepo() now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC) service := NewService(repo, NewRegistry()). WithMatcher(unmatchedMatcherStub{}). WithWatchState(noOpWatchState{}) service.now = func() time.Time { return now } _, err := service.ImportWatched(context.Background(), Connection{ ID: "conn-1", Provider: "trakt", UserID: 7, ProfileID: "profile-1", }, ServerConfig{}, watchedImporterStub{}) if err != nil { t.Fatalf("ImportWatched: %v", err) } updated := repo.connections[connectionKey("trakt", 7, "profile-1")] if updated.LastInboundSyncAt == nil || !updated.LastInboundSyncAt.Equal(now) { t.Fatalf("last inbound sync = %v, want %v", updated.LastInboundSyncAt, now) } if len(updated.SyncCursors) != 0 { t.Fatalf("sync cursors = %+v, want empty for legacy importer", updated.SyncCursors) } } func TestServiceImportProgressPersistsBatchCursorsAndWarnings(t *testing.T) { repo := newServiceFakeRepo() now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC) service := NewService(repo, NewRegistry()). WithMatcher(unmatchedMatcherStub{}). WithUserStoreProvider(staticStoreProvider{}) service.now = func() time.Time { return now } provider := progressBatchImporterStub{ batch: ProgressImportBatch{ UpdatedCursors: map[string]string{"simkl.progress.movies": "2026-05-04T11:30:00Z"}, Warnings: []string{"simkl playback movie skipped because it has no usable external id"}, }, } result, err := service.ImportProgress(context.Background(), Connection{ ID: "conn-1", Provider: "simkl", UserID: 7, ProfileID: "profile-1", SyncCursors: map[string]string{"existing": "cursor"}, }, ServerConfig{}, provider) if err != nil { t.Fatalf("ImportProgress: %v", err) } if len(result.Warnings) != 1 || result.Warnings[0] != "simkl playback movie skipped because it has no usable external id" { t.Fatalf("warnings = %+v", result.Warnings) } updated := repo.connections[connectionKey("simkl", 7, "profile-1")] if updated.LastProgressSyncAt == nil || !updated.LastProgressSyncAt.Equal(now) { t.Fatalf("last progress sync = %v, want %v", updated.LastProgressSyncAt, now) } if updated.SyncCursors["existing"] != "cursor" || updated.SyncCursors["simkl.progress.movies"] != "2026-05-04T11:30:00Z" { t.Fatalf("sync cursors = %+v", updated.SyncCursors) } } func TestServiceSyncConnectionPreservesConnectionUpdatesAcrossFlows(t *testing.T) { db, err := sql.Open("sqlite3", ":memory:") if err != nil { t.Fatalf("open sqlite: %v", err) } defer db.Close() if err := userdb.InitSchema(db); err != nil { t.Fatalf("InitSchema: %v", err) } if err := userdb.AddHistory(db, userstore.WatchHistoryEntry{ ID: "history-1", ProfileID: "profile-1", MediaItemID: "movie-1", WatchedAt: "2026-05-04T12:00:00Z", DurationSeconds: 7200, Completed: true, Source: userstore.WatchHistorySourcePlayback, Identity: userstore.WatchIdentity{ StableType: "movie", ProviderIDs: map[string]string{"tmdb": "603"}, }, }); err != nil { t.Fatalf("AddHistory: %v", err) } watchedAt := time.Date(2026, 5, 4, 13, 0, 0, 0, time.UTC) provider := watchedImportExportStub{ key: "simkl", source: userstore.WatchHistorySourceSimkl, rows: []RemoteWatch{{ Provider: "simkl", Kind: historyimport.KindMovie, Title: "Inception", Year: 2010, LastWatchedAt: &watchedAt, }}, } reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } repo := newServiceFakeRepo() service := NewService(repo, reg). WithMatcher(matchedMatcherStub{mediaItemID: "movie-1"}). WithWatchState(&recordingWatchState{}). WithUserStoreProvider(staticStoreProvider{store: userdb.NewSQLiteUserStore(db)}) now := time.Date(2026, 5, 4, 14, 0, 0, 0, time.UTC) service.now = func() time.Time { return now } conn := Connection{ ID: "conn-1", Provider: "simkl", UserID: 7, ProfileID: "profile-1", AccessToken: "access", ImportWatchedEnabled: true, ExportWatchedEnabled: true, } repo.connections[connectionKey("simkl", 7, "profile-1")] = conn if err := service.SyncConnection(context.Background(), conn, "scheduled"); err != nil { t.Fatalf("SyncConnection: %v", err) } updated := repo.connections[connectionKey("simkl", 7, "profile-1")] if updated.LastInboundSyncAt == nil { t.Fatalf("LastInboundSyncAt was not preserved across export: %+v", updated) } if updated.LastOutboundSyncAt == nil { t.Fatalf("LastOutboundSyncAt was not recorded: %+v", updated) } } func TestServiceExportWatchedDrainsPendingBatches(t *testing.T) { db, err := sql.Open("sqlite3", ":memory:") if err != nil { t.Fatalf("open sqlite: %v", err) } defer db.Close() if err := userdb.InitSchema(db); err != nil { t.Fatalf("InitSchema: %v", err) } for i := range 101 { id := strconv.Itoa(i) if err := userdb.AddHistory(db, userstore.WatchHistoryEntry{ ID: "history-" + id, ProfileID: "profile-1", MediaItemID: "movie-" + id, WatchedAt: "2026-05-04T12:00:00Z", DurationSeconds: 7200, Completed: true, Source: userstore.WatchHistorySourcePlayback, Identity: userstore.WatchIdentity{ StableType: "movie", ProviderIDs: map[string]string{"tmdb": "60" + id}, }, }); err != nil { t.Fatalf("AddHistory %d: %v", i, err) } } repo := newServiceFakeRepo() service := NewService(repo, NewRegistry()).WithUserStoreProvider(staticStoreProvider{ store: userdb.NewSQLiteUserStore(db), }) result, err := service.ExportWatched(context.Background(), Connection{ ID: "conn-1", Provider: "simkl", UserID: 7, ProfileID: "profile-1", }, ServerConfig{}, watchedImportExportStub{key: "simkl", source: userstore.WatchHistorySourceSimkl}) if err != nil { t.Fatalf("ExportWatched: %v", err) } if result.Sent != 101 { t.Fatalf("sent = %d, want 101 (result=%+v)", result.Sent, result) } for _, export := range repo.historyExports { if export.Status != "sent" { t.Fatalf("history exports = %+v, want all sent", repo.historyExports) } } } func TestServiceSyncConnectionMarksRunFailedWhenExportTransportFails(t *testing.T) { db, err := sql.Open("sqlite3", ":memory:") if err != nil { t.Fatalf("open sqlite: %v", err) } defer db.Close() if err := userdb.InitSchema(db); err != nil { t.Fatalf("InitSchema: %v", err) } if err := userdb.AddHistory(db, userstore.WatchHistoryEntry{ ID: "history-1", ProfileID: "profile-1", MediaItemID: "movie-1", WatchedAt: "2026-05-04T12:00:00Z", DurationSeconds: 7200, Completed: true, Source: userstore.WatchHistorySourcePlayback, Identity: userstore.WatchIdentity{ StableType: "movie", ProviderIDs: map[string]string{"tmdb": "603"}, }, }); err != nil { t.Fatalf("AddHistory: %v", err) } repo := newServiceFakeRepo() provider := watchedExporterStub{exportErr: errors.New("provider offline")} reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } service := NewService(repo, reg).WithUserStoreProvider(staticStoreProvider{ store: userdb.NewSQLiteUserStore(db), }) conn := Connection{ ID: "conn-1", Provider: "trakt", UserID: 7, ProfileID: "profile-1", AccessToken: "access", ExportWatchedEnabled: true, } err = service.SyncConnection(context.Background(), conn, "scheduled") if err == nil { t.Fatal("SyncConnection error = nil, want export transport failure") } latest, ok, err := repo.GetLatestSyncRun(context.Background(), "conn-1") if err != nil { t.Fatalf("GetLatestSyncRun: %v", err) } if !ok || latest.Status != string(SyncRunStatusFailed) { t.Fatalf("latest run = %+v, want failed run", latest) } if latest.Error == "" { t.Fatalf("latest run error is empty: %+v", latest) } if len(repo.historyExports) != 1 || repo.historyExports[0].Status != "failed" { t.Fatalf("history exports = %+v, want one failed export", repo.historyExports) } } func TestServiceOrderedScrobblerDispatchesInQueueOrder(t *testing.T) { repo := newServiceFakeRepo() repo.scrobbleConnections = []Connection{{ ID: "conn-1", Provider: "simkl", UserID: 7, ProfileID: "profile-1", ScrobbleEnabled: true, }} provider := newOrderedScrobblerStub() reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } service := NewService(repo, reg) event := ScrobbleEvent{ PlaybackSessionID: "session-1", UserID: 7, ProfileID: "profile-1", Kind: historyimport.KindMovie, MediaItemID: "movie-1", PositionSeconds: 10, DurationSeconds: 100, } if err := service.ScrobbleStart(context.Background(), event); err != nil { t.Fatalf("ScrobbleStart: %v", err) } if action := <-provider.started; action != "start" { t.Fatalf("first dispatch = %q, want start", action) } if err := service.ScrobblePause(context.Background(), event); err != nil { t.Fatalf("ScrobblePause: %v", err) } if err := service.ScrobbleStop(context.Background(), event); err != nil { t.Fatalf("ScrobbleStop: %v", err) } select { case action := <-provider.started: t.Fatalf("ordered dispatch advanced to %q before start completed", action) case <-time.After(25 * time.Millisecond): } provider.release <- struct{}{} if action := <-provider.started; action != "pause" { t.Fatalf("second dispatch = %q, want pause", action) } provider.release <- struct{}{} if action := <-provider.started; action != "stop" { t.Fatalf("third dispatch = %q, want stop", action) } provider.release <- struct{}{} calls := provider.waitCalls(t, 3) if calls[0] != "start" || calls[1] != "pause" || calls[2] != "stop" { t.Fatalf("calls = %+v, want start/pause/stop", calls) } } func TestServiceScrobbleStopKeepsSessionOpenWhenProviderStopFails(t *testing.T) { repo := newServiceFakeRepo() repo.scrobbleConnections = []Connection{{ ID: "conn-1", Provider: "trakt", UserID: 7, ProfileID: "profile-1", ScrobbleEnabled: true, }} provider := scrobblerStub{stopErr: errors.New("stop failed")} reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } service := NewService(repo, reg) err := service.ScrobbleStop(context.Background(), ScrobbleEvent{ PlaybackSessionID: "playback-1", UserID: 7, ProfileID: "profile-1", MediaItemID: "movie-1", HistoryID: "history-1", PositionSeconds: 120, }) if err != nil { t.Fatalf("ScrobbleStop: %v", err) } deadline := time.Now().Add(time.Second) for { for _, update := range repo.scrobbleUpdates { if update.lastError == "stop failed" { for _, seen := range repo.scrobbleUpdates { if seen.stopSentAt != nil { t.Fatalf("stop_sent_at was set despite provider failure: %+v", repo.scrobbleUpdates) } } return } } if time.Now().After(deadline) { t.Fatalf("timed out waiting for failed stop update: %+v", repo.scrobbleUpdates) } time.Sleep(10 * time.Millisecond) } } func TestServiceScrobbleRefreshesExpiredTokenBeforeDispatch(t *testing.T) { repo := newServiceFakeRepo() now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC) expiresAt := now.Add(-time.Minute) refreshedExpiresAt := now.Add(time.Hour) repo.scrobbleConnections = []Connection{{ ID: "conn-1", Provider: "trakt", UserID: 7, ProfileID: "profile-1", AccessToken: "old-access", RefreshToken: "old-refresh", TokenExpiresAt: &expiresAt, ScrobbleEnabled: true, }} provider := &scrobblerStub{ refreshTokens: TokenSet{ AccessToken: "new-access", RefreshToken: "new-refresh", TokenExpiresAt: &refreshedExpiresAt, }, stopConns: make(chan Connection, 1), } reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } service := NewService(repo, reg) service.now = func() time.Time { return now } err := service.ScrobbleStop(context.Background(), ScrobbleEvent{ PlaybackSessionID: "playback-1", UserID: 7, ProfileID: "profile-1", MediaItemID: "movie-1", HistoryID: "history-1", PositionSeconds: 120, }) if err != nil { t.Fatalf("ScrobbleStop: %v", err) } if !provider.refreshed { t.Fatal("provider was not asked to refresh the expired token") } updated := repo.connections[connectionKey("trakt", 7, "profile-1")] if updated.AccessToken != "new-access" || updated.RefreshToken != "new-refresh" { t.Fatalf("stored tokens = %q/%q, want refreshed tokens", updated.AccessToken, updated.RefreshToken) } select { case conn := <-provider.stopConns: if conn.AccessToken != "new-access" { t.Fatalf("scrobble used access token %q, want refreshed token", conn.AccessToken) } case <-time.After(time.Second): t.Fatal("timed out waiting for scrobble dispatch") } } func TestServiceSweepOpenScrobblesRetriesProviderStop(t *testing.T) { repo := newServiceFakeRepo() repo.connections[connectionKey("trakt", 7, "profile-1")] = Connection{ ID: "conn-1", Provider: "trakt", UserID: 7, ProfileID: "profile-1", AccessToken: "access", ScrobbleEnabled: true, } repo.scrobbleSessions = []ScrobbleSession{{ PlaybackSessionID: "playback-1", ConnectionID: "conn-1", MediaItemID: "movie-1", Kind: "movie", TMDBID: "603", HistoryID: "history-1", LastProgress: 5400, DurationSeconds: 7200, Completed: true, }} provider := scrobblerStub{stopEvents: make(chan ScrobbleEvent, 1)} reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC) service := NewService(repo, reg) service.now = func() time.Time { return now } if err := service.SweepOpenScrobbles(context.Background()); err != nil { t.Fatalf("SweepOpenScrobbles: %v", err) } select { case event := <-provider.stopEvents: if event.PlaybackSessionID != "playback-1" || event.UserID != 7 || event.ProfileID != "profile-1" { t.Fatalf("stop event ownership = %+v, want playback/profile context", event) } if event.TMDBID != "603" || event.DurationSeconds != 7200 || !event.Completed { t.Fatalf("stop event media fields = %+v, want persisted event metadata", event) } case <-time.After(time.Second): t.Fatal("timed out waiting for provider stop retry") } foundClosed := false for _, update := range repo.scrobbleUpdates { if update.action == "stop" && update.stopSentAt != nil { foundClosed = true } } if !foundClosed { t.Fatalf("scrobble updates = %+v, want successful stop to mark session closed", repo.scrobbleUpdates) } } func TestServiceSweepOpenScrobblesKeepsFailedStopOpen(t *testing.T) { repo := newServiceFakeRepo() repo.connections[connectionKey("trakt", 7, "profile-1")] = Connection{ ID: "conn-1", Provider: "trakt", UserID: 7, ProfileID: "profile-1", AccessToken: "access", ScrobbleEnabled: true, } repo.scrobbleSessions = []ScrobbleSession{{ PlaybackSessionID: "playback-1", ConnectionID: "conn-1", MediaItemID: "movie-1", Kind: "movie", TMDBID: "603", HistoryID: "history-1", LastProgress: 5400, DurationSeconds: 7200, }} provider := scrobblerStub{stopErr: errors.New("provider offline")} reg := NewRegistry() if err := reg.Register(provider); err != nil { t.Fatalf("Register: %v", err) } service := NewService(repo, reg) if err := service.SweepOpenScrobbles(context.Background()); err != nil { t.Fatalf("SweepOpenScrobbles: %v", err) } for _, update := range repo.scrobbleUpdates { if update.stopSentAt != nil { t.Fatalf("stop_sent_at was set despite provider failure: %+v", repo.scrobbleUpdates) } if update.lastError == "provider offline" { return } } t.Fatalf("scrobble updates = %+v, want provider error recorded", repo.scrobbleUpdates) } func TestAppendWarningSummarizesDuplicateReasons(t *testing.T) { got := appendWarning("", []string{ "missing season or episode number", "missing season or episode number", "no tmdb_id match for \"92820\"", }) want := "missing season or episode number (2 items); no tmdb_id match for \"92820\"" if got != want { t.Fatalf("appendWarning() = %q, want %q", got, want) } } type completedHistoryListerStub struct { rows []userstore.WatchHistoryEntry queries []userstore.CompletedHistoryQuery } func (s *completedHistoryListerStub) ListCompletedHistory(_ context.Context, query userstore.CompletedHistoryQuery) ([]userstore.WatchHistoryEntry, error) { s.queries = append(s.queries, query) start := query.Offset if start >= len(s.rows) { return nil, nil } limit := query.Limit if limit <= 0 { limit = len(s.rows) } end := start + limit if end > len(s.rows) { end = len(s.rows) } return s.rows[start:end], nil } func TestHasVisibleCompletedHistoryAtOrAfterScopesTargetAndPaginates(t *testing.T) { at := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC) rows := make([]userstore.WatchHistoryEntry, 501) for i := 0; i < 500; i++ { rows[i] = userstore.WatchHistoryEntry{ ProfileID: "profile-1", MediaItemID: "movie-1", WatchedAt: at.Add(-time.Duration(500-i) * time.Hour).Format(time.RFC3339), Completed: true, } } rows[500] = userstore.WatchHistoryEntry{ ProfileID: "profile-1", MediaItemID: "movie-1", WatchedAt: at.Add(time.Minute).Format(time.RFC3339), Completed: true, } store := &completedHistoryListerStub{rows: rows} found, err := hasVisibleCompletedHistoryAtOrAfter(context.Background(), store, "profile-1", "movie-1", at) if err != nil { t.Fatalf("hasVisibleCompletedHistoryAtOrAfter: %v", err) } if !found { t.Fatal("found = false, want true") } if len(store.queries) != 2 { t.Fatalf("query count = %d, want 2", len(store.queries)) } for _, query := range store.queries { if query.ProfileID != "profile-1" || len(query.MediaItemIDs) != 1 || query.MediaItemIDs[0] != "movie-1" { t.Fatalf("query was not scoped to target media item: %+v", query) } } } func TestListAllCompletedHistoryPaginatesUntilExhausted(t *testing.T) { rows := make([]userstore.WatchHistoryEntry, 501) for i := range rows { rows[i] = userstore.WatchHistoryEntry{ ID: "history-" + strconv.Itoa(i), ProfileID: "profile-1", MediaItemID: "movie-" + strconv.Itoa(i), Completed: true, } } store := &completedHistoryListerStub{rows: rows} got, err := listAllCompletedHistory(context.Background(), store, userstore.CompletedHistoryQuery{ ProfileID: "profile-1", ExcludeSources: []userstore.WatchHistorySource{userstore.WatchHistorySourceTrakt}, }) if err != nil { t.Fatalf("listAllCompletedHistory: %v", err) } if len(got) != len(rows) { t.Fatalf("history len = %d, want %d", len(got), len(rows)) } if len(store.queries) != 2 { t.Fatalf("query count = %d, want 2", len(store.queries)) } if store.queries[0].Limit != completedHistoryPageSize || store.queries[0].Offset != 0 { t.Fatalf("first query = %+v, want first page", store.queries[0]) } if store.queries[1].Limit != completedHistoryPageSize || store.queries[1].Offset != completedHistoryPageSize { t.Fatalf("second query = %+v, want second page", store.queries[1]) } for _, query := range store.queries { if query.ProfileID != "profile-1" || len(query.ExcludeSources) != 1 || query.ExcludeSources[0] != userstore.WatchHistorySourceTrakt { t.Fatalf("query did not preserve export filters: %+v", query) } } } func TestHistorySourceForProviderDefaultsAndUsesProviderSource(t *testing.T) { if got := historySourceForProvider(watchedExporterStub{source: userstore.WatchHistorySourceSimkl}); got != userstore.WatchHistorySourceSimkl { t.Fatalf("historySourceForProvider(simkl) = %q, want simkl", got) } if got := historySourceForProvider(struct{}{}); got != userstore.WatchHistorySourceImport { t.Fatalf("historySourceForProvider(no source) = %q, want import", got) } }