Files
silo-server/internal/watchsync/service_test.go
40a9de7f26 feat(watchsync): add plugin-backed providers (#475)
* feat(watchsync): add plugin-backed providers

* fix(watchsync): address plugin review findings

* fix(watchsync): harden plugin provider failures

* feat(watchsync): complete plugin provider contract

* fix(watchsync): address provider review feedback

* fix(watchsync): keep device state host-private

* fix(watchsync): build reconciliation index concurrently

* fix(watchsync): preserve empty device state updates

* chore(deps): use released watch-sync SDK

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-08-06 10:30:49 -04:00

3526 lines
111 KiB
Go

package watchsync
import (
"context"
"database/sql"
"errors"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
"github.com/Silo-Server/silo-server/internal/historyimport"
"github.com/Silo-Server/silo-server/internal/userdb"
"github.com/Silo-Server/silo-server/internal/userstore"
)
const (
testProviderAccountID = "account-1"
testAccessToken = "access"
testBearerTokenType = "Bearer"
testCursorOne = "cursor-1"
testDPoPTokenType = "DPoP"
testHistoryExportID = "export-1"
testHistoryScope = "history"
testMovieMediaID = "movie-1"
testMovieProviderItemKey = "movie:tmdb:603"
testOldAccessToken = "old-access"
testOldRefreshToken = "old-refresh"
testOneValue = "one"
testPluginUsername = "alice"
testRefreshToken = "refresh"
testReconnectRequired = "reconnect required"
testRotatedAccessToken = "rotated-access"
testSecretValue = "secret"
testValidatedToken = "validated-token"
)
type serviceFakeRepo struct {
connections map[string]Connection
dueConnections []Connection
sessions map[string]DeviceAuthSession
settings map[string]string
syncRuns []SyncRun
historyExports []HistoryExport
listItemStates []ListItemState
scrobbleConnections []Connection
scrobbleSessions []ScrobbleSession
pendingReconciliations []ScrobbleSession
reconciledScrobbles map[string]time.Time
scrobbleUpdates []scrobbleUpdate
reopenedScrobbles []scrobbleUpdate
confirmedScrobbles map[string]bool
confirmingScrobbles map[string]time.Time
markSatisfiedErr error
markHistoryStatusErr error
syncRunMu sync.Mutex
scrobbleMu sync.Mutex
}
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),
confirmedScrobbles: make(map[string]bool),
confirmingScrobbles: make(map[string]time.Time),
reconciledScrobbles: make(map[string]time.Time),
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) DeferConnectionsForAccount(
_ context.Context,
provider string,
providerAccountID string,
until time.Time,
lastError string,
) (int, error) {
if providerAccountID == "" {
return 0, nil
}
deferred := 0
for key, conn := range r.connections {
if conn.Provider != provider || conn.ProviderAccountID != providerAccountID {
continue
}
untilCopy := until
conn.RateLimitedUntil = &untilCopy
conn.LastError = lastError
r.connections[key] = conn
deferred++
}
return deferred, 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) {
out := make([]Connection, 0, len(r.dueConnections))
for _, conn := range r.dueConnections {
out = append(out, cloneConnectionForTest(conn))
}
return out, nil
}
func (r *serviceFakeRepo) CreateSyncRun(_ context.Context, run SyncRun) (SyncRun, error) {
r.syncRunMu.Lock()
defer r.syncRunMu.Unlock()
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) {
r.syncRunMu.Lock()
defer r.syncRunMu.Unlock()
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) {
r.syncRunMu.Lock()
defer r.syncRunMu.Unlock()
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) {
r.syncRunMu.Lock()
defer r.syncRunMu.Unlock()
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) {
r.syncRunMu.Lock()
defer r.syncRunMu.Unlock()
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
}
if conn.RateLimitedUntil != nil && conn.RateLimitedUntil.After(time.Now()) {
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) ListListEventConnections(_ context.Context, userID int, profileID string, list ListKind) ([]Connection, error) {
var conns []Connection
for _, conn := range r.connections {
if conn.UserID != userID || conn.ProfileID != profileID {
continue
}
enabled := conn.ExportFavoritesEnabled
if list == ListKindWatchlist {
enabled = conn.ExportWatchlistEnabled
}
if enabled {
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 {
existing := r.historyExports[i]
export.ID = existing.ID
export.AttemptCount = existing.AttemptCount
if existing.Status == historyExportStatusSent || existing.Status == historyExportStatusSatisfiedByScrobble || existing.Status == historyExportStatusNotFound || existing.AttemptCount >= 5 {
export.Status = existing.Status
}
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 == historyExportStatusPending || export.Status == historyExportStatusFailed) && export.AttemptCount < 5 {
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 {
if r.markHistoryStatusErr != nil {
return r.markHistoryStatusErr
}
for i := range r.historyExports {
if r.historyExports[i].ID == id {
if r.historyExports[i].Status == historyExportStatusSent || r.historyExports[i].Status == historyExportStatusSatisfiedByScrobble || r.historyExports[i].Status == historyExportStatusNotFound {
return nil
}
r.historyExports[i].Status = status
r.historyExports[i].AttemptCount++
r.historyExports[i].LastError = lastError
return nil
}
}
return nil
}
func (r *serviceFakeRepo) MarkHistoryExportSatisfiedByScrobble(_ context.Context, connectionID string, historyID string) error {
if r.markSatisfiedErr != nil {
return r.markSatisfiedErr
}
for i := range r.historyExports {
if r.historyExports[i].ConnectionID == connectionID && r.historyExports[i].HistoryID == historyID {
if r.historyExports[i].Status == historyExportStatusSent || r.historyExports[i].Status == historyExportStatusNotFound {
return nil
}
r.historyExports[i].Status = historyExportStatusSatisfiedByScrobble
return nil
}
}
return nil
}
func (r *serviceFakeRepo) UpsertListItemStates(_ context.Context, states []ListItemState) error {
for _, state := range states {
if state.ListKind == "" {
state.ListKind = ListKindFavorites
}
replaced := false
for i := range r.listItemStates {
if r.listItemStates[i].ConnectionID == state.ConnectionID &&
r.listItemStates[i].ListKind == state.ListKind &&
r.listItemStates[i].MediaItemID == state.MediaItemID {
if state.ID == "" {
state.ID = r.listItemStates[i].ID
}
r.listItemStates[i] = state
replaced = true
break
}
}
if !replaced {
if state.ID == "" {
state.ID = "list-item-" + strconv.Itoa(len(r.listItemStates)+1)
}
r.listItemStates = append(r.listItemStates, state)
}
}
return nil
}
func (r *serviceFakeRepo) ListListItemStates(_ context.Context, connectionID string, kind ListKind) ([]ListItemState, error) {
var states []ListItemState
for _, state := range r.listItemStates {
if state.ConnectionID == connectionID && state.ListKind == kind {
states = append(states, state)
}
}
return states, nil
}
func (r *serviceFakeRepo) ListPendingListItemExports(_ context.Context, connectionID string, kind ListKind, limit int) ([]ListItemState, error) {
var states []ListItemState
for _, state := range r.listItemStates {
if state.ConnectionID == connectionID && state.ListKind == kind && state.LocalPresent && !state.RemotePresent && state.LastError == "" {
states = append(states, state)
if limit > 0 && len(states) >= limit {
break
}
}
}
return states, nil
}
func (r *serviceFakeRepo) ListPendingListItemRemovals(_ context.Context, connectionID string, kind ListKind, limit int) ([]ListItemState, error) {
var states []ListItemState
for _, state := range r.listItemStates {
if state.ConnectionID == connectionID && state.ListKind == kind && !state.LocalPresent && state.RemotePresent && state.LastError == "" {
states = append(states, state)
if limit > 0 && len(states) >= limit {
break
}
}
}
return states, nil
}
func (r *serviceFakeRepo) markListItem(connectionID string, kind ListKind, mediaItemID string, apply func(*ListItemState)) {
for i := range r.listItemStates {
if r.listItemStates[i].ConnectionID == connectionID && r.listItemStates[i].ListKind == kind && r.listItemStates[i].MediaItemID == mediaItemID {
apply(&r.listItemStates[i])
}
}
}
func (r *serviceFakeRepo) MarkListItemExported(_ context.Context, connectionID string, kind ListKind, mediaItemID string, exportedAt time.Time) error {
// Mirror Postgres: successful transitions clear last_error.
r.markListItem(connectionID, kind, mediaItemID, func(s *ListItemState) {
s.RemotePresent = true
s.LocalPresent = true
s.LastExportedAt = &exportedAt
s.LastError = ""
})
return nil
}
func (r *serviceFakeRepo) MarkListItemRemoteRemoved(_ context.Context, connectionID string, kind ListKind, mediaItemID string, removedAt time.Time) error {
r.markListItem(connectionID, kind, mediaItemID, func(s *ListItemState) {
s.RemotePresent = false
s.LastRemovedRemoteAt = &removedAt
s.LastError = ""
})
return nil
}
func (r *serviceFakeRepo) MarkListItemLocalRemoved(_ context.Context, connectionID string, kind ListKind, mediaItemID string, removedAt time.Time) error {
r.markListItem(connectionID, kind, mediaItemID, func(s *ListItemState) {
s.LocalPresent = false
s.LastRemovedLocalAt = &removedAt
s.LastError = ""
})
return nil
}
func (r *serviceFakeRepo) MarkListItemError(_ context.Context, connectionID string, kind ListKind, mediaItemID, lastError string) error {
r.markListItem(connectionID, kind, mediaItemID, func(s *ListItemState) {
s.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 {
if conn.RateLimitedUntil != nil && conn.RateLimitedUntil.After(time.Now()) {
continue
}
conns = append(conns, cloneConnectionForTest(conn))
}
return conns, nil
}
func (r *serviceFakeRepo) UpsertScrobbleSession(_ context.Context, _ ScrobbleEvent, _ string, _ string) error {
return nil
}
func (r *serviceFakeRepo) PrepareConfirmedScrobbleStop(_ context.Context, event ScrobbleEvent, connectionID string, _ time.Time) (confirmedStopPreparation, time.Time, error) {
r.scrobbleMu.Lock()
defer r.scrobbleMu.Unlock()
key := event.PlaybackSessionID + "|" + connectionID
if r.confirmedScrobbles[key] {
return confirmedStopAlreadySent, time.Time{}, nil
}
if !r.confirmingScrobbles[key].IsZero() {
return confirmedStopInProgress, time.Time{}, nil
}
claimVersion := time.Now()
r.confirmingScrobbles[key] = claimVersion
r.reopenedScrobbles = append(r.reopenedScrobbles, scrobbleUpdate{
playbackSessionID: event.PlaybackSessionID,
connectionID: connectionID,
action: "stop",
positionSeconds: event.PositionSeconds,
historyID: event.HistoryID,
})
return confirmedStopPrepared, claimVersion, nil
}
func (r *serviceFakeRepo) CompleteConfirmedScrobbleStop(_ context.Context, playbackSessionID string, connectionID string, positionSeconds float64, historyID string, claimVersion time.Time, stopSentAt time.Time) error {
r.scrobbleMu.Lock()
defer r.scrobbleMu.Unlock()
key := playbackSessionID + "|" + connectionID
if r.confirmingScrobbles[key] != claimVersion {
return errConfirmedStopClaimLost
}
delete(r.confirmingScrobbles, key)
r.confirmedScrobbles[key] = true
r.scrobbleUpdates = append(r.scrobbleUpdates, scrobbleUpdate{
playbackSessionID: playbackSessionID,
connectionID: connectionID,
action: "stop_confirmed",
positionSeconds: positionSeconds,
historyID: historyID,
stopSentAt: &stopSentAt,
})
if historyID != "" {
r.pendingReconciliations = append(r.pendingReconciliations, ScrobbleSession{
PlaybackSessionID: playbackSessionID, ConnectionID: connectionID, HistoryID: historyID,
})
}
return nil
}
func (r *serviceFakeRepo) FailConfirmedScrobbleStop(_ context.Context, playbackSessionID string, connectionID string, positionSeconds float64, historyID string, claimVersion time.Time, lastError string) error {
r.scrobbleMu.Lock()
defer r.scrobbleMu.Unlock()
key := playbackSessionID + "|" + connectionID
if r.confirmingScrobbles[key] != claimVersion {
return errConfirmedStopClaimLost
}
delete(r.confirmingScrobbles, key)
r.scrobbleUpdates = append(r.scrobbleUpdates, scrobbleUpdate{
playbackSessionID: playbackSessionID,
connectionID: connectionID,
action: "stop_confirming",
positionSeconds: positionSeconds,
historyID: historyID,
lastError: lastError,
})
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.scrobbleMu.Lock()
defer r.scrobbleMu.Unlock()
r.scrobbleUpdates = append(r.scrobbleUpdates, scrobbleUpdate{
playbackSessionID: playbackSessionID,
connectionID: connectionID,
action: action,
positionSeconds: positionSeconds,
historyID: historyID,
lastError: lastError,
stopSentAt: stopSentAt,
})
if stopSentAt != nil && historyID != "" {
r.pendingReconciliations = append(r.pendingReconciliations, ScrobbleSession{
PlaybackSessionID: playbackSessionID, ConnectionID: connectionID, HistoryID: historyID,
})
}
return nil
}
func (r *serviceFakeRepo) ListOpenScrobbleSessions(_ context.Context) ([]ScrobbleSession, error) {
r.scrobbleMu.Lock()
defer r.scrobbleMu.Unlock()
return append([]ScrobbleSession(nil), r.scrobbleSessions...), nil
}
func (r *serviceFakeRepo) ListPendingScrobbleReconciliations(_ context.Context) ([]ScrobbleSession, error) {
r.scrobbleMu.Lock()
defer r.scrobbleMu.Unlock()
return append([]ScrobbleSession(nil), r.pendingReconciliations...), nil
}
func (r *serviceFakeRepo) MarkScrobbleHistoryReconciled(_ context.Context, playbackSessionID, connectionID string, reconciledAt time.Time) error {
r.scrobbleMu.Lock()
defer r.scrobbleMu.Unlock()
key := playbackSessionID + "|" + connectionID
r.reconciledScrobbles[key] = reconciledAt
remaining := r.pendingReconciliations[:0]
for _, session := range r.pendingReconciliations {
if session.PlaybackSessionID == playbackSessionID && session.ConnectionID == connectionID {
continue
}
remaining = append(remaining, session)
}
r.pendingReconciliations = remaining
return nil
}
func (r *serviceFakeRepo) syncRunsSnapshot() []SyncRun {
r.syncRunMu.Lock()
defer r.syncRunMu.Unlock()
return append([]SyncRun(nil), r.syncRuns...)
}
func (r *serviceFakeRepo) scrobbleUpdatesSnapshot() []scrobbleUpdate {
r.scrobbleMu.Lock()
defer r.scrobbleMu.Unlock()
return append([]scrobbleUpdate(nil), r.scrobbleUpdates...)
}
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
pollErr error
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
if p.pollErr != nil {
return TokenSet{}, p.pollErr
}
expires := time.Now().Add(time.Hour)
return TokenSet{AccessToken: testAccessToken, RefreshToken: testRefreshToken, 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 emptyPluginAPIKeyProvider struct{}
func (emptyPluginAPIKeyProvider) Key() string { return "plugin:1:tracker" }
func (emptyPluginAPIKeyProvider) DisplayName() string { return "Tracker" }
func (emptyPluginAPIKeyProvider) Capabilities() Capabilities { return Capabilities{} }
func (emptyPluginAPIKeyProvider) ProviderSource() string { return providerSourcePlugin }
func (emptyPluginAPIKeyProvider) ConnectWithAPIKey(context.Context, string) (TokenSet, ProviderAccount, error) {
return TokenSet{}, ProviderAccount{ID: testProviderAccountID}, 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
exportResult ExportResult
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) {
return p.exportResult, p.exportErr
}
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
stopStarted chan struct{}
stopRelease chan struct{}
stopFailures *atomic.Int32
}
type keyedScrobblerStub struct {
scrobblerStub
key string
}
func (p keyedScrobblerStub) Key() string {
return p.key
}
func (p keyedScrobblerStub) DisplayName() string {
return p.key
}
func (p scrobblerStub) Key() string {
return "trakt"
}
func (p scrobblerStub) DisplayName() string {
return "Trakt"
}
func (p scrobblerStub) Capabilities() Capabilities {
return Capabilities{ScrobblePlayback: true}
}
type watchedScrobblerStub struct{ scrobblerStub }
func (watchedScrobblerStub) Capabilities() Capabilities {
return Capabilities{ScrobblePlayback: true, ExportWatched: 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.stopStarted != nil {
p.stopStarted <- struct{}{}
}
if p.stopRelease != nil {
<-p.stopRelease
}
if p.stopConns != nil {
p.stopConns <- conn
}
if p.stopEvents != nil {
p.stopEvents <- event
}
if p.stopFailures != nil && p.stopFailures.Add(-1) >= 0 {
return errors.New("stop failed")
}
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
}
func (noOpWatchState) RecordImportedWatchIfNewerWithSource(
context.Context,
int,
string,
string,
float64,
float64,
bool,
time.Time,
*time.Time,
userstore.WatchHistorySource,
) (bool, error) {
return false, nil
}
type recordingWatchState struct {
sources []userstore.WatchHistorySource
updatedAt []time.Time
watchedAt []*time.Time
completed []bool
positions []float64
durations []float64
targetIDs []string
}
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 (s *recordingWatchState) RecordImportedWatchIfNewerWithSource(
_ context.Context,
_ int,
_ string,
targetID string,
duration float64,
position float64,
completed bool,
updatedAt time.Time,
watchedAt *time.Time,
source userstore.WatchHistorySource,
) (bool, error) {
s.sources = append(s.sources, source)
s.updatedAt = append(s.updatedAt, updatedAt)
s.watchedAt = append(s.watchedAt, watchedAt)
s.completed = append(s.completed, completed)
s.positions = append(s.positions, position)
s.durations = append(s.durations, duration)
s.targetIDs = append(s.targetIDs, targetID)
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 != testAccessToken {
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 TestServicePersistsRotatedPendingDeviceAuthorizationState(t *testing.T) {
repo := newServiceFakeRepo()
now := time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC)
original := DeviceAuthSession{
ID: "auth-1", Provider: "trakt", UserID: 7, ProfileID: "profile-1",
DeviceCode: "original", UserCode: "CODE", VerificationURL: "https://trakt.tv/activate",
IntervalSeconds: 5, ExpiresAt: now.Add(10 * time.Minute),
}
repo.sessions[original.ID] = original
provider := &authProviderStub{pollErr: deviceAuthorizationPendingError{session: DeviceAuthSession{
// These host-owned fields are intentionally wrong; only the rotated
// challenge state, interval, and expiry may be accepted from the provider.
ID: "wrong", Provider: "wrong", UserID: 99, ProfileID: "wrong",
DeviceCode: "rotated", UserCode: "WRONG", VerificationURL: "https://evil.example",
IntervalSeconds: 11, ExpiresAt: now.Add(20 * time.Minute),
}}}
reg := NewRegistry()
if err := reg.Register(provider); err != nil {
t.Fatal(err)
}
service := NewService(repo, reg)
service.now = func() time.Time { return now }
_, err := service.PollDeviceAuth(context.Background(), 7, "profile-1", "trakt", original.ID)
var pending deviceAuthorizationPendingError
if !errors.As(err, &pending) {
t.Fatalf("error = %#v, want pending", err)
}
stored := repo.sessions[original.ID]
if stored.ID != original.ID || stored.Provider != original.Provider || stored.UserID != original.UserID ||
stored.ProfileID != original.ProfileID || stored.UserCode != original.UserCode ||
stored.VerificationURL != original.VerificationURL || stored.DeviceCode != "rotated" ||
stored.IntervalSeconds != 11 || !stored.ExpiresAt.Equal(now.Add(20*time.Minute)) {
t.Fatalf("stored session = %#v", stored)
}
}
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 != testAccessToken || 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.syncRunsSnapshot())
}
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: testOldAccessToken,
RefreshToken: testOldRefreshToken,
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 TestServicePluginRefreshPersistsAuthoritativeCredentialsBeforeFault(t *testing.T) {
repo := newServiceFakeRepo()
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
expiresAt := now.Add(-time.Minute)
client := &fakeWatchSyncPluginClient{refreshResponse: &pluginv1.WatchSyncCredentialResponse{
Credentials: &pluginv1.WatchSyncCredentials{AccessToken: testRotatedAccessToken, TokenType: testBearerTokenType},
Fault: &pluginv1.WatchSyncFault{
Code: pluginv1.WatchSyncFaultCode_WATCH_SYNC_FAULT_CODE_INVALID_CREDENTIAL,
SafeMessage: testReconnectRequired,
},
}}
provider := testPluginProvider(t, client)
service := NewService(repo, NewRegistry())
service.now = func() time.Time { return now }
conn := Connection{
ID: "conn-1",
Provider: provider.Key(),
UserID: 7,
ProfileID: "profile-1",
AccessToken: testOldAccessToken,
RefreshToken: testOldRefreshToken,
TokenExpiresAt: &expiresAt,
}
repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)] = conn
if _, err := service.refreshConnectionIfNeeded(context.Background(), provider, ServerConfig{}, conn); !isWatchSyncInvalidCredentialError(err) {
t.Fatalf("error = %#v", err)
}
updated := repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)]
if updated.AccessToken != testRotatedAccessToken || updated.RefreshToken != "" || updated.TokenExpiresAt != nil {
t.Fatalf("connection credentials = %#v", updated)
}
if updated.LastError != testReconnectRequired {
t.Fatalf("LastError = %q", updated.LastError)
}
}
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: testAccessToken,
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: testMovieMediaID}).
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)
}
if len(watchState.targetIDs) != 1 || watchState.targetIDs[0] != testMovieMediaID {
t.Fatalf("recorded target ids = %+v, want movie-1", watchState.targetIDs)
}
if len(watchState.completed) != 1 || !watchState.completed[0] {
t.Fatalf("recorded completed flags = %+v, want true", watchState.completed)
}
if len(watchState.positions) != 1 || watchState.positions[0] != 0 {
t.Fatalf("recorded positions = %+v, want 0", watchState.positions)
}
if len(watchState.updatedAt) != 1 || !watchState.updatedAt[0].Equal(watchedAt) {
t.Fatalf("recorded updated_at = %+v, want %v", watchState.updatedAt, watchedAt)
}
if len(watchState.watchedAt) != 1 || watchState.watchedAt[0] == nil || !watchState.watchedAt[0].Equal(watchedAt) {
t.Fatalf("recorded watched_at = %+v, want %v", watchState.watchedAt, watchedAt)
}
}
func TestServiceImportWatchedSkipsRowsWithoutLastWatchedAt(t *testing.T) {
repo := newServiceFakeRepo()
provider := watchedImporterStub{
rows: []RemoteWatch{{
Provider: "trakt",
Kind: historyimport.KindMovie,
Title: "Inception",
Year: 2010,
}},
}
watchState := &recordingWatchState{}
service := NewService(repo, NewRegistry()).
WithMatcher(matchedMatcherStub{mediaItemID: testMovieMediaID}).
WithWatchState(watchState)
result, err := service.ImportWatched(context.Background(), Connection{
ID: "conn-1",
Provider: "trakt",
UserID: 7,
ProfileID: "profile-1",
}, ServerConfig{}, provider)
if err != nil {
t.Fatalf("ImportWatched: %v", err)
}
if result.Found != 1 || result.Imported != 0 {
t.Fatalf("result = %+v, want found row skipped with no import", result)
}
if len(watchState.targetIDs) != 0 {
t.Fatalf("watch state calls = %+v, want none", watchState.targetIDs)
}
}
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: testMovieMediaID,
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: testMovieMediaID}).
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: testAccessToken,
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 != historyExportStatusSent {
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: testMovieMediaID,
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: testAccessToken,
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 != historyExportStatusFailed {
t.Fatalf("history exports = %+v, want one failed export", repo.historyExports)
}
}
func TestServiceExportWatchedReturnsStatusPersistenceFailure(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer func() {
if closeErr := db.Close(); closeErr != nil {
t.Fatalf("close sqlite: %v", closeErr)
}
}()
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: testMovieMediaID,
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()
repo.markHistoryStatusErr = errors.New("persist failed")
service := NewService(repo, NewRegistry()).WithUserStoreProvider(staticStoreProvider{
store: userdb.NewSQLiteUserStore(db),
})
result, err := service.ExportWatched(context.Background(), Connection{
ID: "conn-1",
Provider: "trakt",
UserID: 7,
ProfileID: "profile-1",
}, ServerConfig{}, watchedExporterStub{exportErr: errors.New("provider offline")})
if err == nil {
t.Fatal("ExportWatched error = nil, want combined export and persistence failure")
}
if !strings.Contains(err.Error(), "provider offline") || !strings.Contains(err.Error(), "persist failed") {
t.Fatalf("error = %v", err)
}
if result.Failed != 1 {
t.Fatalf("result = %+v", result)
}
if len(repo.historyExports) != 1 || repo.historyExports[0].Status != historyExportStatusPending {
t.Fatalf("history exports = %+v", repo.historyExports)
}
}
func TestServiceCompletedScrobblePersistsAndSatisfiesHistoryExport(t *testing.T) {
repo := newServiceFakeRepo()
service := NewService(repo, NewRegistry())
conn := Connection{ID: "conn-1"}
event := ScrobbleEvent{
PlaybackSessionID: testPlaybackSessionID,
MediaItemID: testEpisodeMediaID,
Kind: historyimport.KindEpisode,
SeriesTVDBID: "123",
SeasonNumber: 1,
EpisodeNumber: 2,
HistoryID: testWatchHistoryID,
OccurredAt: time.Now().UTC(),
Completed: true,
}
if err := service.persistCompletedScrobbleExport(context.Background(), conn, event); err != nil {
t.Fatal(err)
}
if len(repo.historyExports) != 1 || repo.historyExports[0].Status != historyExportStatusPending {
t.Fatalf("history exports = %#v", repo.historyExports)
}
if repo.historyExports[0].ProviderItemKey != "show:tvdb:123:s1:e2" {
t.Fatalf("provider item key = %q", repo.historyExports[0].ProviderItemKey)
}
if err := service.dispatchScrobble(context.Background(), watchedScrobblerStub{}, ServerConfig{}, conn, event, "stop", nil); err != nil {
t.Fatal(err)
}
if repo.historyExports[0].Status != historyExportStatusSatisfiedByScrobble {
t.Fatalf("history export status = %q", repo.historyExports[0].Status)
}
}
func TestServiceCompletedScrobbleDurablyRetriesSatisfiedPersistenceWithoutResendingStop(t *testing.T) {
repo := newServiceFakeRepo()
repo.markSatisfiedErr = errors.New("database unavailable")
service := NewService(repo, NewRegistry())
conn := Connection{ID: "conn-1", Provider: "trakt"}
event := ScrobbleEvent{
PlaybackSessionID: testPlaybackSessionID,
HistoryID: testWatchHistoryID,
Completed: true,
}
repo.historyExports = []HistoryExport{{ID: testHistoryExportID, ConnectionID: conn.ID, HistoryID: event.HistoryID, Status: historyExportStatusPending}}
if err := service.dispatchScrobble(context.Background(), watchedScrobblerStub{}, ServerConfig{}, conn, event, "stop", nil); err == nil {
t.Fatal("expected reconciliation persistence failure")
}
if repo.historyExports[0].Status != historyExportStatusPending {
t.Fatalf("history export status = %q", repo.historyExports[0].Status)
}
updates := repo.scrobbleUpdatesSnapshot()
if len(updates) != 1 || updates[0].stopSentAt == nil {
t.Fatalf("scrobble updates = %#v", updates)
}
if len(repo.pendingReconciliations) != 1 {
t.Fatalf("pending reconciliations = %#v", repo.pendingReconciliations)
}
repo.markSatisfiedErr = nil
if err := service.SweepOpenScrobbles(context.Background()); err != nil {
t.Fatal(err)
}
if repo.historyExports[0].Status != historyExportStatusSatisfiedByScrobble || len(repo.pendingReconciliations) != 0 {
t.Fatalf("history exports=%#v pending=%#v", repo.historyExports, repo.pendingReconciliations)
}
if updates = repo.scrobbleUpdatesSnapshot(); len(updates) != 1 {
t.Fatalf("sweeper redispatched remote stop: %#v", updates)
}
}
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: testMovieMediaID,
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: testMovieMediaID,
HistoryID: "history-1",
PositionSeconds: 120,
})
if err != nil {
t.Fatalf("ScrobbleStop: %v", err)
}
deadline := time.Now().Add(time.Second)
for {
updates := repo.scrobbleUpdatesSnapshot()
for _, update := range updates {
if update.lastError == "stop failed" {
for _, seen := range updates {
if seen.stopSentAt != nil {
t.Fatalf("stop_sent_at was set despite provider failure: %+v", updates)
}
}
return
}
}
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for failed stop update: %+v", updates)
}
time.Sleep(10 * time.Millisecond)
}
}
func TestServiceConfirmedStopWaitsForProviderAndReopensFailedReplacement(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.ScrobbleStopConfirmed(context.Background(), ScrobbleEvent{
PlaybackSessionID: "playback-1",
UserID: 7,
ProfileID: "profile-1",
MediaItemID: testMovieMediaID,
HistoryID: "history-1",
PositionSeconds: 120,
})
if err == nil || err.Error() != "stop failed" {
t.Fatalf("ScrobbleStopConfirmed error = %v, want stop failed", err)
}
if len(repo.reopenedScrobbles) != 1 {
t.Fatalf("reopened scrobbles = %+v, want one durable reopen", repo.reopenedScrobbles)
}
reopened := repo.reopenedScrobbles[0]
if reopened.positionSeconds != 120 || reopened.historyID != "history-1" {
t.Fatalf("reopened scrobble = %+v, want authoritative progress", reopened)
}
updates := repo.scrobbleUpdatesSnapshot()
for _, update := range updates {
if update.stopSentAt != nil {
t.Fatalf("failed confirmed stop marked session closed: %+v", updates)
}
}
}
func TestServiceConfirmedStopRetriesImmediatelyAfterProviderFailure(t *testing.T) {
repo := newServiceFakeRepo()
repo.scrobbleConnections = []Connection{{
ID: "conn-1",
Provider: "trakt",
UserID: 7,
ProfileID: "profile-1",
ScrobbleEnabled: true,
}}
var failures atomic.Int32
failures.Store(1)
provider := scrobblerStub{
stopEvents: make(chan ScrobbleEvent, 2),
stopFailures: &failures,
}
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",
MediaItemID: testMovieMediaID,
}
if err := service.ScrobbleStopConfirmed(context.Background(), event); err == nil {
t.Fatal("first ScrobbleStopConfirmed unexpectedly succeeded")
}
if err := service.ScrobbleStopConfirmed(context.Background(), event); err != nil {
t.Fatalf("retry ScrobbleStopConfirmed: %v", err)
}
if len(provider.stopEvents) != 2 {
t.Fatalf("provider stop attempts = %d, want immediate retry", len(provider.stopEvents))
}
}
func TestServiceConfirmedStopWaitsInProviderOrder(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",
MediaItemID: testMovieMediaID,
}
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)
}
result := make(chan error, 1)
go func() {
result <- service.ScrobbleStopConfirmed(context.Background(), event)
}()
select {
case err := <-result:
t.Fatalf("confirmed stop returned before queued start completed: %v", err)
case <-time.After(25 * time.Millisecond):
}
provider.release <- struct{}{}
if action := <-provider.started; action != "stop" {
t.Fatalf("second dispatch = %q, want stop", action)
}
select {
case err := <-result:
t.Fatalf("confirmed stop returned before provider completed: %v", err)
case <-time.After(25 * time.Millisecond):
}
provider.release <- struct{}{}
if err := <-result; err != nil {
t.Fatalf("ScrobbleStopConfirmed: %v", err)
}
}
func TestServiceConfirmedStopDispatchesProvidersIndependently(t *testing.T) {
repo := newServiceFakeRepo()
repo.settings["watchsync.slow.client_id"] = "client-id"
repo.settings["watchsync.slow.client_secret"] = "client-secret"
repo.settings["watchsync.healthy.client_id"] = "client-id"
repo.settings["watchsync.healthy.client_secret"] = "client-secret"
repo.scrobbleConnections = []Connection{
{
ID: "conn-slow",
Provider: "slow",
UserID: 7,
ProfileID: "profile-1",
ScrobbleEnabled: true,
},
{
ID: "conn-healthy",
Provider: "healthy",
UserID: 7,
ProfileID: "profile-1",
ScrobbleEnabled: true,
},
}
slow := keyedScrobblerStub{
key: "slow",
scrobblerStub: scrobblerStub{
stopStarted: make(chan struct{}, 1),
stopRelease: make(chan struct{}),
},
}
healthy := keyedScrobblerStub{
key: "healthy",
scrobblerStub: scrobblerStub{
stopEvents: make(chan ScrobbleEvent, 1),
},
}
reg := NewRegistry()
if err := reg.Register(slow); err != nil {
t.Fatalf("Register slow provider: %v", err)
}
if err := reg.Register(healthy); err != nil {
t.Fatalf("Register healthy provider: %v", err)
}
service := NewService(repo, reg)
event := ScrobbleEvent{
PlaybackSessionID: "session-1",
UserID: 7,
ProfileID: "profile-1",
MediaItemID: testMovieMediaID,
}
result := make(chan error, 1)
go func() {
result <- service.ScrobbleStopConfirmed(context.Background(), event)
}()
select {
case <-slow.stopStarted:
case <-time.After(time.Second):
t.Fatal("timed out waiting for slow provider")
}
select {
case <-healthy.stopEvents:
case <-time.After(time.Second):
t.Fatal("healthy provider was starved by slow provider")
}
close(slow.stopRelease)
if err := <-result; err != nil {
t.Fatalf("ScrobbleStopConfirmed: %v", err)
}
}
func TestServiceConfirmedStopWaitsBehindFallbackWithoutProviderOrdering(t *testing.T) {
repo := newServiceFakeRepo()
repo.scrobbleConnections = []Connection{{
ID: "conn-1",
Provider: "trakt",
UserID: 7,
ProfileID: "profile-1",
ScrobbleEnabled: true,
}}
provider := scrobblerStub{
stopStarted: make(chan struct{}, 2),
stopRelease: make(chan struct{}),
}
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",
MediaItemID: testMovieMediaID,
}
if err := service.ScrobbleStop(context.Background(), event); err != nil {
t.Fatalf("fallback ScrobbleStop: %v", err)
}
select {
case <-provider.stopStarted:
case <-time.After(time.Second):
t.Fatal("timed out waiting for fallback stop")
}
result := make(chan error, 1)
go func() {
result <- service.ScrobbleStopConfirmed(context.Background(), event)
}()
select {
case <-provider.stopStarted:
t.Fatal("confirmed stop passed the in-flight fallback")
case <-time.After(25 * time.Millisecond):
}
provider.stopRelease <- struct{}{}
select {
case <-provider.stopStarted:
case <-time.After(time.Second):
t.Fatal("confirmed stop did not follow the fallback")
}
provider.stopRelease <- struct{}{}
if err := <-result; err != nil {
t.Fatalf("ScrobbleStopConfirmed: %v", err)
}
}
func TestServiceConfirmedStopDoesNotResendSuccessfulProvider(t *testing.T) {
repo := newServiceFakeRepo()
repo.scrobbleConnections = []Connection{{
ID: "conn-1",
Provider: "trakt",
UserID: 7,
ProfileID: "profile-1",
ScrobbleEnabled: true,
}}
provider := scrobblerStub{stopEvents: make(chan ScrobbleEvent, 2)}
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",
MediaItemID: testMovieMediaID,
}
if err := service.ScrobbleStopConfirmed(context.Background(), event); err != nil {
t.Fatalf("first ScrobbleStopConfirmed: %v", err)
}
if err := service.ScrobbleStopConfirmed(context.Background(), event); err != nil {
t.Fatalf("retry ScrobbleStopConfirmed: %v", err)
}
select {
case <-provider.stopEvents:
default:
t.Fatal("successful confirmed stop was not dispatched")
}
select {
case duplicate := <-provider.stopEvents:
t.Fatalf("successful provider received duplicate confirmed stop: %+v", duplicate)
default:
}
if len(repo.reopenedScrobbles) != 1 {
t.Fatalf("confirmed stop preparations = %+v, want one", repo.reopenedScrobbles)
}
}
func TestServiceConfirmedStopSerializesConcurrentConfirmation(t *testing.T) {
repo := newServiceFakeRepo()
repo.scrobbleConnections = []Connection{{
ID: "conn-1",
Provider: "trakt",
UserID: 7,
ProfileID: "profile-1",
ScrobbleEnabled: true,
}}
provider := scrobblerStub{
stopStarted: make(chan struct{}, 1),
stopRelease: make(chan struct{}),
}
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",
MediaItemID: testMovieMediaID,
}
firstResult := make(chan error, 1)
go func() {
firstResult <- service.ScrobbleStopConfirmed(context.Background(), event)
}()
select {
case <-provider.stopStarted:
case <-time.After(time.Second):
t.Fatal("timed out waiting for first provider stop")
}
secondResult := make(chan error, 1)
go func() {
secondResult <- service.ScrobbleStopConfirmed(context.Background(), event)
}()
select {
case err := <-secondResult:
t.Fatalf("concurrent confirmation returned before the first completed: %v", err)
case <-time.After(25 * time.Millisecond):
}
close(provider.stopRelease)
if err := <-firstResult; err != nil {
t.Fatalf("first ScrobbleStopConfirmed: %v", err)
}
if err := <-secondResult; err != nil {
t.Fatalf("second ScrobbleStopConfirmed: %v", err)
}
select {
case <-provider.stopStarted:
t.Fatal("concurrent confirmation dispatched a duplicate provider stop")
default:
}
}
func TestServiceConfirmedStopCannotCompleteReclaimedLease(t *testing.T) {
repo := newServiceFakeRepo()
repo.scrobbleConnections = []Connection{{
ID: "conn-1",
Provider: "trakt",
UserID: 7,
ProfileID: "profile-1",
ScrobbleEnabled: true,
}}
provider := scrobblerStub{
stopStarted: make(chan struct{}, 1),
stopRelease: make(chan struct{}),
}
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",
MediaItemID: testMovieMediaID,
}
result := make(chan error, 1)
go func() {
result <- service.ScrobbleStopConfirmed(context.Background(), event)
}()
select {
case <-provider.stopStarted:
case <-time.After(time.Second):
t.Fatal("timed out waiting for provider stop")
}
key := event.PlaybackSessionID + "|conn-1"
repo.confirmingScrobbles[key] = time.Now().Add(time.Second)
close(provider.stopRelease)
if err := <-result; !errors.Is(err, errConfirmedStopClaimLost) {
t.Fatalf("stale ScrobbleStopConfirmed error = %v, want claim lost", err)
}
if repo.confirmedScrobbles[key] {
t.Fatal("stale provider worker marked the reclaimed stop confirmed")
}
}
func TestServiceConfirmedStopRejectsUnregisteredProvider(t *testing.T) {
repo := newServiceFakeRepo()
repo.scrobbleConnections = []Connection{{
ID: "conn-1",
Provider: "temporarily-unavailable",
UserID: 7,
ProfileID: "profile-1",
ScrobbleEnabled: true,
}}
service := NewService(repo, NewRegistry())
err := service.ScrobbleStopConfirmed(context.Background(), ScrobbleEvent{
PlaybackSessionID: "session-1",
UserID: 7,
ProfileID: "profile-1",
MediaItemID: testMovieMediaID,
})
if err == nil || !strings.Contains(err.Error(), "not registered") {
t.Fatalf("ScrobbleStopConfirmed error = %v, want unregistered provider", err)
}
}
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: testOldAccessToken,
RefreshToken: testOldRefreshToken,
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: testMovieMediaID,
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: testAccessToken,
ScrobbleEnabled: true,
}
repo.scrobbleSessions = []ScrobbleSession{{
PlaybackSessionID: "playback-1",
ConnectionID: "conn-1",
MediaItemID: testMovieMediaID,
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
updates := repo.scrobbleUpdatesSnapshot()
for _, update := range updates {
if update.action == "stop" && update.stopSentAt != nil {
foundClosed = true
}
}
if !foundClosed {
t.Fatalf("scrobble updates = %+v, want successful stop to mark session closed", updates)
}
}
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: testAccessToken,
ScrobbleEnabled: true,
}
repo.scrobbleSessions = []ScrobbleSession{{
PlaybackSessionID: "playback-1",
ConnectionID: "conn-1",
MediaItemID: testMovieMediaID,
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)
}
updates := repo.scrobbleUpdatesSnapshot()
for _, update := range updates {
if update.stopSentAt != nil {
t.Fatalf("stop_sent_at was set despite provider failure: %+v", updates)
}
if update.lastError == "provider offline" {
return
}
}
t.Fatalf("scrobble updates = %+v, want provider error recorded", updates)
}
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: testMovieMediaID,
WatchedAt: at.Add(-time.Duration(500-i) * time.Hour).Format(time.RFC3339),
Completed: true,
}
}
rows[500] = userstore.WatchHistoryEntry{
ProfileID: "profile-1",
MediaItemID: testMovieMediaID,
WatchedAt: at.Add(time.Minute).Format(time.RFC3339),
Completed: true,
}
store := &completedHistoryListerStub{rows: rows}
found, err := hasVisibleCompletedHistoryAtOrAfter(context.Background(), store, "profile-1", testMovieMediaID, 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] != testMovieMediaID {
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)
}
}
// rateLimitedImporterStub rate-limits watched import and records whether the
// progress flow was still attempted afterwards.
type rateLimitedImporterStub struct {
progressCalled *bool
fetchCalls *int
}
func (p rateLimitedImporterStub) Key() string { return "trakt" }
func (p rateLimitedImporterStub) DisplayName() string { return "Trakt" }
func (p rateLimitedImporterStub) Capabilities() Capabilities {
return Capabilities{ImportWatched: true, ImportProgress: true}
}
func (p rateLimitedImporterStub) FetchWatched(context.Context, ServerConfig, Connection) ([]RemoteWatch, error) {
if p.fetchCalls != nil {
*p.fetchCalls++
}
return nil, RateLimitedError{Provider: "trakt", RetryAfter: 30 * time.Minute}
}
func (p rateLimitedImporterStub) FetchProgress(context.Context, ServerConfig, Connection) ([]RemoteProgress, error) {
*p.progressCalled = true
return nil, nil
}
func (p rateLimitedImporterStub) HistorySource() userstore.WatchHistorySource {
return userstore.WatchHistorySourceTrakt
}
func TestServicePluginAPIKeyConnectRejectsEmptyReturnedCredential(t *testing.T) {
repo := newServiceFakeRepo()
registry := NewRegistry()
provider := emptyPluginAPIKeyProvider{}
if err := registry.Register(provider); err != nil {
t.Fatal(err)
}
service := NewService(repo, registry)
if _, err := service.ConnectAPIKey(context.Background(), 7, "profile-1", provider.Key(), "input-secret"); err == nil {
t.Fatal("ConnectAPIKey error = nil")
}
if len(repo.connections) != 0 {
t.Fatalf("connections = %#v", repo.connections)
}
}
func TestServicePluginAPIKeyReconnectClearsConnectionError(t *testing.T) {
repo := newServiceFakeRepo()
client := &fakeWatchSyncPluginClient{exchangeResponse: &pluginv1.WatchSyncCredentialResponse{
Credentials: &pluginv1.WatchSyncCredentials{AccessToken: "new-access", TokenType: testBearerTokenType},
Account: &pluginv1.WatchSyncAccount{ExternalSubject: "account-1", Username: testPluginUsername},
}}
provider := testPluginProvider(t, client)
registry := NewRegistry()
if err := registry.Register(provider); err != nil {
t.Fatal(err)
}
conn := Connection{
ID: "conn-1",
Provider: provider.Key(),
UserID: 7,
ProfileID: "profile-1",
LastError: testReconnectRequired,
}
repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)] = conn
updated, err := NewService(repo, registry).ConnectAPIKey(context.Background(), conn.UserID, conn.ProfileID, provider.Key(), "replacement-key")
if err != nil {
t.Fatal(err)
}
if updated.AccessToken != "new-access" || updated.LastError != "" {
t.Fatalf("connection = %#v", updated)
}
}
func TestServiceSyncConnectionDefersOnRateLimit(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)
}
repo := newServiceFakeRepo()
progressCalled := false
provider := rateLimitedImporterStub{progressCalled: &progressCalled}
reg := NewRegistry()
if err := reg.Register(provider); err != nil {
t.Fatalf("Register: %v", err)
}
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
service := NewService(repo, reg).
WithMatcher(matchedMatcherStub{mediaItemID: testMovieMediaID}).
WithWatchState(noOpWatchState{}).
WithUserStoreProvider(staticStoreProvider{store: userdb.NewSQLiteUserStore(db)})
service.now = func() time.Time { return now }
conn := Connection{
ID: "conn-1",
Provider: "trakt",
UserID: 7,
ProfileID: "profile-1",
AccessToken: testAccessToken,
ImportWatchedEnabled: true,
ImportProgressEnabled: true,
}
repo.connections[connectionKey("trakt", 7, "profile-1")] = conn
err = service.SyncConnection(context.Background(), conn, "scheduled")
if err == nil {
t.Fatal("SyncConnection error = nil, want rate limit failure")
}
if progressCalled {
t.Fatal("progress import ran after the watched import was rate limited")
}
updated := repo.connections[connectionKey("trakt", 7, "profile-1")]
wantUntil := now.Add(30 * time.Minute)
if updated.RateLimitedUntil == nil || !updated.RateLimitedUntil.Equal(wantUntil) {
t.Fatalf("RateLimitedUntil = %v, want %v", updated.RateLimitedUntil, wantUntil)
}
if !strings.Contains(updated.LastError, "rate limit") {
t.Fatalf("LastError = %q, want rate limit message", updated.LastError)
}
// A manual sync during the deferral must be refused with the remaining wait.
_, err = service.RequestManualSync(context.Background(), 7, "profile-1", "trakt")
var cooldown SyncCooldownError
if !errors.As(err, &cooldown) {
t.Fatalf("RequestManualSync error = %v, want SyncCooldownError", err)
}
if cooldown.RetryAfterSeconds != 30*60 {
t.Fatalf("RetryAfterSeconds = %d, want %d", cooldown.RetryAfterSeconds, 30*60)
}
}
func TestServiceLocalWatchEventCommitsPartialResultAndDefersRateLimit(t *testing.T) {
repo := newServiceFakeRepo()
provider := watchedExporterStub{
exportErr: RateLimitedError{Provider: "trakt", RetryAfter: 72 * time.Hour},
exportResult: ExportResult{Sent: []string{"history-1"}},
}
registry := NewRegistry()
if err := registry.Register(provider); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)
service := NewService(repo, registry)
service.now = func() time.Time { return now }
conn := Connection{
ID: "conn-1",
Provider: "trakt",
UserID: 7,
ProfileID: "profile-1",
ProviderAccountID: testProviderAccountID,
ExportWatchedEnabled: true,
}
repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)] = conn
if err := service.processLocalWatchEvent(context.Background(), LocalWatchEvent{
Kind: LocalWatchEventMarkedWatched,
UserID: conn.UserID,
ProfileID: conn.ProfileID,
Plays: []LocalPlay{{
HistoryID: "history-1",
MediaItemID: testMovieMediaID,
ProviderItemKey: testMovieProviderItemKey,
}},
}); err != nil {
t.Fatal(err)
}
if len(repo.historyExports) != 1 || repo.historyExports[0].Status != historyExportStatusSent {
t.Fatalf("history exports = %#v", repo.historyExports)
}
updated := repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)]
wantUntil := now.Add(24 * time.Hour)
if updated.RateLimitedUntil == nil || !updated.RateLimitedUntil.Equal(wantUntil) {
t.Fatalf("RateLimitedUntil = %v, want %v", updated.RateLimitedUntil, wantUntil)
}
}
func TestServicePluginTransportFailureLeavesExportPending(t *testing.T) {
repo := newServiceFakeRepo()
service := NewService(repo, NewRegistry())
conn := Connection{ID: "conn-1"}
err := service.exportLocalPlays(context.Background(), conn, ServerConfig{}, watchedExporterStub{
exportErr: retryableProviderError{message: "watch sync plugin is unavailable"},
}, []LocalPlay{{
HistoryID: "history-1",
MediaItemID: testMovieMediaID,
ProviderItemKey: testMovieProviderItemKey,
}})
if !isRetryableProviderError(err) {
t.Fatalf("error = %#v", err)
}
if len(repo.historyExports) != 1 || repo.historyExports[0].Status != historyExportStatusPending || repo.historyExports[0].AttemptCount != 0 {
t.Fatalf("history exports = %#v", repo.historyExports)
}
}
func TestServicePluginInvalidCredentialLeavesExportPendingAndRecordsConnectionError(t *testing.T) {
repo := newServiceFakeRepo()
client := &fakeWatchSyncPluginClient{applyResponse: &pluginv1.WatchSyncApplyEventsResponse{
Fault: &pluginv1.WatchSyncFault{
Code: pluginv1.WatchSyncFaultCode_WATCH_SYNC_FAULT_CODE_INVALID_CREDENTIAL,
SafeMessage: "credential revoked",
},
}}
provider := testPluginProvider(t, client)
registry := NewRegistry()
if err := registry.Register(provider); err != nil {
t.Fatal(err)
}
service := NewService(repo, registry)
conn := Connection{
ID: "conn-1",
Provider: provider.Key(),
UserID: 7,
ProfileID: "profile-1",
AccessToken: testSecretValue,
ExportWatchedEnabled: true,
}
repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)] = conn
err := service.processLocalWatchEvent(context.Background(), LocalWatchEvent{
Kind: LocalWatchEventMarkedWatched,
UserID: conn.UserID,
ProfileID: conn.ProfileID,
Plays: []LocalPlay{{
HistoryID: "history-1",
MediaItemID: testMovieMediaID,
ProviderItemKey: testMovieProviderItemKey,
Kind: historyimport.KindMovie,
}},
})
if err != nil {
t.Fatal(err)
}
if len(repo.historyExports) != 1 || repo.historyExports[0].Status != historyExportStatusPending || repo.historyExports[0].AttemptCount != 0 {
t.Fatalf("history exports = %#v", repo.historyExports)
}
updated := repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)]
if updated.LastError != "credential revoked" {
t.Fatalf("LastError = %q", updated.LastError)
}
}
func TestServiceExportLocalPlaysReturnsStatusPersistenceFailure(t *testing.T) {
repo := newServiceFakeRepo()
repo.markHistoryStatusErr = errors.New("persist failed")
service := NewService(repo, NewRegistry())
conn := Connection{ID: "conn-1"}
err := service.exportLocalPlays(context.Background(), conn, ServerConfig{}, watchedExporterStub{
exportErr: errors.New("provider offline"),
}, []LocalPlay{{
HistoryID: "history-1",
MediaItemID: testMovieMediaID,
ProviderItemKey: testMovieProviderItemKey,
}})
if err == nil {
t.Fatal("exportLocalPlays error = nil, want combined export and persistence failure")
}
if !strings.Contains(err.Error(), "provider offline") || !strings.Contains(err.Error(), "persist failed") {
t.Fatalf("error = %v", err)
}
if len(repo.historyExports) != 1 || repo.historyExports[0].Status != historyExportStatusPending || repo.historyExports[0].AttemptCount != 0 {
t.Fatalf("history exports = %#v", repo.historyExports)
}
}
func TestServiceFakeRepoMarkHistoryExportSatisfiedByScrobbleSkipsSent(t *testing.T) {
repo := newServiceFakeRepo()
repo.historyExports = []HistoryExport{{
ID: testHistoryExportID,
ConnectionID: "conn-1",
HistoryID: "history-1",
Status: historyExportStatusSent,
}}
if err := repo.MarkHistoryExportSatisfiedByScrobble(context.Background(), "conn-1", "history-1"); err != nil {
t.Fatal(err)
}
if repo.historyExports[0].Status != historyExportStatusSent {
t.Fatalf("history exports = %#v", repo.historyExports)
}
}
func TestServiceFakeRepoPreservesNotFoundHistoryExport(t *testing.T) {
repo := newServiceFakeRepo()
repo.historyExports = []HistoryExport{{
ID: testHistoryExportID,
ConnectionID: "conn-1",
HistoryID: "history-1",
Status: historyExportStatusNotFound,
}}
if err := repo.UpsertHistoryExports(context.Background(), []HistoryExport{{
ConnectionID: "conn-1",
HistoryID: "history-1",
Status: historyExportStatusPending,
}}); err != nil {
t.Fatal(err)
}
if err := repo.MarkHistoryExportStatus(context.Background(), testHistoryExportID, historyExportStatusFailed, "retry"); err != nil {
t.Fatal(err)
}
if err := repo.MarkHistoryExportSatisfiedByScrobble(context.Background(), "conn-1", "history-1"); err != nil {
t.Fatal(err)
}
if repo.historyExports[0].Status != historyExportStatusNotFound || repo.historyExports[0].AttemptCount != 0 {
t.Fatalf("history exports = %#v", repo.historyExports)
}
}
func TestServiceScrobbleRateLimitDefersConnection(t *testing.T) {
repo := newServiceFakeRepo()
now := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)
service := NewService(repo, NewRegistry())
service.now = func() time.Time { return now }
conn := Connection{
ID: "conn-1",
Provider: "trakt",
UserID: 7,
ProfileID: "profile-1",
ProviderAccountID: testProviderAccountID,
}
repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)] = conn
err := service.dispatchScrobble(
context.Background(),
scrobblerStub{stopErr: RateLimitedError{Provider: conn.Provider, RetryAfter: 30 * time.Minute}},
ServerConfig{}, conn,
ScrobbleEvent{PlaybackSessionID: testPlaybackSessionID},
scrobbleActionStop,
nil,
)
if _, ok := AsRateLimited(err); !ok {
t.Fatalf("error = %#v", err)
}
updated := repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)]
wantUntil := now.Add(30 * time.Minute)
if updated.RateLimitedUntil == nil || !updated.RateLimitedUntil.Equal(wantUntil) {
t.Fatalf("RateLimitedUntil = %v, want %v", updated.RateLimitedUntil, wantUntil)
}
}
func TestServiceScrobbleInvalidCredentialRecordsConnectionError(t *testing.T) {
repo := newServiceFakeRepo()
service := NewService(repo, NewRegistry())
conn := Connection{
ID: "conn-1",
Provider: "plugin:15:probe",
UserID: 7,
ProfileID: "profile-1",
}
repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)] = conn
fault := watchSyncProviderFaultError{
code: pluginv1.WatchSyncFaultCode_WATCH_SYNC_FAULT_CODE_INVALID_CREDENTIAL,
message: testReconnectRequired,
}
err := service.dispatchScrobble(
context.Background(), scrobblerStub{stopErr: fault}, ServerConfig{}, conn,
ScrobbleEvent{PlaybackSessionID: testPlaybackSessionID}, scrobbleActionStop, nil,
)
if !isWatchSyncInvalidCredentialError(err) {
t.Fatalf("error = %#v", err)
}
updated := repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)]
if updated.LastError != fault.Error() {
t.Fatalf("LastError = %q, want %q", updated.LastError, fault.Error())
}
}
func TestServiceSyncConnectionLeavesExportsPendingOnRateLimit(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: testMovieMediaID,
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: RateLimitedError{Provider: "trakt", RetryAfter: time.Hour}}
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: testAccessToken,
ExportWatchedEnabled: true,
}
repo.connections[connectionKey("trakt", 7, "profile-1")] = conn
if err := service.SyncConnection(context.Background(), conn, "scheduled"); err == nil {
t.Fatal("SyncConnection error = nil, want rate limit failure")
}
if len(repo.historyExports) != 1 || repo.historyExports[0].Status != historyExportStatusPending {
t.Fatalf("history exports = %+v, want one still-pending export", repo.historyExports)
}
updated := repo.connections[connectionKey("trakt", 7, "profile-1")]
if updated.RateLimitedUntil == nil {
t.Fatal("RateLimitedUntil not set after rate-limited export")
}
}
func TestSyncDueConnectionsSkipsSiblingsOfRateLimitedAccount(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)
}
repo := newServiceFakeRepo()
fetchCalls := 0
progressCalled := false
provider := rateLimitedImporterStub{progressCalled: &progressCalled, fetchCalls: &fetchCalls}
reg := NewRegistry()
if err := reg.Register(provider); err != nil {
t.Fatalf("Register: %v", err)
}
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
service := NewService(repo, reg).
WithMatcher(matchedMatcherStub{mediaItemID: testMovieMediaID}).
WithWatchState(noOpWatchState{}).
WithUserStoreProvider(staticStoreProvider{store: userdb.NewSQLiteUserStore(db)})
service.now = func() time.Time { return now }
// Two household profiles share one MDBList-style API key (same provider
// account); the second must not sync after the first exhausts the quota.
first := Connection{
ID: "conn-1",
Provider: "trakt",
UserID: 7,
ProfileID: "profile-1",
ProviderAccountID: "acct-1",
AccessToken: testAccessToken,
ImportWatchedEnabled: true,
}
second := Connection{
ID: "conn-2",
Provider: "trakt",
UserID: 7,
ProfileID: "profile-2",
ProviderAccountID: "acct-1",
AccessToken: testAccessToken,
ImportWatchedEnabled: true,
}
repo.connections[connectionKey("trakt", 7, "profile-1")] = first
repo.connections[connectionKey("trakt", 7, "profile-2")] = second
repo.dueConnections = []Connection{first, second}
if err := service.SyncDueConnections(context.Background()); err != nil {
t.Fatalf("SyncDueConnections: %v", err)
}
if fetchCalls != 1 {
t.Fatalf("fetch calls = %d, want 1 (sibling connection must be skipped)", fetchCalls)
}
sibling := repo.connections[connectionKey("trakt", 7, "profile-2")]
wantUntil := now.Add(30 * time.Minute)
if sibling.RateLimitedUntil == nil || !sibling.RateLimitedUntil.Equal(wantUntil) {
t.Fatalf("sibling RateLimitedUntil = %v, want %v", sibling.RateLimitedUntil, wantUntil)
}
}
type favoriteBatchProviderStub struct {
batch FavoriteImportBatch
}
func (favoriteBatchProviderStub) Key() string { return "plugin:4:list" }
func (favoriteBatchProviderStub) DisplayName() string { return "List" }
func (favoriteBatchProviderStub) Capabilities() Capabilities {
return Capabilities{ImportFavorites: true}
}
func (p favoriteBatchProviderStub) FetchFavorites(context.Context, ServerConfig, Connection) ([]RemoteFavorite, error) {
return p.batch.Rows, nil
}
func (p favoriteBatchProviderStub) FetchFavoritesBatch(context.Context, ServerConfig, Connection) (FavoriteImportBatch, error) {
return p.batch, nil
}
func TestServiceAppliesIncrementalFavoriteTombstone(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := userdb.InitSchema(db); err != nil {
t.Fatal(err)
}
store := userdb.NewSQLiteUserStore(db)
ctx := context.Background()
if _, err := store.AddFavoriteAt(ctx, "profile-1", testMovieMediaID, time.Now().UTC()); err != nil {
t.Fatal(err)
}
repo := newServiceFakeRepo()
conn := Connection{
ID: "conn-1", Provider: "plugin:4:list", UserID: 7, ProfileID: "profile-1",
ImportFavoritesEnabled: true, SyncFavoriteRemovalsEnabled: true,
}
repo.listItemStates = []ListItemState{{
ConnectionID: conn.ID, ListKind: ListKindFavorites, MediaItemID: testMovieMediaID,
ProviderItemKey: testMovieProviderItemKey, RemotePresent: true, LocalPresent: true,
}}
service := NewService(repo, NewRegistry()).
WithMatcher(matchedMatcherStub{mediaItemID: testMovieMediaID}).
WithUserStoreProvider(staticStoreProvider{store: store})
provider := favoriteBatchProviderStub{batch: FavoriteImportBatch{
Rows: []RemoteFavorite{{ProviderItemKey: testMovieProviderItemKey, Removed: true}},
UpdatedCursors: map[string]string{pluginFavoritesCursorKey: "cursor-2"},
Incremental: true,
}}
result, err := service.importList(ctx, conn, ServerConfig{}, provider, service.favoritesBinding())
if err != nil {
t.Fatal(err)
}
if result.Removed != 1 {
t.Fatalf("result = %#v", result)
}
favorites, err := store.ListFavorites(ctx, conn.ProfileID, 10, 0)
if err != nil {
t.Fatal(err)
}
if len(favorites) != 0 || repo.listItemStates[0].RemotePresent || repo.listItemStates[0].LocalPresent {
t.Fatalf("favorites=%#v state=%#v", favorites, repo.listItemStates[0])
}
updated := repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)]
if updated.SyncCursors[pluginFavoritesCursorKey] != "cursor-2" {
t.Fatalf("connection cursors = %#v", updated.SyncCursors)
}
}
func TestServiceIncrementalFavoriteAbsenceIsNotRemoval(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := userdb.InitSchema(db); err != nil {
t.Fatal(err)
}
store := userdb.NewSQLiteUserStore(db)
ctx := context.Background()
if _, err := store.AddFavoriteAt(ctx, "profile-1", testMovieMediaID, time.Now().UTC()); err != nil {
t.Fatal(err)
}
repo := newServiceFakeRepo()
conn := Connection{
ID: "conn-1", Provider: "plugin:4:list", UserID: 7, ProfileID: "profile-1",
ImportFavoritesEnabled: true, SyncFavoriteRemovalsEnabled: true,
}
repo.listItemStates = []ListItemState{{
ConnectionID: conn.ID, ListKind: ListKindFavorites, MediaItemID: testMovieMediaID,
ProviderItemKey: testMovieProviderItemKey, RemotePresent: true, LocalPresent: true,
}}
service := NewService(repo, NewRegistry()).
WithMatcher(matchedMatcherStub{mediaItemID: testMovieMediaID}).
WithUserStoreProvider(staticStoreProvider{store: store})
provider := favoriteBatchProviderStub{batch: FavoriteImportBatch{Incremental: true}}
result, err := service.importList(ctx, conn, ServerConfig{}, provider, service.favoritesBinding())
if err != nil {
t.Fatal(err)
}
favorites, err := store.ListFavorites(ctx, conn.ProfileID, 10, 0)
if err != nil {
t.Fatal(err)
}
if result.Removed != 0 || len(favorites) != 1 || !repo.listItemStates[0].RemotePresent || !repo.listItemStates[0].LocalPresent {
t.Fatalf("result=%#v favorites=%#v state=%#v", result, favorites, repo.listItemStates[0])
}
}