Parse current MDBList watched payloads and expand aggregate show and season markers to local episode leaves without duplicating overlapping items.
3611 lines
114 KiB
Go
3611 lines
114 KiB
Go
package watchsync
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"reflect"
|
|
"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 watchedLeafMatcherStub struct {
|
|
matches []historyimport.Match
|
|
}
|
|
|
|
func (m watchedLeafMatcherStub) Match(context.Context, historyimport.Record) (*historyimport.Match, string, error) {
|
|
return nil, "unexpected single-item match", nil
|
|
}
|
|
|
|
func (m watchedLeafMatcherStub) MatchLeaves(context.Context, historyimport.Record) ([]historyimport.Match, string, error) {
|
|
return m.matches, "", 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 TestServiceImportWatchedExpandsAggregateMarkerToEpisodeLeaves(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
watchedAt := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
|
provider := watchedImporterStub{
|
|
key: "mdblist",
|
|
source: userstore.WatchHistorySourceMDBList,
|
|
rows: []RemoteWatch{{
|
|
Provider: "mdblist", Kind: historyimport.KindSeries, Title: "Breaking Bad", LastWatchedAt: &watchedAt,
|
|
}},
|
|
}
|
|
watchState := &recordingWatchState{}
|
|
service := NewService(repo, NewRegistry()).
|
|
WithMatcher(watchedLeafMatcherStub{matches: []historyimport.Match{
|
|
{MediaItemID: "episode-1", Kind: historyimport.KindEpisode},
|
|
{MediaItemID: "episode-2", Kind: historyimport.KindEpisode},
|
|
}}).
|
|
WithWatchState(watchState)
|
|
|
|
result, err := service.ImportWatched(context.Background(), Connection{
|
|
ID: "conn-1", Provider: "mdblist", UserID: 7, ProfileID: "profile-1",
|
|
}, ServerConfig{}, provider)
|
|
if err != nil {
|
|
t.Fatalf("ImportWatched: %v", err)
|
|
}
|
|
if result.Found != 1 || result.Imported != 2 || result.Unmatched != 0 {
|
|
t.Fatalf("result = %+v, want one aggregate found and two leaves imported", result)
|
|
}
|
|
if !reflect.DeepEqual(watchState.targetIDs, []string{"episode-1", "episode-2"}) {
|
|
t.Fatalf("recorded target ids = %+v", watchState.targetIDs)
|
|
}
|
|
if !reflect.DeepEqual(watchState.sources, []userstore.WatchHistorySource{
|
|
userstore.WatchHistorySourceMDBList,
|
|
userstore.WatchHistorySourceMDBList,
|
|
}) {
|
|
t.Fatalf("recorded sources = %+v", watchState.sources)
|
|
}
|
|
}
|
|
|
|
func TestServiceImportWatchedDeduplicatesResolvedLeavesAtNewestTimestamp(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
olderWatchedAt := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC)
|
|
newerWatchedAt := olderWatchedAt.Add(24 * time.Hour)
|
|
provider := watchedImporterStub{
|
|
key: "mdblist",
|
|
source: userstore.WatchHistorySourceMDBList,
|
|
rows: []RemoteWatch{
|
|
{Provider: "mdblist", Kind: historyimport.KindMovie, Title: "Inception", LastWatchedAt: &olderWatchedAt},
|
|
{Provider: "mdblist", Kind: historyimport.KindMovie, Title: "Inception", LastWatchedAt: &newerWatchedAt},
|
|
},
|
|
}
|
|
watchState := &recordingWatchState{}
|
|
service := NewService(repo, NewRegistry()).
|
|
WithMatcher(matchedMatcherStub{mediaItemID: testMovieMediaID}).
|
|
WithWatchState(watchState)
|
|
|
|
result, err := service.ImportWatched(context.Background(), Connection{
|
|
ID: "conn-1", Provider: "mdblist", UserID: 7, ProfileID: "profile-1",
|
|
}, ServerConfig{}, provider)
|
|
if err != nil {
|
|
t.Fatalf("ImportWatched: %v", err)
|
|
}
|
|
if result.Found != 2 || result.Imported != 1 || result.Unmatched != 0 {
|
|
t.Fatalf("result = %+v, want two provider markers and one imported leaf", result)
|
|
}
|
|
if !reflect.DeepEqual(watchState.targetIDs, []string{testMovieMediaID}) {
|
|
t.Fatalf("recorded target ids = %+v", watchState.targetIDs)
|
|
}
|
|
if len(watchState.watchedAt) != 1 || watchState.watchedAt[0] == nil || !watchState.watchedAt[0].Equal(newerWatchedAt) {
|
|
t.Fatalf("recorded watched_at = %+v, want %v", watchState.watchedAt, newerWatchedAt)
|
|
}
|
|
}
|
|
|
|
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])
|
|
}
|
|
}
|