* feat(watchsync): add plugin-backed providers * fix(watchsync): address plugin review findings * fix(watchsync): harden plugin provider failures * feat(watchsync): complete plugin provider contract * fix(watchsync): address provider review feedback * fix(watchsync): keep device state host-private * fix(watchsync): build reconciliation index concurrently * fix(watchsync): preserve empty device state updates * chore(deps): use released watch-sync SDK --------- Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
3526 lines
111 KiB
Go
3526 lines
111 KiB
Go
package watchsync
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
|
"github.com/Silo-Server/silo-server/internal/historyimport"
|
|
"github.com/Silo-Server/silo-server/internal/userdb"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
const (
|
|
testProviderAccountID = "account-1"
|
|
testAccessToken = "access"
|
|
testBearerTokenType = "Bearer"
|
|
testCursorOne = "cursor-1"
|
|
testDPoPTokenType = "DPoP"
|
|
testHistoryExportID = "export-1"
|
|
testHistoryScope = "history"
|
|
testMovieMediaID = "movie-1"
|
|
testMovieProviderItemKey = "movie:tmdb:603"
|
|
testOldAccessToken = "old-access"
|
|
testOldRefreshToken = "old-refresh"
|
|
testOneValue = "one"
|
|
testPluginUsername = "alice"
|
|
testRefreshToken = "refresh"
|
|
testReconnectRequired = "reconnect required"
|
|
testRotatedAccessToken = "rotated-access"
|
|
testSecretValue = "secret"
|
|
testValidatedToken = "validated-token"
|
|
)
|
|
|
|
type serviceFakeRepo struct {
|
|
connections map[string]Connection
|
|
dueConnections []Connection
|
|
sessions map[string]DeviceAuthSession
|
|
settings map[string]string
|
|
syncRuns []SyncRun
|
|
historyExports []HistoryExport
|
|
listItemStates []ListItemState
|
|
scrobbleConnections []Connection
|
|
scrobbleSessions []ScrobbleSession
|
|
pendingReconciliations []ScrobbleSession
|
|
reconciledScrobbles map[string]time.Time
|
|
scrobbleUpdates []scrobbleUpdate
|
|
reopenedScrobbles []scrobbleUpdate
|
|
confirmedScrobbles map[string]bool
|
|
confirmingScrobbles map[string]time.Time
|
|
markSatisfiedErr error
|
|
markHistoryStatusErr error
|
|
syncRunMu sync.Mutex
|
|
scrobbleMu sync.Mutex
|
|
}
|
|
|
|
type scrobbleUpdate struct {
|
|
playbackSessionID string
|
|
connectionID string
|
|
action string
|
|
positionSeconds float64
|
|
historyID string
|
|
lastError string
|
|
stopSentAt *time.Time
|
|
}
|
|
|
|
func newServiceFakeRepo() *serviceFakeRepo {
|
|
return &serviceFakeRepo{
|
|
connections: make(map[string]Connection),
|
|
sessions: make(map[string]DeviceAuthSession),
|
|
confirmedScrobbles: make(map[string]bool),
|
|
confirmingScrobbles: make(map[string]time.Time),
|
|
reconciledScrobbles: make(map[string]time.Time),
|
|
settings: map[string]string{
|
|
"watchsync.trakt.client_id": "client-id",
|
|
"watchsync.trakt.client_secret": "client-secret",
|
|
"watchsync.simkl.client_id": "client-id",
|
|
"watchsync.simkl.client_secret": "client-secret",
|
|
},
|
|
}
|
|
}
|
|
|
|
func (r *serviceFakeRepo) GetServerSetting(_ context.Context, key string) (string, error) {
|
|
return r.settings[key], nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) UpsertAuthSession(
|
|
_ context.Context,
|
|
session DeviceAuthSession,
|
|
) (DeviceAuthSession, error) {
|
|
if session.ID == "" {
|
|
session.ID = "auth-1"
|
|
}
|
|
r.sessions[session.ID] = session
|
|
return session, nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) GetAuthSession(_ context.Context, id string) (DeviceAuthSession, error) {
|
|
session, ok := r.sessions[id]
|
|
if !ok {
|
|
return DeviceAuthSession{}, errors.New("missing auth session")
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) UpsertConnection(
|
|
_ context.Context,
|
|
conn Connection,
|
|
) (Connection, error) {
|
|
if conn.ID == "" {
|
|
conn.ID = "conn-1"
|
|
}
|
|
conn = cloneConnectionForTest(conn)
|
|
r.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)] = conn
|
|
return cloneConnectionForTest(conn), nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) GetConnection(
|
|
_ context.Context,
|
|
provider string,
|
|
userID int,
|
|
profileID string,
|
|
) (Connection, bool, error) {
|
|
conn, ok := r.connections[connectionKey(provider, userID, profileID)]
|
|
return cloneConnectionForTest(conn), ok, nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) DeferConnectionsForAccount(
|
|
_ context.Context,
|
|
provider string,
|
|
providerAccountID string,
|
|
until time.Time,
|
|
lastError string,
|
|
) (int, error) {
|
|
if providerAccountID == "" {
|
|
return 0, nil
|
|
}
|
|
deferred := 0
|
|
for key, conn := range r.connections {
|
|
if conn.Provider != provider || conn.ProviderAccountID != providerAccountID {
|
|
continue
|
|
}
|
|
untilCopy := until
|
|
conn.RateLimitedUntil = &untilCopy
|
|
conn.LastError = lastError
|
|
r.connections[key] = conn
|
|
deferred++
|
|
}
|
|
return deferred, nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) GetConnectionByID(_ context.Context, id string) (Connection, bool, error) {
|
|
for _, conn := range r.connections {
|
|
if conn.ID == id {
|
|
return cloneConnectionForTest(conn), true, nil
|
|
}
|
|
}
|
|
for _, conn := range r.scrobbleConnections {
|
|
if conn.ID == id {
|
|
return cloneConnectionForTest(conn), true, nil
|
|
}
|
|
}
|
|
return Connection{}, false, nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) DeleteConnection(
|
|
_ context.Context,
|
|
provider string,
|
|
userID int,
|
|
profileID string,
|
|
) error {
|
|
delete(r.connections, connectionKey(provider, userID, profileID))
|
|
return nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) ListConnectionsDueForSync(
|
|
_ context.Context,
|
|
_ time.Time,
|
|
) ([]Connection, error) {
|
|
out := make([]Connection, 0, len(r.dueConnections))
|
|
for _, conn := range r.dueConnections {
|
|
out = append(out, cloneConnectionForTest(conn))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) CreateSyncRun(_ context.Context, run SyncRun) (SyncRun, error) {
|
|
r.syncRunMu.Lock()
|
|
defer r.syncRunMu.Unlock()
|
|
if run.ID == "" {
|
|
run.ID = "run-" + strconv.Itoa(len(r.syncRuns)+1)
|
|
}
|
|
if run.Status == "" {
|
|
run.Status = string(SyncRunStatusRunning)
|
|
}
|
|
if run.StartedAt.IsZero() {
|
|
run.StartedAt = time.Now()
|
|
}
|
|
if run.CreatedAt.IsZero() {
|
|
run.CreatedAt = run.StartedAt
|
|
}
|
|
r.syncRuns = append(r.syncRuns, run)
|
|
return run, nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) CompleteSyncRun(_ context.Context, run SyncRun) (SyncRun, error) {
|
|
r.syncRunMu.Lock()
|
|
defer r.syncRunMu.Unlock()
|
|
for i := range r.syncRuns {
|
|
if r.syncRuns[i].ID == run.ID {
|
|
if run.CreatedAt.IsZero() {
|
|
run.CreatedAt = r.syncRuns[i].CreatedAt
|
|
}
|
|
if run.StartedAt.IsZero() {
|
|
run.StartedAt = r.syncRuns[i].StartedAt
|
|
}
|
|
r.syncRuns[i] = run
|
|
return run, nil
|
|
}
|
|
}
|
|
return SyncRun{}, errors.New("missing sync run")
|
|
}
|
|
|
|
func (r *serviceFakeRepo) GetLatestSyncRun(_ context.Context, connectionID string) (SyncRun, bool, error) {
|
|
r.syncRunMu.Lock()
|
|
defer r.syncRunMu.Unlock()
|
|
for i := len(r.syncRuns) - 1; i >= 0; i-- {
|
|
if r.syncRuns[i].ConnectionID == connectionID {
|
|
return r.syncRuns[i], true, nil
|
|
}
|
|
}
|
|
return SyncRun{}, false, nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) GetActiveSyncRun(_ context.Context, connectionID string) (SyncRun, bool, error) {
|
|
r.syncRunMu.Lock()
|
|
defer r.syncRunMu.Unlock()
|
|
for i := len(r.syncRuns) - 1; i >= 0; i-- {
|
|
run := r.syncRuns[i]
|
|
if run.ConnectionID == connectionID &&
|
|
(run.Status == string(SyncRunStatusQueued) || run.Status == string(SyncRunStatusRunning)) {
|
|
return run, true, nil
|
|
}
|
|
}
|
|
return SyncRun{}, false, nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) ListSyncRuns(_ context.Context, connectionID string, limit int) ([]SyncRun, error) {
|
|
r.syncRunMu.Lock()
|
|
defer r.syncRunMu.Unlock()
|
|
if limit <= 0 || limit > 50 {
|
|
limit = 10
|
|
}
|
|
var runs []SyncRun
|
|
for i := len(r.syncRuns) - 1; i >= 0 && len(runs) < limit; i-- {
|
|
if r.syncRuns[i].ConnectionID == connectionID {
|
|
runs = append(runs, r.syncRuns[i])
|
|
}
|
|
}
|
|
return runs, nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) ListLocalWatchEventConnections(_ context.Context, userID int, profileID string, kind LocalWatchEventKind) ([]Connection, error) {
|
|
var conns []Connection
|
|
for _, conn := range r.connections {
|
|
if conn.UserID != userID || conn.ProfileID != profileID {
|
|
continue
|
|
}
|
|
if conn.RateLimitedUntil != nil && conn.RateLimitedUntil.After(time.Now()) {
|
|
continue
|
|
}
|
|
switch kind {
|
|
case LocalWatchEventMarkedWatched:
|
|
if conn.ExportWatchedEnabled {
|
|
conns = append(conns, cloneConnectionForTest(conn))
|
|
}
|
|
case LocalWatchEventMarkedUnwatched:
|
|
if conn.ExportUnwatchedEnabled {
|
|
conns = append(conns, cloneConnectionForTest(conn))
|
|
}
|
|
}
|
|
}
|
|
return conns, nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) ListListEventConnections(_ context.Context, userID int, profileID string, list ListKind) ([]Connection, error) {
|
|
var conns []Connection
|
|
for _, conn := range r.connections {
|
|
if conn.UserID != userID || conn.ProfileID != profileID {
|
|
continue
|
|
}
|
|
enabled := conn.ExportFavoritesEnabled
|
|
if list == ListKindWatchlist {
|
|
enabled = conn.ExportWatchlistEnabled
|
|
}
|
|
if enabled {
|
|
conns = append(conns, cloneConnectionForTest(conn))
|
|
}
|
|
}
|
|
return conns, nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) UpsertHistoryExports(_ context.Context, exports []HistoryExport) error {
|
|
for _, export := range exports {
|
|
if export.ID == "" {
|
|
export.ID = "export-" + strconv.Itoa(len(r.historyExports)+1)
|
|
}
|
|
replaced := false
|
|
for i := range r.historyExports {
|
|
if r.historyExports[i].HistoryID == export.HistoryID && r.historyExports[i].ConnectionID == export.ConnectionID {
|
|
existing := r.historyExports[i]
|
|
export.ID = existing.ID
|
|
export.AttemptCount = existing.AttemptCount
|
|
if existing.Status == historyExportStatusSent || existing.Status == historyExportStatusSatisfiedByScrobble || existing.Status == historyExportStatusNotFound || existing.AttemptCount >= 5 {
|
|
export.Status = existing.Status
|
|
}
|
|
r.historyExports[i] = export
|
|
replaced = true
|
|
break
|
|
}
|
|
}
|
|
if !replaced {
|
|
r.historyExports = append(r.historyExports, export)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) ListPendingHistoryExports(_ context.Context, connectionID string, limit int) ([]HistoryExport, error) {
|
|
var exports []HistoryExport
|
|
for _, export := range r.historyExports {
|
|
if export.ConnectionID == connectionID &&
|
|
(export.Status == historyExportStatusPending || export.Status == historyExportStatusFailed) && export.AttemptCount < 5 {
|
|
exports = append(exports, export)
|
|
if limit > 0 && len(exports) >= limit {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return exports, nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) MarkHistoryExportStatus(_ context.Context, id string, status string, lastError string) error {
|
|
if r.markHistoryStatusErr != nil {
|
|
return r.markHistoryStatusErr
|
|
}
|
|
for i := range r.historyExports {
|
|
if r.historyExports[i].ID == id {
|
|
if r.historyExports[i].Status == historyExportStatusSent || r.historyExports[i].Status == historyExportStatusSatisfiedByScrobble || r.historyExports[i].Status == historyExportStatusNotFound {
|
|
return nil
|
|
}
|
|
r.historyExports[i].Status = status
|
|
r.historyExports[i].AttemptCount++
|
|
r.historyExports[i].LastError = lastError
|
|
return nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) MarkHistoryExportSatisfiedByScrobble(_ context.Context, connectionID string, historyID string) error {
|
|
if r.markSatisfiedErr != nil {
|
|
return r.markSatisfiedErr
|
|
}
|
|
for i := range r.historyExports {
|
|
if r.historyExports[i].ConnectionID == connectionID && r.historyExports[i].HistoryID == historyID {
|
|
if r.historyExports[i].Status == historyExportStatusSent || r.historyExports[i].Status == historyExportStatusNotFound {
|
|
return nil
|
|
}
|
|
r.historyExports[i].Status = historyExportStatusSatisfiedByScrobble
|
|
return nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) UpsertListItemStates(_ context.Context, states []ListItemState) error {
|
|
for _, state := range states {
|
|
if state.ListKind == "" {
|
|
state.ListKind = ListKindFavorites
|
|
}
|
|
replaced := false
|
|
for i := range r.listItemStates {
|
|
if r.listItemStates[i].ConnectionID == state.ConnectionID &&
|
|
r.listItemStates[i].ListKind == state.ListKind &&
|
|
r.listItemStates[i].MediaItemID == state.MediaItemID {
|
|
if state.ID == "" {
|
|
state.ID = r.listItemStates[i].ID
|
|
}
|
|
r.listItemStates[i] = state
|
|
replaced = true
|
|
break
|
|
}
|
|
}
|
|
if !replaced {
|
|
if state.ID == "" {
|
|
state.ID = "list-item-" + strconv.Itoa(len(r.listItemStates)+1)
|
|
}
|
|
r.listItemStates = append(r.listItemStates, state)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) ListListItemStates(_ context.Context, connectionID string, kind ListKind) ([]ListItemState, error) {
|
|
var states []ListItemState
|
|
for _, state := range r.listItemStates {
|
|
if state.ConnectionID == connectionID && state.ListKind == kind {
|
|
states = append(states, state)
|
|
}
|
|
}
|
|
return states, nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) ListPendingListItemExports(_ context.Context, connectionID string, kind ListKind, limit int) ([]ListItemState, error) {
|
|
var states []ListItemState
|
|
for _, state := range r.listItemStates {
|
|
if state.ConnectionID == connectionID && state.ListKind == kind && state.LocalPresent && !state.RemotePresent && state.LastError == "" {
|
|
states = append(states, state)
|
|
if limit > 0 && len(states) >= limit {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return states, nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) ListPendingListItemRemovals(_ context.Context, connectionID string, kind ListKind, limit int) ([]ListItemState, error) {
|
|
var states []ListItemState
|
|
for _, state := range r.listItemStates {
|
|
if state.ConnectionID == connectionID && state.ListKind == kind && !state.LocalPresent && state.RemotePresent && state.LastError == "" {
|
|
states = append(states, state)
|
|
if limit > 0 && len(states) >= limit {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return states, nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) markListItem(connectionID string, kind ListKind, mediaItemID string, apply func(*ListItemState)) {
|
|
for i := range r.listItemStates {
|
|
if r.listItemStates[i].ConnectionID == connectionID && r.listItemStates[i].ListKind == kind && r.listItemStates[i].MediaItemID == mediaItemID {
|
|
apply(&r.listItemStates[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *serviceFakeRepo) MarkListItemExported(_ context.Context, connectionID string, kind ListKind, mediaItemID string, exportedAt time.Time) error {
|
|
// Mirror Postgres: successful transitions clear last_error.
|
|
r.markListItem(connectionID, kind, mediaItemID, func(s *ListItemState) {
|
|
s.RemotePresent = true
|
|
s.LocalPresent = true
|
|
s.LastExportedAt = &exportedAt
|
|
s.LastError = ""
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) MarkListItemRemoteRemoved(_ context.Context, connectionID string, kind ListKind, mediaItemID string, removedAt time.Time) error {
|
|
r.markListItem(connectionID, kind, mediaItemID, func(s *ListItemState) {
|
|
s.RemotePresent = false
|
|
s.LastRemovedRemoteAt = &removedAt
|
|
s.LastError = ""
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) MarkListItemLocalRemoved(_ context.Context, connectionID string, kind ListKind, mediaItemID string, removedAt time.Time) error {
|
|
r.markListItem(connectionID, kind, mediaItemID, func(s *ListItemState) {
|
|
s.LocalPresent = false
|
|
s.LastRemovedLocalAt = &removedAt
|
|
s.LastError = ""
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) MarkListItemError(_ context.Context, connectionID string, kind ListKind, mediaItemID, lastError string) error {
|
|
r.markListItem(connectionID, kind, mediaItemID, func(s *ListItemState) {
|
|
s.LastError = lastError
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) ListScrobbleConnections(_ context.Context, _ int, _ string) ([]Connection, error) {
|
|
conns := make([]Connection, 0, len(r.scrobbleConnections))
|
|
for _, conn := range r.scrobbleConnections {
|
|
if conn.RateLimitedUntil != nil && conn.RateLimitedUntil.After(time.Now()) {
|
|
continue
|
|
}
|
|
conns = append(conns, cloneConnectionForTest(conn))
|
|
}
|
|
return conns, nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) UpsertScrobbleSession(_ context.Context, _ ScrobbleEvent, _ string, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) PrepareConfirmedScrobbleStop(_ context.Context, event ScrobbleEvent, connectionID string, _ time.Time) (confirmedStopPreparation, time.Time, error) {
|
|
r.scrobbleMu.Lock()
|
|
defer r.scrobbleMu.Unlock()
|
|
key := event.PlaybackSessionID + "|" + connectionID
|
|
if r.confirmedScrobbles[key] {
|
|
return confirmedStopAlreadySent, time.Time{}, nil
|
|
}
|
|
if !r.confirmingScrobbles[key].IsZero() {
|
|
return confirmedStopInProgress, time.Time{}, nil
|
|
}
|
|
claimVersion := time.Now()
|
|
r.confirmingScrobbles[key] = claimVersion
|
|
r.reopenedScrobbles = append(r.reopenedScrobbles, scrobbleUpdate{
|
|
playbackSessionID: event.PlaybackSessionID,
|
|
connectionID: connectionID,
|
|
action: "stop",
|
|
positionSeconds: event.PositionSeconds,
|
|
historyID: event.HistoryID,
|
|
})
|
|
return confirmedStopPrepared, claimVersion, nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) CompleteConfirmedScrobbleStop(_ context.Context, playbackSessionID string, connectionID string, positionSeconds float64, historyID string, claimVersion time.Time, stopSentAt time.Time) error {
|
|
r.scrobbleMu.Lock()
|
|
defer r.scrobbleMu.Unlock()
|
|
key := playbackSessionID + "|" + connectionID
|
|
if r.confirmingScrobbles[key] != claimVersion {
|
|
return errConfirmedStopClaimLost
|
|
}
|
|
delete(r.confirmingScrobbles, key)
|
|
r.confirmedScrobbles[key] = true
|
|
r.scrobbleUpdates = append(r.scrobbleUpdates, scrobbleUpdate{
|
|
playbackSessionID: playbackSessionID,
|
|
connectionID: connectionID,
|
|
action: "stop_confirmed",
|
|
positionSeconds: positionSeconds,
|
|
historyID: historyID,
|
|
stopSentAt: &stopSentAt,
|
|
})
|
|
if historyID != "" {
|
|
r.pendingReconciliations = append(r.pendingReconciliations, ScrobbleSession{
|
|
PlaybackSessionID: playbackSessionID, ConnectionID: connectionID, HistoryID: historyID,
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) FailConfirmedScrobbleStop(_ context.Context, playbackSessionID string, connectionID string, positionSeconds float64, historyID string, claimVersion time.Time, lastError string) error {
|
|
r.scrobbleMu.Lock()
|
|
defer r.scrobbleMu.Unlock()
|
|
key := playbackSessionID + "|" + connectionID
|
|
if r.confirmingScrobbles[key] != claimVersion {
|
|
return errConfirmedStopClaimLost
|
|
}
|
|
delete(r.confirmingScrobbles, key)
|
|
r.scrobbleUpdates = append(r.scrobbleUpdates, scrobbleUpdate{
|
|
playbackSessionID: playbackSessionID,
|
|
connectionID: connectionID,
|
|
action: "stop_confirming",
|
|
positionSeconds: positionSeconds,
|
|
historyID: historyID,
|
|
lastError: lastError,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) UpdateScrobbleSession(_ context.Context, playbackSessionID string, connectionID string, action string, positionSeconds float64, historyID string, lastError string, stopSentAt *time.Time) error {
|
|
r.scrobbleMu.Lock()
|
|
defer r.scrobbleMu.Unlock()
|
|
r.scrobbleUpdates = append(r.scrobbleUpdates, scrobbleUpdate{
|
|
playbackSessionID: playbackSessionID,
|
|
connectionID: connectionID,
|
|
action: action,
|
|
positionSeconds: positionSeconds,
|
|
historyID: historyID,
|
|
lastError: lastError,
|
|
stopSentAt: stopSentAt,
|
|
})
|
|
if stopSentAt != nil && historyID != "" {
|
|
r.pendingReconciliations = append(r.pendingReconciliations, ScrobbleSession{
|
|
PlaybackSessionID: playbackSessionID, ConnectionID: connectionID, HistoryID: historyID,
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) ListOpenScrobbleSessions(_ context.Context) ([]ScrobbleSession, error) {
|
|
r.scrobbleMu.Lock()
|
|
defer r.scrobbleMu.Unlock()
|
|
return append([]ScrobbleSession(nil), r.scrobbleSessions...), nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) ListPendingScrobbleReconciliations(_ context.Context) ([]ScrobbleSession, error) {
|
|
r.scrobbleMu.Lock()
|
|
defer r.scrobbleMu.Unlock()
|
|
return append([]ScrobbleSession(nil), r.pendingReconciliations...), nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) MarkScrobbleHistoryReconciled(_ context.Context, playbackSessionID, connectionID string, reconciledAt time.Time) error {
|
|
r.scrobbleMu.Lock()
|
|
defer r.scrobbleMu.Unlock()
|
|
key := playbackSessionID + "|" + connectionID
|
|
r.reconciledScrobbles[key] = reconciledAt
|
|
remaining := r.pendingReconciliations[:0]
|
|
for _, session := range r.pendingReconciliations {
|
|
if session.PlaybackSessionID == playbackSessionID && session.ConnectionID == connectionID {
|
|
continue
|
|
}
|
|
remaining = append(remaining, session)
|
|
}
|
|
r.pendingReconciliations = remaining
|
|
return nil
|
|
}
|
|
|
|
func (r *serviceFakeRepo) syncRunsSnapshot() []SyncRun {
|
|
r.syncRunMu.Lock()
|
|
defer r.syncRunMu.Unlock()
|
|
return append([]SyncRun(nil), r.syncRuns...)
|
|
}
|
|
|
|
func (r *serviceFakeRepo) scrobbleUpdatesSnapshot() []scrobbleUpdate {
|
|
r.scrobbleMu.Lock()
|
|
defer r.scrobbleMu.Unlock()
|
|
return append([]scrobbleUpdate(nil), r.scrobbleUpdates...)
|
|
}
|
|
|
|
func connectionKey(provider string, userID int, profileID string) string {
|
|
return provider + "|" + strconv.Itoa(userID) + "|" + profileID
|
|
}
|
|
|
|
func cloneConnectionForTest(conn Connection) Connection {
|
|
conn.SyncCursors = cloneStringMapForTest(conn.SyncCursors)
|
|
return conn
|
|
}
|
|
|
|
func cloneStringMapForTest(values map[string]string) map[string]string {
|
|
if len(values) == 0 {
|
|
return map[string]string{}
|
|
}
|
|
clone := make(map[string]string, len(values))
|
|
for key, value := range values {
|
|
clone[key] = value
|
|
}
|
|
return clone
|
|
}
|
|
|
|
type authProviderStub struct {
|
|
started bool
|
|
polled bool
|
|
pollErr error
|
|
refreshed bool
|
|
refreshTokens TokenSet
|
|
refreshErr error
|
|
}
|
|
|
|
func (p *authProviderStub) Key() string {
|
|
return "trakt"
|
|
}
|
|
|
|
func (p *authProviderStub) DisplayName() string {
|
|
return "Trakt"
|
|
}
|
|
|
|
func (p *authProviderStub) Capabilities() Capabilities {
|
|
return Capabilities{}
|
|
}
|
|
|
|
func (p *authProviderStub) StartDeviceAuth(
|
|
context.Context,
|
|
ServerConfig,
|
|
) (DeviceAuthSession, error) {
|
|
p.started = true
|
|
return DeviceAuthSession{
|
|
Provider: "trakt",
|
|
DeviceCode: "device",
|
|
UserCode: "CODE",
|
|
VerificationURL: "https://trakt.tv/activate",
|
|
IntervalSeconds: 5,
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
}, nil
|
|
}
|
|
|
|
func (p *authProviderStub) PollDeviceAuth(
|
|
context.Context,
|
|
ServerConfig,
|
|
DeviceAuthSession,
|
|
) (TokenSet, error) {
|
|
p.polled = true
|
|
if p.pollErr != nil {
|
|
return TokenSet{}, p.pollErr
|
|
}
|
|
expires := time.Now().Add(time.Hour)
|
|
return TokenSet{AccessToken: testAccessToken, RefreshToken: testRefreshToken, TokenExpiresAt: &expires}, nil
|
|
}
|
|
|
|
func (p *authProviderStub) RefreshToken(context.Context, ServerConfig, Connection) (TokenSet, error) {
|
|
p.refreshed = true
|
|
if p.refreshErr != nil {
|
|
return TokenSet{}, p.refreshErr
|
|
}
|
|
return p.refreshTokens, nil
|
|
}
|
|
|
|
func (p *authProviderStub) LookupAccount(
|
|
context.Context,
|
|
ServerConfig,
|
|
Connection,
|
|
) (ProviderAccount, error) {
|
|
return ProviderAccount{ID: "trakt-user-1", Username: "alex"}, nil
|
|
}
|
|
|
|
type emptyPluginAPIKeyProvider struct{}
|
|
|
|
func (emptyPluginAPIKeyProvider) Key() string { return "plugin:1:tracker" }
|
|
|
|
func (emptyPluginAPIKeyProvider) DisplayName() string { return "Tracker" }
|
|
|
|
func (emptyPluginAPIKeyProvider) Capabilities() Capabilities { return Capabilities{} }
|
|
|
|
func (emptyPluginAPIKeyProvider) ProviderSource() string { return providerSourcePlugin }
|
|
|
|
func (emptyPluginAPIKeyProvider) ConnectWithAPIKey(context.Context, string) (TokenSet, ProviderAccount, error) {
|
|
return TokenSet{}, ProviderAccount{ID: testProviderAccountID}, nil
|
|
}
|
|
|
|
type watchedImporterStub struct {
|
|
key string
|
|
source userstore.WatchHistorySource
|
|
rows []RemoteWatch
|
|
}
|
|
|
|
func (p watchedImporterStub) Key() string {
|
|
if p.key != "" {
|
|
return p.key
|
|
}
|
|
return "trakt"
|
|
}
|
|
|
|
func (p watchedImporterStub) DisplayName() string {
|
|
return "Trakt"
|
|
}
|
|
|
|
func (p watchedImporterStub) Capabilities() Capabilities {
|
|
return Capabilities{ImportWatched: true}
|
|
}
|
|
|
|
func (p watchedImporterStub) FetchWatched(context.Context, ServerConfig, Connection) ([]RemoteWatch, error) {
|
|
return p.rows, nil
|
|
}
|
|
|
|
func (p watchedImporterStub) HistorySource() userstore.WatchHistorySource {
|
|
if p.source == "" {
|
|
return userstore.WatchHistorySourceTrakt
|
|
}
|
|
return p.source
|
|
}
|
|
|
|
type watchedBatchImporterStub struct {
|
|
watchedImporterStub
|
|
batch WatchedImportBatch
|
|
}
|
|
|
|
func (p watchedBatchImporterStub) FetchWatchedBatch(context.Context, ServerConfig, Connection) (WatchedImportBatch, error) {
|
|
return p.batch, nil
|
|
}
|
|
|
|
type progressImporterStub struct {
|
|
rows []RemoteProgress
|
|
}
|
|
|
|
func (p progressImporterStub) FetchProgress(context.Context, ServerConfig, Connection) ([]RemoteProgress, error) {
|
|
return p.rows, nil
|
|
}
|
|
|
|
type progressBatchImporterStub struct {
|
|
progressImporterStub
|
|
batch ProgressImportBatch
|
|
}
|
|
|
|
func (p progressBatchImporterStub) FetchProgressBatch(context.Context, ServerConfig, Connection) (ProgressImportBatch, error) {
|
|
return p.batch, nil
|
|
}
|
|
|
|
type watchedExporterStub struct {
|
|
exportErr error
|
|
exportResult ExportResult
|
|
key string
|
|
source userstore.WatchHistorySource
|
|
}
|
|
|
|
func (p watchedExporterStub) Key() string {
|
|
if p.key != "" {
|
|
return p.key
|
|
}
|
|
return "trakt"
|
|
}
|
|
|
|
func (p watchedExporterStub) DisplayName() string {
|
|
return "Trakt"
|
|
}
|
|
|
|
func (p watchedExporterStub) Capabilities() Capabilities {
|
|
return Capabilities{ExportWatched: true}
|
|
}
|
|
|
|
func (p watchedExporterStub) FetchHistory(context.Context, ServerConfig, Connection) ([]RemotePlay, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (p watchedExporterStub) ExportHistory(context.Context, ServerConfig, Connection, []LocalPlay) (ExportResult, error) {
|
|
return p.exportResult, p.exportErr
|
|
}
|
|
|
|
func (p watchedExporterStub) HistorySource() userstore.WatchHistorySource {
|
|
if p.source == "" {
|
|
return userstore.WatchHistorySourceTrakt
|
|
}
|
|
return p.source
|
|
}
|
|
|
|
type watchedImportExportStub struct {
|
|
key string
|
|
source userstore.WatchHistorySource
|
|
rows []RemoteWatch
|
|
exportErr error
|
|
}
|
|
|
|
func (p watchedImportExportStub) Key() string {
|
|
if p.key != "" {
|
|
return p.key
|
|
}
|
|
return "trakt"
|
|
}
|
|
|
|
func (p watchedImportExportStub) DisplayName() string {
|
|
return "Trakt"
|
|
}
|
|
|
|
func (p watchedImportExportStub) Capabilities() Capabilities {
|
|
return Capabilities{ImportWatched: true, ExportWatched: true}
|
|
}
|
|
|
|
func (p watchedImportExportStub) FetchWatched(context.Context, ServerConfig, Connection) ([]RemoteWatch, error) {
|
|
return p.rows, nil
|
|
}
|
|
|
|
func (p watchedImportExportStub) FetchHistory(context.Context, ServerConfig, Connection) ([]RemotePlay, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (p watchedImportExportStub) ExportHistory(_ context.Context, _ ServerConfig, _ Connection, plays []LocalPlay) (ExportResult, error) {
|
|
if p.exportErr != nil {
|
|
return ExportResult{}, p.exportErr
|
|
}
|
|
result := ExportResult{Sent: make([]string, 0, len(plays))}
|
|
for _, play := range plays {
|
|
result.Sent = append(result.Sent, play.HistoryID)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (p watchedImportExportStub) HistorySource() userstore.WatchHistorySource {
|
|
if p.source == "" {
|
|
return userstore.WatchHistorySourceTrakt
|
|
}
|
|
return p.source
|
|
}
|
|
|
|
type scrobblerStub struct {
|
|
stopErr error
|
|
refreshed bool
|
|
refreshTokens TokenSet
|
|
refreshErr error
|
|
stopConns chan Connection
|
|
stopEvents chan ScrobbleEvent
|
|
stopStarted chan struct{}
|
|
stopRelease chan struct{}
|
|
stopFailures *atomic.Int32
|
|
}
|
|
|
|
type keyedScrobblerStub struct {
|
|
scrobblerStub
|
|
key string
|
|
}
|
|
|
|
func (p keyedScrobblerStub) Key() string {
|
|
return p.key
|
|
}
|
|
|
|
func (p keyedScrobblerStub) DisplayName() string {
|
|
return p.key
|
|
}
|
|
|
|
func (p scrobblerStub) Key() string {
|
|
return "trakt"
|
|
}
|
|
|
|
func (p scrobblerStub) DisplayName() string {
|
|
return "Trakt"
|
|
}
|
|
|
|
func (p scrobblerStub) Capabilities() Capabilities {
|
|
return Capabilities{ScrobblePlayback: true}
|
|
}
|
|
|
|
type watchedScrobblerStub struct{ scrobblerStub }
|
|
|
|
func (watchedScrobblerStub) Capabilities() Capabilities {
|
|
return Capabilities{ScrobblePlayback: true, ExportWatched: true}
|
|
}
|
|
|
|
func (p scrobblerStub) Start(context.Context, ServerConfig, Connection, ScrobbleEvent) error {
|
|
return nil
|
|
}
|
|
|
|
func (p scrobblerStub) Pause(context.Context, ServerConfig, Connection, ScrobbleEvent) error {
|
|
return nil
|
|
}
|
|
|
|
func (p scrobblerStub) Stop(_ context.Context, _ ServerConfig, conn Connection, event ScrobbleEvent) error {
|
|
if p.stopStarted != nil {
|
|
p.stopStarted <- struct{}{}
|
|
}
|
|
if p.stopRelease != nil {
|
|
<-p.stopRelease
|
|
}
|
|
if p.stopConns != nil {
|
|
p.stopConns <- conn
|
|
}
|
|
if p.stopEvents != nil {
|
|
p.stopEvents <- event
|
|
}
|
|
if p.stopFailures != nil && p.stopFailures.Add(-1) >= 0 {
|
|
return errors.New("stop failed")
|
|
}
|
|
return p.stopErr
|
|
}
|
|
|
|
func (p *scrobblerStub) RefreshToken(context.Context, ServerConfig, Connection) (TokenSet, error) {
|
|
p.refreshed = true
|
|
if p.refreshErr != nil {
|
|
return TokenSet{}, p.refreshErr
|
|
}
|
|
return p.refreshTokens, nil
|
|
}
|
|
|
|
func (p *scrobblerStub) StartDeviceAuth(context.Context, ServerConfig) (DeviceAuthSession, error) {
|
|
return DeviceAuthSession{}, nil
|
|
}
|
|
|
|
func (p *scrobblerStub) PollDeviceAuth(context.Context, ServerConfig, DeviceAuthSession) (TokenSet, error) {
|
|
return TokenSet{}, nil
|
|
}
|
|
|
|
func (p *scrobblerStub) LookupAccount(context.Context, ServerConfig, Connection) (ProviderAccount, error) {
|
|
return ProviderAccount{}, nil
|
|
}
|
|
|
|
type orderedScrobblerStub struct {
|
|
mu sync.Mutex
|
|
calls []string
|
|
started chan string
|
|
release chan struct{}
|
|
}
|
|
|
|
func newOrderedScrobblerStub() *orderedScrobblerStub {
|
|
stub := &orderedScrobblerStub{
|
|
started: make(chan string, 3),
|
|
release: make(chan struct{}),
|
|
}
|
|
return stub
|
|
}
|
|
|
|
func (p *orderedScrobblerStub) Key() string {
|
|
return "simkl"
|
|
}
|
|
|
|
func (p *orderedScrobblerStub) DisplayName() string {
|
|
return "Simkl"
|
|
}
|
|
|
|
func (p *orderedScrobblerStub) Capabilities() Capabilities {
|
|
return Capabilities{ScrobblePlayback: true}
|
|
}
|
|
|
|
func (p *orderedScrobblerStub) ScrobbleOrderingKey(conn Connection, _ ScrobbleEvent) string {
|
|
return "simkl:" + conn.ID
|
|
}
|
|
|
|
func (p *orderedScrobblerStub) Start(context.Context, ServerConfig, Connection, ScrobbleEvent) error {
|
|
return p.record("start")
|
|
}
|
|
|
|
func (p *orderedScrobblerStub) Pause(context.Context, ServerConfig, Connection, ScrobbleEvent) error {
|
|
return p.record("pause")
|
|
}
|
|
|
|
func (p *orderedScrobblerStub) Stop(context.Context, ServerConfig, Connection, ScrobbleEvent) error {
|
|
return p.record("stop")
|
|
}
|
|
|
|
func (p *orderedScrobblerStub) record(action string) error {
|
|
p.started <- action
|
|
<-p.release
|
|
p.mu.Lock()
|
|
p.calls = append(p.calls, action)
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (p *orderedScrobblerStub) waitCalls(t *testing.T, count int) []string {
|
|
t.Helper()
|
|
deadline := time.Now().Add(time.Second)
|
|
for {
|
|
p.mu.Lock()
|
|
if len(p.calls) >= count {
|
|
calls := append([]string{}, p.calls...)
|
|
p.mu.Unlock()
|
|
return calls
|
|
}
|
|
calls := append([]string{}, p.calls...)
|
|
p.mu.Unlock()
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("calls = %+v, want %d", calls, count)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
type staticStoreProvider struct {
|
|
store userstore.UserStore
|
|
}
|
|
|
|
func (p staticStoreProvider) ForUser(context.Context, int) (userstore.UserStore, error) {
|
|
return p.store, nil
|
|
}
|
|
|
|
func (p staticStoreProvider) Close() error {
|
|
return nil
|
|
}
|
|
|
|
type unmatchedMatcherStub struct {
|
|
reason string
|
|
}
|
|
|
|
func (m unmatchedMatcherStub) Match(context.Context, historyimport.Record) (*historyimport.Match, string, error) {
|
|
return nil, m.reason, nil
|
|
}
|
|
|
|
type matchedMatcherStub struct {
|
|
mediaItemID string
|
|
}
|
|
|
|
func (m matchedMatcherStub) Match(context.Context, historyimport.Record) (*historyimport.Match, string, error) {
|
|
return &historyimport.Match{MediaItemID: m.mediaItemID}, "", nil
|
|
}
|
|
|
|
type noOpWatchState struct{}
|
|
|
|
func (noOpWatchState) RecordImportedHistoryWithSource(
|
|
context.Context,
|
|
int,
|
|
string,
|
|
string,
|
|
float64,
|
|
bool,
|
|
*time.Time,
|
|
userstore.WatchHistorySource,
|
|
) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
func (noOpWatchState) RecordImportedWatchIfNewerWithSource(
|
|
context.Context,
|
|
int,
|
|
string,
|
|
string,
|
|
float64,
|
|
float64,
|
|
bool,
|
|
time.Time,
|
|
*time.Time,
|
|
userstore.WatchHistorySource,
|
|
) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
type recordingWatchState struct {
|
|
sources []userstore.WatchHistorySource
|
|
updatedAt []time.Time
|
|
watchedAt []*time.Time
|
|
completed []bool
|
|
positions []float64
|
|
durations []float64
|
|
targetIDs []string
|
|
}
|
|
|
|
func (s *recordingWatchState) RecordImportedHistoryWithSource(
|
|
_ context.Context,
|
|
_ int,
|
|
_ string,
|
|
_ string,
|
|
_ float64,
|
|
_ bool,
|
|
_ *time.Time,
|
|
source userstore.WatchHistorySource,
|
|
) (bool, error) {
|
|
s.sources = append(s.sources, source)
|
|
return true, nil
|
|
}
|
|
|
|
func (s *recordingWatchState) RecordImportedWatchIfNewerWithSource(
|
|
_ context.Context,
|
|
_ int,
|
|
_ string,
|
|
targetID string,
|
|
duration float64,
|
|
position float64,
|
|
completed bool,
|
|
updatedAt time.Time,
|
|
watchedAt *time.Time,
|
|
source userstore.WatchHistorySource,
|
|
) (bool, error) {
|
|
s.sources = append(s.sources, source)
|
|
s.updatedAt = append(s.updatedAt, updatedAt)
|
|
s.watchedAt = append(s.watchedAt, watchedAt)
|
|
s.completed = append(s.completed, completed)
|
|
s.positions = append(s.positions, position)
|
|
s.durations = append(s.durations, duration)
|
|
s.targetIDs = append(s.targetIDs, targetID)
|
|
return true, nil
|
|
}
|
|
|
|
func TestServiceStartsAndPollsDeviceAuth(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
provider := &authProviderStub{}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
|
|
session, err := service.StartDeviceAuth(context.Background(), 7, "profile-1", "trakt")
|
|
if err != nil {
|
|
t.Fatalf("StartDeviceAuth: %v", err)
|
|
}
|
|
if !provider.started || session.ID != "auth-1" {
|
|
t.Fatalf("session = %+v started=%v", session, provider.started)
|
|
}
|
|
|
|
conn, err := service.PollDeviceAuth(context.Background(), 7, "profile-1", "trakt", session.ID)
|
|
if err != nil {
|
|
t.Fatalf("PollDeviceAuth: %v", err)
|
|
}
|
|
if conn.ProviderUsername != "alex" || conn.AccessToken != testAccessToken {
|
|
t.Fatalf("connection = %+v", conn)
|
|
}
|
|
if !conn.ImportWatchedEnabled || !conn.ImportProgressEnabled ||
|
|
!conn.ExportWatchedEnabled || !conn.ScrobbleEnabled {
|
|
t.Fatalf("default toggles were not enabled: %+v", conn)
|
|
}
|
|
storedSession := repo.sessions[session.ID]
|
|
if storedSession.CompletedAt == nil {
|
|
t.Fatalf("auth session was not marked completed: %+v", storedSession)
|
|
}
|
|
}
|
|
|
|
func TestServicePersistsRotatedPendingDeviceAuthorizationState(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
now := time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC)
|
|
original := DeviceAuthSession{
|
|
ID: "auth-1", Provider: "trakt", UserID: 7, ProfileID: "profile-1",
|
|
DeviceCode: "original", UserCode: "CODE", VerificationURL: "https://trakt.tv/activate",
|
|
IntervalSeconds: 5, ExpiresAt: now.Add(10 * time.Minute),
|
|
}
|
|
repo.sessions[original.ID] = original
|
|
provider := &authProviderStub{pollErr: deviceAuthorizationPendingError{session: DeviceAuthSession{
|
|
// These host-owned fields are intentionally wrong; only the rotated
|
|
// challenge state, interval, and expiry may be accepted from the provider.
|
|
ID: "wrong", Provider: "wrong", UserID: 99, ProfileID: "wrong",
|
|
DeviceCode: "rotated", UserCode: "WRONG", VerificationURL: "https://evil.example",
|
|
IntervalSeconds: 11, ExpiresAt: now.Add(20 * time.Minute),
|
|
}}}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
service.now = func() time.Time { return now }
|
|
_, err := service.PollDeviceAuth(context.Background(), 7, "profile-1", "trakt", original.ID)
|
|
var pending deviceAuthorizationPendingError
|
|
if !errors.As(err, &pending) {
|
|
t.Fatalf("error = %#v, want pending", err)
|
|
}
|
|
stored := repo.sessions[original.ID]
|
|
if stored.ID != original.ID || stored.Provider != original.Provider || stored.UserID != original.UserID ||
|
|
stored.ProfileID != original.ProfileID || stored.UserCode != original.UserCode ||
|
|
stored.VerificationURL != original.VerificationURL || stored.DeviceCode != "rotated" ||
|
|
stored.IntervalSeconds != 11 || !stored.ExpiresAt.Equal(now.Add(20*time.Minute)) {
|
|
t.Fatalf("stored session = %#v", stored)
|
|
}
|
|
}
|
|
|
|
func TestServiceStartsAndPollsDeviceAuthRejectsMismatchedSession(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
provider := &authProviderStub{}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
|
|
session, err := service.StartDeviceAuth(context.Background(), 7, "profile-1", "trakt")
|
|
if err != nil {
|
|
t.Fatalf("StartDeviceAuth: %v", err)
|
|
}
|
|
_, err = service.PollDeviceAuth(context.Background(), 7, "profile-2", "trakt", session.ID)
|
|
if err == nil {
|
|
t.Fatal("expected mismatched profile to be rejected")
|
|
}
|
|
if provider.polled {
|
|
t.Fatal("provider was polled before session ownership was verified")
|
|
}
|
|
}
|
|
|
|
func TestServiceRejectsMissingProfileScope(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
provider := &authProviderStub{}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
|
|
if _, err := service.StartDeviceAuth(context.Background(), 0, "profile-1", "trakt"); err == nil {
|
|
t.Fatal("expected missing user id to be rejected")
|
|
}
|
|
if _, err := service.StartDeviceAuth(context.Background(), 7, "", "trakt"); err == nil {
|
|
t.Fatal("expected missing profile id to be rejected")
|
|
}
|
|
if _, err := service.PollDeviceAuth(context.Background(), 7, "profile-1", "trakt", ""); err == nil {
|
|
t.Fatal("expected missing auth session id to be rejected")
|
|
}
|
|
if provider.started || provider.polled {
|
|
t.Fatal("provider was called for invalid profile scope")
|
|
}
|
|
}
|
|
|
|
func TestServicePollDeviceAuthRejectsExpiredOrCompletedSession(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
provider := &authProviderStub{}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
|
service.now = func() time.Time { return now }
|
|
|
|
repo.sessions["expired"] = DeviceAuthSession{
|
|
ID: "expired",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ExpiresAt: now.Add(-time.Second),
|
|
}
|
|
if _, err := service.PollDeviceAuth(context.Background(), 7, "profile-1", "trakt", "expired"); err == nil {
|
|
t.Fatal("expected expired session to be rejected")
|
|
}
|
|
|
|
completedAt := now.Add(-time.Minute)
|
|
repo.sessions["completed"] = DeviceAuthSession{
|
|
ID: "completed",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ExpiresAt: now.Add(time.Minute),
|
|
CompletedAt: &completedAt,
|
|
}
|
|
if _, err := service.PollDeviceAuth(context.Background(), 7, "profile-1", "trakt", "completed"); err == nil {
|
|
t.Fatal("expected completed session to be rejected")
|
|
}
|
|
if provider.polled {
|
|
t.Fatal("provider was polled for expired or completed session")
|
|
}
|
|
}
|
|
|
|
func TestServicePollDeviceAuthPreservesExistingConnectionToggles(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
provider := &authProviderStub{}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
|
|
existing := Connection{
|
|
ID: "existing-conn",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ImportWatchedEnabled: false,
|
|
ImportProgressEnabled: false,
|
|
ExportWatchedEnabled: true,
|
|
ScrobbleEnabled: true,
|
|
}
|
|
repo.connections[connectionKey("trakt", 7, "profile-1")] = existing
|
|
repo.sessions["auth-1"] = DeviceAuthSession{
|
|
ID: "auth-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
}
|
|
|
|
conn, err := service.PollDeviceAuth(context.Background(), 7, "profile-1", "trakt", "auth-1")
|
|
if err != nil {
|
|
t.Fatalf("PollDeviceAuth: %v", err)
|
|
}
|
|
if conn.ID != existing.ID {
|
|
t.Fatalf("connection ID = %q, want existing ID %q", conn.ID, existing.ID)
|
|
}
|
|
if conn.ImportWatchedEnabled || conn.ImportProgressEnabled ||
|
|
!conn.ExportWatchedEnabled || !conn.ScrobbleEnabled {
|
|
t.Fatalf("connection toggles were not preserved: %+v", conn)
|
|
}
|
|
if conn.AccessToken != testAccessToken || conn.ProviderUsername != "alex" {
|
|
t.Fatalf("connection credentials/account were not refreshed: %+v", conn)
|
|
}
|
|
}
|
|
|
|
func TestServiceRequestManualSyncCreatesAsyncRun(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
provider := &authProviderStub{}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
|
service.now = func() time.Time { return now }
|
|
repo.connections[connectionKey("trakt", 7, "profile-1")] = Connection{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
}
|
|
|
|
result, err := service.RequestManualSync(context.Background(), 7, "profile-1", "trakt")
|
|
if err != nil {
|
|
t.Fatalf("RequestManualSync: %v", err)
|
|
}
|
|
if result.Run.ID == "" || result.Run.Status != string(SyncRunStatusRunning) {
|
|
t.Fatalf("run = %+v, want running run", result.Run)
|
|
}
|
|
if result.RetryAfterSeconds != 0 {
|
|
t.Fatalf("retry after = %d, want 0", result.RetryAfterSeconds)
|
|
}
|
|
|
|
deadline := time.Now().Add(time.Second)
|
|
for {
|
|
latest, ok, err := repo.GetLatestSyncRun(context.Background(), "conn-1")
|
|
if err != nil {
|
|
t.Fatalf("GetLatestSyncRun: %v", err)
|
|
}
|
|
if ok && latest.Status == string(SyncRunStatusSuccess) {
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("sync run did not complete: %+v", repo.syncRunsSnapshot())
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestServiceRequestManualSyncReturnsActiveRun(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
provider := &authProviderStub{}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
repo.connections[connectionKey("trakt", 7, "profile-1")] = Connection{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
}
|
|
repo.syncRuns = append(repo.syncRuns, SyncRun{
|
|
ID: "active-run",
|
|
ConnectionID: "conn-1",
|
|
Provider: "trakt",
|
|
Trigger: "manual",
|
|
Status: string(SyncRunStatusRunning),
|
|
StartedAt: time.Now(),
|
|
CreatedAt: time.Now(),
|
|
})
|
|
|
|
result, err := service.RequestManualSync(context.Background(), 7, "profile-1", "trakt")
|
|
if err != nil {
|
|
t.Fatalf("RequestManualSync: %v", err)
|
|
}
|
|
if result.Run.ID != "active-run" {
|
|
t.Fatalf("run ID = %q, want active-run", result.Run.ID)
|
|
}
|
|
if len(repo.syncRuns) != 1 {
|
|
t.Fatalf("sync runs = %d, want 1", len(repo.syncRuns))
|
|
}
|
|
}
|
|
|
|
func TestServiceRequestManualSyncCooldown(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
provider := &authProviderStub{}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
|
service.now = func() time.Time { return now }
|
|
completedAt := now.Add(-30 * time.Minute)
|
|
repo.connections[connectionKey("trakt", 7, "profile-1")] = Connection{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
}
|
|
repo.syncRuns = append(repo.syncRuns, SyncRun{
|
|
ID: "recent-run",
|
|
ConnectionID: "conn-1",
|
|
Provider: "trakt",
|
|
Trigger: "scheduled",
|
|
Status: string(SyncRunStatusSuccess),
|
|
StartedAt: completedAt.Add(-time.Minute),
|
|
CompletedAt: &completedAt,
|
|
CreatedAt: completedAt.Add(-time.Minute),
|
|
})
|
|
|
|
_, err := service.RequestManualSync(context.Background(), 7, "profile-1", "trakt")
|
|
var cooldown SyncCooldownError
|
|
if !errors.As(err, &cooldown) {
|
|
t.Fatalf("error = %v, want SyncCooldownError", err)
|
|
}
|
|
if cooldown.RetryAfterSeconds != 30*60 {
|
|
t.Fatalf("retry after = %d, want %d", cooldown.RetryAfterSeconds, 30*60)
|
|
}
|
|
}
|
|
|
|
func TestServiceConnectionStatusRejectsBlankAccessToken(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
provider := &authProviderStub{}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
repo.connections[connectionKey("trakt", 7, "profile-1")] = Connection{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
AccessToken: " ",
|
|
}
|
|
|
|
status, err := NewService(repo, reg).GetConnectionStatus(context.Background(), 7, "profile-1", "trakt")
|
|
if err != nil {
|
|
t.Fatalf("GetConnectionStatus: %v", err)
|
|
}
|
|
if status.Connected {
|
|
t.Fatalf("status = %+v, want disconnected for blank access token", status)
|
|
}
|
|
if status.LastError == "" {
|
|
t.Fatalf("status = %+v, want reconnect error", status)
|
|
}
|
|
}
|
|
|
|
func TestServiceSyncConnectionRejectsBlankAccessToken(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
provider := watchedExporterStub{}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
conn := Connection{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
AccessToken: "",
|
|
ExportWatchedEnabled: true,
|
|
}
|
|
repo.connections[connectionKey("trakt", 7, "profile-1")] = conn
|
|
|
|
err := NewService(repo, reg).SyncConnection(context.Background(), conn, "scheduled")
|
|
if err == nil {
|
|
t.Fatal("SyncConnection error = nil, want blank token error")
|
|
}
|
|
latest, ok, err := repo.GetLatestSyncRun(context.Background(), "conn-1")
|
|
if err != nil {
|
|
t.Fatalf("GetLatestSyncRun: %v", err)
|
|
}
|
|
if !ok || latest.Status != string(SyncRunStatusFailed) || latest.Error == "" {
|
|
t.Fatalf("latest run = %+v, want failed blank token run", latest)
|
|
}
|
|
}
|
|
|
|
func TestServiceSyncConnectionRefreshesExpiredToken(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
|
expiresAt := now.Add(-time.Minute)
|
|
refreshedExpiresAt := now.Add(time.Hour)
|
|
provider := &authProviderStub{
|
|
refreshTokens: TokenSet{
|
|
AccessToken: "new-access",
|
|
RefreshToken: "new-refresh",
|
|
TokenExpiresAt: &refreshedExpiresAt,
|
|
},
|
|
}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
service.now = func() time.Time { return now }
|
|
conn := Connection{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
AccessToken: testOldAccessToken,
|
|
RefreshToken: testOldRefreshToken,
|
|
TokenExpiresAt: &expiresAt,
|
|
}
|
|
repo.connections[connectionKey("trakt", 7, "profile-1")] = conn
|
|
|
|
if err := service.SyncConnection(context.Background(), conn, "scheduled"); err != nil {
|
|
t.Fatalf("SyncConnection: %v", err)
|
|
}
|
|
if !provider.refreshed {
|
|
t.Fatal("provider was not asked to refresh the expired token")
|
|
}
|
|
updated := repo.connections[connectionKey("trakt", 7, "profile-1")]
|
|
if updated.AccessToken != "new-access" || updated.RefreshToken != "new-refresh" {
|
|
t.Fatalf("connection tokens = %q/%q, want refreshed tokens", updated.AccessToken, updated.RefreshToken)
|
|
}
|
|
if updated.TokenExpiresAt == nil || !updated.TokenExpiresAt.Equal(refreshedExpiresAt) {
|
|
t.Fatalf("token expiry = %v, want %v", updated.TokenExpiresAt, refreshedExpiresAt)
|
|
}
|
|
}
|
|
|
|
func TestServicePluginRefreshPersistsAuthoritativeCredentialsBeforeFault(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
|
expiresAt := now.Add(-time.Minute)
|
|
client := &fakeWatchSyncPluginClient{refreshResponse: &pluginv1.WatchSyncCredentialResponse{
|
|
Credentials: &pluginv1.WatchSyncCredentials{AccessToken: testRotatedAccessToken, TokenType: testBearerTokenType},
|
|
Fault: &pluginv1.WatchSyncFault{
|
|
Code: pluginv1.WatchSyncFaultCode_WATCH_SYNC_FAULT_CODE_INVALID_CREDENTIAL,
|
|
SafeMessage: testReconnectRequired,
|
|
},
|
|
}}
|
|
provider := testPluginProvider(t, client)
|
|
service := NewService(repo, NewRegistry())
|
|
service.now = func() time.Time { return now }
|
|
conn := Connection{
|
|
ID: "conn-1",
|
|
Provider: provider.Key(),
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
AccessToken: testOldAccessToken,
|
|
RefreshToken: testOldRefreshToken,
|
|
TokenExpiresAt: &expiresAt,
|
|
}
|
|
repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)] = conn
|
|
|
|
if _, err := service.refreshConnectionIfNeeded(context.Background(), provider, ServerConfig{}, conn); !isWatchSyncInvalidCredentialError(err) {
|
|
t.Fatalf("error = %#v", err)
|
|
}
|
|
updated := repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)]
|
|
if updated.AccessToken != testRotatedAccessToken || updated.RefreshToken != "" || updated.TokenExpiresAt != nil {
|
|
t.Fatalf("connection credentials = %#v", updated)
|
|
}
|
|
if updated.LastError != testReconnectRequired {
|
|
t.Fatalf("LastError = %q", updated.LastError)
|
|
}
|
|
}
|
|
|
|
func TestServiceSyncConnectionTreatsMatcherWarningsAsSuccess(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
watchedAt := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
|
provider := watchedImporterStub{rows: []RemoteWatch{{
|
|
Provider: "trakt",
|
|
Kind: "movie",
|
|
Title: "Ghost Hunters",
|
|
Year: 2019,
|
|
LastWatchedAt: &watchedAt,
|
|
}}}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg).
|
|
WithMatcher(unmatchedMatcherStub{reason: `no tmdb_id match for "92820"`}).
|
|
WithWatchState(noOpWatchState{})
|
|
conn := Connection{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
AccessToken: testAccessToken,
|
|
ImportWatchedEnabled: true,
|
|
}
|
|
|
|
if err := service.SyncConnection(context.Background(), conn, "scheduled"); err != nil {
|
|
t.Fatalf("SyncConnection: %v", err)
|
|
}
|
|
latest, ok, err := repo.GetLatestSyncRun(context.Background(), "conn-1")
|
|
if err != nil {
|
|
t.Fatalf("GetLatestSyncRun: %v", err)
|
|
}
|
|
if !ok || latest.Status != string(SyncRunStatusSuccess) {
|
|
t.Fatalf("latest run = %+v, want success run", latest)
|
|
}
|
|
if latest.Warning == "" {
|
|
t.Fatalf("latest run warning is empty: %+v", latest)
|
|
}
|
|
}
|
|
|
|
func TestServiceImportWatchedUsesProviderHistorySource(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
watchedAt := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
|
provider := watchedImporterStub{
|
|
key: "simkl",
|
|
source: userstore.WatchHistorySourceSimkl,
|
|
rows: []RemoteWatch{{
|
|
Provider: "simkl",
|
|
Kind: historyimport.KindMovie,
|
|
Title: "Inception",
|
|
Year: 2010,
|
|
LastWatchedAt: &watchedAt,
|
|
}},
|
|
}
|
|
watchState := &recordingWatchState{}
|
|
service := NewService(repo, NewRegistry()).
|
|
WithMatcher(matchedMatcherStub{mediaItemID: testMovieMediaID}).
|
|
WithWatchState(watchState)
|
|
|
|
result, err := service.ImportWatched(context.Background(), Connection{
|
|
ID: "conn-1",
|
|
Provider: "simkl",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
}, ServerConfig{}, provider)
|
|
if err != nil {
|
|
t.Fatalf("ImportWatched: %v", err)
|
|
}
|
|
if result.Imported != 1 {
|
|
t.Fatalf("imported = %d, want 1", result.Imported)
|
|
}
|
|
if len(watchState.sources) != 1 || watchState.sources[0] != userstore.WatchHistorySourceSimkl {
|
|
t.Fatalf("recorded sources = %+v, want simkl", watchState.sources)
|
|
}
|
|
if len(watchState.targetIDs) != 1 || watchState.targetIDs[0] != testMovieMediaID {
|
|
t.Fatalf("recorded target ids = %+v, want movie-1", watchState.targetIDs)
|
|
}
|
|
if len(watchState.completed) != 1 || !watchState.completed[0] {
|
|
t.Fatalf("recorded completed flags = %+v, want true", watchState.completed)
|
|
}
|
|
if len(watchState.positions) != 1 || watchState.positions[0] != 0 {
|
|
t.Fatalf("recorded positions = %+v, want 0", watchState.positions)
|
|
}
|
|
if len(watchState.updatedAt) != 1 || !watchState.updatedAt[0].Equal(watchedAt) {
|
|
t.Fatalf("recorded updated_at = %+v, want %v", watchState.updatedAt, watchedAt)
|
|
}
|
|
if len(watchState.watchedAt) != 1 || watchState.watchedAt[0] == nil || !watchState.watchedAt[0].Equal(watchedAt) {
|
|
t.Fatalf("recorded watched_at = %+v, want %v", watchState.watchedAt, watchedAt)
|
|
}
|
|
}
|
|
|
|
func TestServiceImportWatchedSkipsRowsWithoutLastWatchedAt(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
provider := watchedImporterStub{
|
|
rows: []RemoteWatch{{
|
|
Provider: "trakt",
|
|
Kind: historyimport.KindMovie,
|
|
Title: "Inception",
|
|
Year: 2010,
|
|
}},
|
|
}
|
|
watchState := &recordingWatchState{}
|
|
service := NewService(repo, NewRegistry()).
|
|
WithMatcher(matchedMatcherStub{mediaItemID: testMovieMediaID}).
|
|
WithWatchState(watchState)
|
|
|
|
result, err := service.ImportWatched(context.Background(), Connection{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
}, ServerConfig{}, provider)
|
|
if err != nil {
|
|
t.Fatalf("ImportWatched: %v", err)
|
|
}
|
|
if result.Found != 1 || result.Imported != 0 {
|
|
t.Fatalf("result = %+v, want found row skipped with no import", result)
|
|
}
|
|
if len(watchState.targetIDs) != 0 {
|
|
t.Fatalf("watch state calls = %+v, want none", watchState.targetIDs)
|
|
}
|
|
}
|
|
|
|
func TestServiceImportWatchedPersistsBatchCursorsAndWarnings(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
|
provider := watchedBatchImporterStub{
|
|
watchedImporterStub: watchedImporterStub{key: "simkl", source: userstore.WatchHistorySourceSimkl},
|
|
batch: WatchedImportBatch{
|
|
UpdatedCursors: map[string]string{"simkl.inbound.movies.completed": "2026-05-04T11:00:00Z"},
|
|
Warnings: []string{"simkl removed_from_list changed; removals are not imported"},
|
|
},
|
|
}
|
|
service := NewService(repo, NewRegistry()).
|
|
WithMatcher(unmatchedMatcherStub{}).
|
|
WithWatchState(noOpWatchState{})
|
|
service.now = func() time.Time { return now }
|
|
|
|
result, err := service.ImportWatched(context.Background(), Connection{
|
|
ID: "conn-1",
|
|
Provider: "simkl",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
SyncCursors: map[string]string{"existing": "cursor"},
|
|
}, ServerConfig{}, provider)
|
|
if err != nil {
|
|
t.Fatalf("ImportWatched: %v", err)
|
|
}
|
|
if len(result.Warnings) != 1 || result.Warnings[0] != "simkl removed_from_list changed; removals are not imported" {
|
|
t.Fatalf("warnings = %+v", result.Warnings)
|
|
}
|
|
updated := repo.connections[connectionKey("simkl", 7, "profile-1")]
|
|
if updated.LastInboundSyncAt == nil || !updated.LastInboundSyncAt.Equal(now) {
|
|
t.Fatalf("last inbound sync = %v, want %v", updated.LastInboundSyncAt, now)
|
|
}
|
|
if updated.SyncCursors["existing"] != "cursor" ||
|
|
updated.SyncCursors["simkl.inbound.movies.completed"] != "2026-05-04T11:00:00Z" {
|
|
t.Fatalf("sync cursors = %+v", updated.SyncCursors)
|
|
}
|
|
}
|
|
|
|
func TestServiceImportWatchedLegacyImporterStillSetsLastSyncTimestamp(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
|
service := NewService(repo, NewRegistry()).
|
|
WithMatcher(unmatchedMatcherStub{}).
|
|
WithWatchState(noOpWatchState{})
|
|
service.now = func() time.Time { return now }
|
|
|
|
_, err := service.ImportWatched(context.Background(), Connection{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
}, ServerConfig{}, watchedImporterStub{})
|
|
if err != nil {
|
|
t.Fatalf("ImportWatched: %v", err)
|
|
}
|
|
updated := repo.connections[connectionKey("trakt", 7, "profile-1")]
|
|
if updated.LastInboundSyncAt == nil || !updated.LastInboundSyncAt.Equal(now) {
|
|
t.Fatalf("last inbound sync = %v, want %v", updated.LastInboundSyncAt, now)
|
|
}
|
|
if len(updated.SyncCursors) != 0 {
|
|
t.Fatalf("sync cursors = %+v, want empty for legacy importer", updated.SyncCursors)
|
|
}
|
|
}
|
|
|
|
func TestServiceImportProgressPersistsBatchCursorsAndWarnings(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
|
service := NewService(repo, NewRegistry()).
|
|
WithMatcher(unmatchedMatcherStub{}).
|
|
WithUserStoreProvider(staticStoreProvider{})
|
|
service.now = func() time.Time { return now }
|
|
provider := progressBatchImporterStub{
|
|
batch: ProgressImportBatch{
|
|
UpdatedCursors: map[string]string{"simkl.progress.movies": "2026-05-04T11:30:00Z"},
|
|
Warnings: []string{"simkl playback movie skipped because it has no usable external id"},
|
|
},
|
|
}
|
|
|
|
result, err := service.ImportProgress(context.Background(), Connection{
|
|
ID: "conn-1",
|
|
Provider: "simkl",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
SyncCursors: map[string]string{"existing": "cursor"},
|
|
}, ServerConfig{}, provider)
|
|
if err != nil {
|
|
t.Fatalf("ImportProgress: %v", err)
|
|
}
|
|
if len(result.Warnings) != 1 || result.Warnings[0] != "simkl playback movie skipped because it has no usable external id" {
|
|
t.Fatalf("warnings = %+v", result.Warnings)
|
|
}
|
|
updated := repo.connections[connectionKey("simkl", 7, "profile-1")]
|
|
if updated.LastProgressSyncAt == nil || !updated.LastProgressSyncAt.Equal(now) {
|
|
t.Fatalf("last progress sync = %v, want %v", updated.LastProgressSyncAt, now)
|
|
}
|
|
if updated.SyncCursors["existing"] != "cursor" ||
|
|
updated.SyncCursors["simkl.progress.movies"] != "2026-05-04T11:30:00Z" {
|
|
t.Fatalf("sync cursors = %+v", updated.SyncCursors)
|
|
}
|
|
}
|
|
|
|
func TestServiceSyncConnectionPreservesConnectionUpdatesAcrossFlows(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
defer db.Close()
|
|
if err := userdb.InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
if err := userdb.AddHistory(db, userstore.WatchHistoryEntry{
|
|
ID: "history-1",
|
|
ProfileID: "profile-1",
|
|
MediaItemID: testMovieMediaID,
|
|
WatchedAt: "2026-05-04T12:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourcePlayback,
|
|
Identity: userstore.WatchIdentity{
|
|
StableType: "movie",
|
|
ProviderIDs: map[string]string{"tmdb": "603"},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory: %v", err)
|
|
}
|
|
|
|
watchedAt := time.Date(2026, 5, 4, 13, 0, 0, 0, time.UTC)
|
|
provider := watchedImportExportStub{
|
|
key: "simkl",
|
|
source: userstore.WatchHistorySourceSimkl,
|
|
rows: []RemoteWatch{{
|
|
Provider: "simkl",
|
|
Kind: historyimport.KindMovie,
|
|
Title: "Inception",
|
|
Year: 2010,
|
|
LastWatchedAt: &watchedAt,
|
|
}},
|
|
}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
repo := newServiceFakeRepo()
|
|
service := NewService(repo, reg).
|
|
WithMatcher(matchedMatcherStub{mediaItemID: testMovieMediaID}).
|
|
WithWatchState(&recordingWatchState{}).
|
|
WithUserStoreProvider(staticStoreProvider{store: userdb.NewSQLiteUserStore(db)})
|
|
now := time.Date(2026, 5, 4, 14, 0, 0, 0, time.UTC)
|
|
service.now = func() time.Time { return now }
|
|
conn := Connection{
|
|
ID: "conn-1",
|
|
Provider: "simkl",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
AccessToken: testAccessToken,
|
|
ImportWatchedEnabled: true,
|
|
ExportWatchedEnabled: true,
|
|
}
|
|
repo.connections[connectionKey("simkl", 7, "profile-1")] = conn
|
|
|
|
if err := service.SyncConnection(context.Background(), conn, "scheduled"); err != nil {
|
|
t.Fatalf("SyncConnection: %v", err)
|
|
}
|
|
updated := repo.connections[connectionKey("simkl", 7, "profile-1")]
|
|
if updated.LastInboundSyncAt == nil {
|
|
t.Fatalf("LastInboundSyncAt was not preserved across export: %+v", updated)
|
|
}
|
|
if updated.LastOutboundSyncAt == nil {
|
|
t.Fatalf("LastOutboundSyncAt was not recorded: %+v", updated)
|
|
}
|
|
}
|
|
|
|
func TestServiceExportWatchedDrainsPendingBatches(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
defer db.Close()
|
|
if err := userdb.InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
for i := range 101 {
|
|
id := strconv.Itoa(i)
|
|
if err := userdb.AddHistory(db, userstore.WatchHistoryEntry{
|
|
ID: "history-" + id,
|
|
ProfileID: "profile-1",
|
|
MediaItemID: "movie-" + id,
|
|
WatchedAt: "2026-05-04T12:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourcePlayback,
|
|
Identity: userstore.WatchIdentity{
|
|
StableType: "movie",
|
|
ProviderIDs: map[string]string{"tmdb": "60" + id},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
repo := newServiceFakeRepo()
|
|
service := NewService(repo, NewRegistry()).WithUserStoreProvider(staticStoreProvider{
|
|
store: userdb.NewSQLiteUserStore(db),
|
|
})
|
|
result, err := service.ExportWatched(context.Background(), Connection{
|
|
ID: "conn-1",
|
|
Provider: "simkl",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
}, ServerConfig{}, watchedImportExportStub{key: "simkl", source: userstore.WatchHistorySourceSimkl})
|
|
if err != nil {
|
|
t.Fatalf("ExportWatched: %v", err)
|
|
}
|
|
if result.Sent != 101 {
|
|
t.Fatalf("sent = %d, want 101 (result=%+v)", result.Sent, result)
|
|
}
|
|
for _, export := range repo.historyExports {
|
|
if export.Status != historyExportStatusSent {
|
|
t.Fatalf("history exports = %+v, want all sent", repo.historyExports)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServiceSyncConnectionMarksRunFailedWhenExportTransportFails(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
defer db.Close()
|
|
if err := userdb.InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
if err := userdb.AddHistory(db, userstore.WatchHistoryEntry{
|
|
ID: "history-1",
|
|
ProfileID: "profile-1",
|
|
MediaItemID: testMovieMediaID,
|
|
WatchedAt: "2026-05-04T12:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourcePlayback,
|
|
Identity: userstore.WatchIdentity{
|
|
StableType: "movie",
|
|
ProviderIDs: map[string]string{"tmdb": "603"},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory: %v", err)
|
|
}
|
|
|
|
repo := newServiceFakeRepo()
|
|
provider := watchedExporterStub{exportErr: errors.New("provider offline")}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg).WithUserStoreProvider(staticStoreProvider{
|
|
store: userdb.NewSQLiteUserStore(db),
|
|
})
|
|
conn := Connection{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
AccessToken: testAccessToken,
|
|
ExportWatchedEnabled: true,
|
|
}
|
|
|
|
err = service.SyncConnection(context.Background(), conn, "scheduled")
|
|
if err == nil {
|
|
t.Fatal("SyncConnection error = nil, want export transport failure")
|
|
}
|
|
latest, ok, err := repo.GetLatestSyncRun(context.Background(), "conn-1")
|
|
if err != nil {
|
|
t.Fatalf("GetLatestSyncRun: %v", err)
|
|
}
|
|
if !ok || latest.Status != string(SyncRunStatusFailed) {
|
|
t.Fatalf("latest run = %+v, want failed run", latest)
|
|
}
|
|
if latest.Error == "" {
|
|
t.Fatalf("latest run error is empty: %+v", latest)
|
|
}
|
|
if len(repo.historyExports) != 1 || repo.historyExports[0].Status != historyExportStatusFailed {
|
|
t.Fatalf("history exports = %+v, want one failed export", repo.historyExports)
|
|
}
|
|
}
|
|
|
|
func TestServiceExportWatchedReturnsStatusPersistenceFailure(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
defer func() {
|
|
if closeErr := db.Close(); closeErr != nil {
|
|
t.Fatalf("close sqlite: %v", closeErr)
|
|
}
|
|
}()
|
|
if err := userdb.InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
if err := userdb.AddHistory(db, userstore.WatchHistoryEntry{
|
|
ID: "history-1",
|
|
ProfileID: "profile-1",
|
|
MediaItemID: testMovieMediaID,
|
|
WatchedAt: "2026-05-04T12:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourcePlayback,
|
|
Identity: userstore.WatchIdentity{
|
|
StableType: "movie",
|
|
ProviderIDs: map[string]string{"tmdb": "603"},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory: %v", err)
|
|
}
|
|
|
|
repo := newServiceFakeRepo()
|
|
repo.markHistoryStatusErr = errors.New("persist failed")
|
|
service := NewService(repo, NewRegistry()).WithUserStoreProvider(staticStoreProvider{
|
|
store: userdb.NewSQLiteUserStore(db),
|
|
})
|
|
result, err := service.ExportWatched(context.Background(), Connection{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
}, ServerConfig{}, watchedExporterStub{exportErr: errors.New("provider offline")})
|
|
if err == nil {
|
|
t.Fatal("ExportWatched error = nil, want combined export and persistence failure")
|
|
}
|
|
if !strings.Contains(err.Error(), "provider offline") || !strings.Contains(err.Error(), "persist failed") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
if result.Failed != 1 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
if len(repo.historyExports) != 1 || repo.historyExports[0].Status != historyExportStatusPending {
|
|
t.Fatalf("history exports = %+v", repo.historyExports)
|
|
}
|
|
}
|
|
|
|
func TestServiceCompletedScrobblePersistsAndSatisfiesHistoryExport(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
service := NewService(repo, NewRegistry())
|
|
conn := Connection{ID: "conn-1"}
|
|
event := ScrobbleEvent{
|
|
PlaybackSessionID: testPlaybackSessionID,
|
|
MediaItemID: testEpisodeMediaID,
|
|
Kind: historyimport.KindEpisode,
|
|
SeriesTVDBID: "123",
|
|
SeasonNumber: 1,
|
|
EpisodeNumber: 2,
|
|
HistoryID: testWatchHistoryID,
|
|
OccurredAt: time.Now().UTC(),
|
|
Completed: true,
|
|
}
|
|
if err := service.persistCompletedScrobbleExport(context.Background(), conn, event); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(repo.historyExports) != 1 || repo.historyExports[0].Status != historyExportStatusPending {
|
|
t.Fatalf("history exports = %#v", repo.historyExports)
|
|
}
|
|
if repo.historyExports[0].ProviderItemKey != "show:tvdb:123:s1:e2" {
|
|
t.Fatalf("provider item key = %q", repo.historyExports[0].ProviderItemKey)
|
|
}
|
|
if err := service.dispatchScrobble(context.Background(), watchedScrobblerStub{}, ServerConfig{}, conn, event, "stop", nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if repo.historyExports[0].Status != historyExportStatusSatisfiedByScrobble {
|
|
t.Fatalf("history export status = %q", repo.historyExports[0].Status)
|
|
}
|
|
}
|
|
|
|
func TestServiceCompletedScrobbleDurablyRetriesSatisfiedPersistenceWithoutResendingStop(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
repo.markSatisfiedErr = errors.New("database unavailable")
|
|
service := NewService(repo, NewRegistry())
|
|
conn := Connection{ID: "conn-1", Provider: "trakt"}
|
|
event := ScrobbleEvent{
|
|
PlaybackSessionID: testPlaybackSessionID,
|
|
HistoryID: testWatchHistoryID,
|
|
Completed: true,
|
|
}
|
|
repo.historyExports = []HistoryExport{{ID: testHistoryExportID, ConnectionID: conn.ID, HistoryID: event.HistoryID, Status: historyExportStatusPending}}
|
|
if err := service.dispatchScrobble(context.Background(), watchedScrobblerStub{}, ServerConfig{}, conn, event, "stop", nil); err == nil {
|
|
t.Fatal("expected reconciliation persistence failure")
|
|
}
|
|
if repo.historyExports[0].Status != historyExportStatusPending {
|
|
t.Fatalf("history export status = %q", repo.historyExports[0].Status)
|
|
}
|
|
updates := repo.scrobbleUpdatesSnapshot()
|
|
if len(updates) != 1 || updates[0].stopSentAt == nil {
|
|
t.Fatalf("scrobble updates = %#v", updates)
|
|
}
|
|
if len(repo.pendingReconciliations) != 1 {
|
|
t.Fatalf("pending reconciliations = %#v", repo.pendingReconciliations)
|
|
}
|
|
repo.markSatisfiedErr = nil
|
|
if err := service.SweepOpenScrobbles(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if repo.historyExports[0].Status != historyExportStatusSatisfiedByScrobble || len(repo.pendingReconciliations) != 0 {
|
|
t.Fatalf("history exports=%#v pending=%#v", repo.historyExports, repo.pendingReconciliations)
|
|
}
|
|
if updates = repo.scrobbleUpdatesSnapshot(); len(updates) != 1 {
|
|
t.Fatalf("sweeper redispatched remote stop: %#v", updates)
|
|
}
|
|
}
|
|
|
|
func TestServiceOrderedScrobblerDispatchesInQueueOrder(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
repo.scrobbleConnections = []Connection{{
|
|
ID: "conn-1",
|
|
Provider: "simkl",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ScrobbleEnabled: true,
|
|
}}
|
|
provider := newOrderedScrobblerStub()
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
event := ScrobbleEvent{
|
|
PlaybackSessionID: "session-1",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
Kind: historyimport.KindMovie,
|
|
MediaItemID: testMovieMediaID,
|
|
PositionSeconds: 10,
|
|
DurationSeconds: 100,
|
|
}
|
|
|
|
if err := service.ScrobbleStart(context.Background(), event); err != nil {
|
|
t.Fatalf("ScrobbleStart: %v", err)
|
|
}
|
|
if action := <-provider.started; action != "start" {
|
|
t.Fatalf("first dispatch = %q, want start", action)
|
|
}
|
|
if err := service.ScrobblePause(context.Background(), event); err != nil {
|
|
t.Fatalf("ScrobblePause: %v", err)
|
|
}
|
|
if err := service.ScrobbleStop(context.Background(), event); err != nil {
|
|
t.Fatalf("ScrobbleStop: %v", err)
|
|
}
|
|
select {
|
|
case action := <-provider.started:
|
|
t.Fatalf("ordered dispatch advanced to %q before start completed", action)
|
|
case <-time.After(25 * time.Millisecond):
|
|
}
|
|
|
|
provider.release <- struct{}{}
|
|
if action := <-provider.started; action != "pause" {
|
|
t.Fatalf("second dispatch = %q, want pause", action)
|
|
}
|
|
provider.release <- struct{}{}
|
|
if action := <-provider.started; action != "stop" {
|
|
t.Fatalf("third dispatch = %q, want stop", action)
|
|
}
|
|
provider.release <- struct{}{}
|
|
calls := provider.waitCalls(t, 3)
|
|
if calls[0] != "start" || calls[1] != "pause" || calls[2] != "stop" {
|
|
t.Fatalf("calls = %+v, want start/pause/stop", calls)
|
|
}
|
|
}
|
|
|
|
func TestServiceScrobbleStopKeepsSessionOpenWhenProviderStopFails(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
repo.scrobbleConnections = []Connection{{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ScrobbleEnabled: true,
|
|
}}
|
|
provider := scrobblerStub{stopErr: errors.New("stop failed")}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
|
|
err := service.ScrobbleStop(context.Background(), ScrobbleEvent{
|
|
PlaybackSessionID: "playback-1",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
MediaItemID: testMovieMediaID,
|
|
HistoryID: "history-1",
|
|
PositionSeconds: 120,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ScrobbleStop: %v", err)
|
|
}
|
|
|
|
deadline := time.Now().Add(time.Second)
|
|
for {
|
|
updates := repo.scrobbleUpdatesSnapshot()
|
|
for _, update := range updates {
|
|
if update.lastError == "stop failed" {
|
|
for _, seen := range updates {
|
|
if seen.stopSentAt != nil {
|
|
t.Fatalf("stop_sent_at was set despite provider failure: %+v", updates)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("timed out waiting for failed stop update: %+v", updates)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestServiceConfirmedStopWaitsForProviderAndReopensFailedReplacement(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
repo.scrobbleConnections = []Connection{{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ScrobbleEnabled: true,
|
|
}}
|
|
provider := scrobblerStub{stopErr: errors.New("stop failed")}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
|
|
err := service.ScrobbleStopConfirmed(context.Background(), ScrobbleEvent{
|
|
PlaybackSessionID: "playback-1",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
MediaItemID: testMovieMediaID,
|
|
HistoryID: "history-1",
|
|
PositionSeconds: 120,
|
|
})
|
|
if err == nil || err.Error() != "stop failed" {
|
|
t.Fatalf("ScrobbleStopConfirmed error = %v, want stop failed", err)
|
|
}
|
|
if len(repo.reopenedScrobbles) != 1 {
|
|
t.Fatalf("reopened scrobbles = %+v, want one durable reopen", repo.reopenedScrobbles)
|
|
}
|
|
reopened := repo.reopenedScrobbles[0]
|
|
if reopened.positionSeconds != 120 || reopened.historyID != "history-1" {
|
|
t.Fatalf("reopened scrobble = %+v, want authoritative progress", reopened)
|
|
}
|
|
updates := repo.scrobbleUpdatesSnapshot()
|
|
for _, update := range updates {
|
|
if update.stopSentAt != nil {
|
|
t.Fatalf("failed confirmed stop marked session closed: %+v", updates)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServiceConfirmedStopRetriesImmediatelyAfterProviderFailure(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
repo.scrobbleConnections = []Connection{{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ScrobbleEnabled: true,
|
|
}}
|
|
var failures atomic.Int32
|
|
failures.Store(1)
|
|
provider := scrobblerStub{
|
|
stopEvents: make(chan ScrobbleEvent, 2),
|
|
stopFailures: &failures,
|
|
}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
event := ScrobbleEvent{
|
|
PlaybackSessionID: "session-1",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
MediaItemID: testMovieMediaID,
|
|
}
|
|
|
|
if err := service.ScrobbleStopConfirmed(context.Background(), event); err == nil {
|
|
t.Fatal("first ScrobbleStopConfirmed unexpectedly succeeded")
|
|
}
|
|
if err := service.ScrobbleStopConfirmed(context.Background(), event); err != nil {
|
|
t.Fatalf("retry ScrobbleStopConfirmed: %v", err)
|
|
}
|
|
if len(provider.stopEvents) != 2 {
|
|
t.Fatalf("provider stop attempts = %d, want immediate retry", len(provider.stopEvents))
|
|
}
|
|
}
|
|
|
|
func TestServiceConfirmedStopWaitsInProviderOrder(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
repo.scrobbleConnections = []Connection{{
|
|
ID: "conn-1",
|
|
Provider: "simkl",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ScrobbleEnabled: true,
|
|
}}
|
|
provider := newOrderedScrobblerStub()
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
event := ScrobbleEvent{
|
|
PlaybackSessionID: "session-1",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
MediaItemID: testMovieMediaID,
|
|
}
|
|
if err := service.ScrobbleStart(context.Background(), event); err != nil {
|
|
t.Fatalf("ScrobbleStart: %v", err)
|
|
}
|
|
if action := <-provider.started; action != "start" {
|
|
t.Fatalf("first dispatch = %q, want start", action)
|
|
}
|
|
|
|
result := make(chan error, 1)
|
|
go func() {
|
|
result <- service.ScrobbleStopConfirmed(context.Background(), event)
|
|
}()
|
|
select {
|
|
case err := <-result:
|
|
t.Fatalf("confirmed stop returned before queued start completed: %v", err)
|
|
case <-time.After(25 * time.Millisecond):
|
|
}
|
|
|
|
provider.release <- struct{}{}
|
|
if action := <-provider.started; action != "stop" {
|
|
t.Fatalf("second dispatch = %q, want stop", action)
|
|
}
|
|
select {
|
|
case err := <-result:
|
|
t.Fatalf("confirmed stop returned before provider completed: %v", err)
|
|
case <-time.After(25 * time.Millisecond):
|
|
}
|
|
provider.release <- struct{}{}
|
|
if err := <-result; err != nil {
|
|
t.Fatalf("ScrobbleStopConfirmed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceConfirmedStopDispatchesProvidersIndependently(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
repo.settings["watchsync.slow.client_id"] = "client-id"
|
|
repo.settings["watchsync.slow.client_secret"] = "client-secret"
|
|
repo.settings["watchsync.healthy.client_id"] = "client-id"
|
|
repo.settings["watchsync.healthy.client_secret"] = "client-secret"
|
|
repo.scrobbleConnections = []Connection{
|
|
{
|
|
ID: "conn-slow",
|
|
Provider: "slow",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ScrobbleEnabled: true,
|
|
},
|
|
{
|
|
ID: "conn-healthy",
|
|
Provider: "healthy",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ScrobbleEnabled: true,
|
|
},
|
|
}
|
|
slow := keyedScrobblerStub{
|
|
key: "slow",
|
|
scrobblerStub: scrobblerStub{
|
|
stopStarted: make(chan struct{}, 1),
|
|
stopRelease: make(chan struct{}),
|
|
},
|
|
}
|
|
healthy := keyedScrobblerStub{
|
|
key: "healthy",
|
|
scrobblerStub: scrobblerStub{
|
|
stopEvents: make(chan ScrobbleEvent, 1),
|
|
},
|
|
}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(slow); err != nil {
|
|
t.Fatalf("Register slow provider: %v", err)
|
|
}
|
|
if err := reg.Register(healthy); err != nil {
|
|
t.Fatalf("Register healthy provider: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
event := ScrobbleEvent{
|
|
PlaybackSessionID: "session-1",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
MediaItemID: testMovieMediaID,
|
|
}
|
|
|
|
result := make(chan error, 1)
|
|
go func() {
|
|
result <- service.ScrobbleStopConfirmed(context.Background(), event)
|
|
}()
|
|
select {
|
|
case <-slow.stopStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for slow provider")
|
|
}
|
|
select {
|
|
case <-healthy.stopEvents:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("healthy provider was starved by slow provider")
|
|
}
|
|
close(slow.stopRelease)
|
|
if err := <-result; err != nil {
|
|
t.Fatalf("ScrobbleStopConfirmed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceConfirmedStopWaitsBehindFallbackWithoutProviderOrdering(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
repo.scrobbleConnections = []Connection{{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ScrobbleEnabled: true,
|
|
}}
|
|
provider := scrobblerStub{
|
|
stopStarted: make(chan struct{}, 2),
|
|
stopRelease: make(chan struct{}),
|
|
}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
event := ScrobbleEvent{
|
|
PlaybackSessionID: "session-1",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
MediaItemID: testMovieMediaID,
|
|
}
|
|
|
|
if err := service.ScrobbleStop(context.Background(), event); err != nil {
|
|
t.Fatalf("fallback ScrobbleStop: %v", err)
|
|
}
|
|
select {
|
|
case <-provider.stopStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for fallback stop")
|
|
}
|
|
|
|
result := make(chan error, 1)
|
|
go func() {
|
|
result <- service.ScrobbleStopConfirmed(context.Background(), event)
|
|
}()
|
|
select {
|
|
case <-provider.stopStarted:
|
|
t.Fatal("confirmed stop passed the in-flight fallback")
|
|
case <-time.After(25 * time.Millisecond):
|
|
}
|
|
|
|
provider.stopRelease <- struct{}{}
|
|
select {
|
|
case <-provider.stopStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("confirmed stop did not follow the fallback")
|
|
}
|
|
provider.stopRelease <- struct{}{}
|
|
if err := <-result; err != nil {
|
|
t.Fatalf("ScrobbleStopConfirmed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceConfirmedStopDoesNotResendSuccessfulProvider(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
repo.scrobbleConnections = []Connection{{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ScrobbleEnabled: true,
|
|
}}
|
|
provider := scrobblerStub{stopEvents: make(chan ScrobbleEvent, 2)}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
event := ScrobbleEvent{
|
|
PlaybackSessionID: "session-1",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
MediaItemID: testMovieMediaID,
|
|
}
|
|
|
|
if err := service.ScrobbleStopConfirmed(context.Background(), event); err != nil {
|
|
t.Fatalf("first ScrobbleStopConfirmed: %v", err)
|
|
}
|
|
if err := service.ScrobbleStopConfirmed(context.Background(), event); err != nil {
|
|
t.Fatalf("retry ScrobbleStopConfirmed: %v", err)
|
|
}
|
|
select {
|
|
case <-provider.stopEvents:
|
|
default:
|
|
t.Fatal("successful confirmed stop was not dispatched")
|
|
}
|
|
select {
|
|
case duplicate := <-provider.stopEvents:
|
|
t.Fatalf("successful provider received duplicate confirmed stop: %+v", duplicate)
|
|
default:
|
|
}
|
|
if len(repo.reopenedScrobbles) != 1 {
|
|
t.Fatalf("confirmed stop preparations = %+v, want one", repo.reopenedScrobbles)
|
|
}
|
|
}
|
|
|
|
func TestServiceConfirmedStopSerializesConcurrentConfirmation(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
repo.scrobbleConnections = []Connection{{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ScrobbleEnabled: true,
|
|
}}
|
|
provider := scrobblerStub{
|
|
stopStarted: make(chan struct{}, 1),
|
|
stopRelease: make(chan struct{}),
|
|
}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
event := ScrobbleEvent{
|
|
PlaybackSessionID: "session-1",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
MediaItemID: testMovieMediaID,
|
|
}
|
|
|
|
firstResult := make(chan error, 1)
|
|
go func() {
|
|
firstResult <- service.ScrobbleStopConfirmed(context.Background(), event)
|
|
}()
|
|
select {
|
|
case <-provider.stopStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for first provider stop")
|
|
}
|
|
|
|
secondResult := make(chan error, 1)
|
|
go func() {
|
|
secondResult <- service.ScrobbleStopConfirmed(context.Background(), event)
|
|
}()
|
|
select {
|
|
case err := <-secondResult:
|
|
t.Fatalf("concurrent confirmation returned before the first completed: %v", err)
|
|
case <-time.After(25 * time.Millisecond):
|
|
}
|
|
close(provider.stopRelease)
|
|
if err := <-firstResult; err != nil {
|
|
t.Fatalf("first ScrobbleStopConfirmed: %v", err)
|
|
}
|
|
if err := <-secondResult; err != nil {
|
|
t.Fatalf("second ScrobbleStopConfirmed: %v", err)
|
|
}
|
|
select {
|
|
case <-provider.stopStarted:
|
|
t.Fatal("concurrent confirmation dispatched a duplicate provider stop")
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestServiceConfirmedStopCannotCompleteReclaimedLease(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
repo.scrobbleConnections = []Connection{{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ScrobbleEnabled: true,
|
|
}}
|
|
provider := scrobblerStub{
|
|
stopStarted: make(chan struct{}, 1),
|
|
stopRelease: make(chan struct{}),
|
|
}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
event := ScrobbleEvent{
|
|
PlaybackSessionID: "session-1",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
MediaItemID: testMovieMediaID,
|
|
}
|
|
|
|
result := make(chan error, 1)
|
|
go func() {
|
|
result <- service.ScrobbleStopConfirmed(context.Background(), event)
|
|
}()
|
|
select {
|
|
case <-provider.stopStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for provider stop")
|
|
}
|
|
|
|
key := event.PlaybackSessionID + "|conn-1"
|
|
repo.confirmingScrobbles[key] = time.Now().Add(time.Second)
|
|
close(provider.stopRelease)
|
|
if err := <-result; !errors.Is(err, errConfirmedStopClaimLost) {
|
|
t.Fatalf("stale ScrobbleStopConfirmed error = %v, want claim lost", err)
|
|
}
|
|
if repo.confirmedScrobbles[key] {
|
|
t.Fatal("stale provider worker marked the reclaimed stop confirmed")
|
|
}
|
|
}
|
|
|
|
func TestServiceConfirmedStopRejectsUnregisteredProvider(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
repo.scrobbleConnections = []Connection{{
|
|
ID: "conn-1",
|
|
Provider: "temporarily-unavailable",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ScrobbleEnabled: true,
|
|
}}
|
|
service := NewService(repo, NewRegistry())
|
|
|
|
err := service.ScrobbleStopConfirmed(context.Background(), ScrobbleEvent{
|
|
PlaybackSessionID: "session-1",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
MediaItemID: testMovieMediaID,
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "not registered") {
|
|
t.Fatalf("ScrobbleStopConfirmed error = %v, want unregistered provider", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceScrobbleRefreshesExpiredTokenBeforeDispatch(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
|
expiresAt := now.Add(-time.Minute)
|
|
refreshedExpiresAt := now.Add(time.Hour)
|
|
repo.scrobbleConnections = []Connection{{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
AccessToken: testOldAccessToken,
|
|
RefreshToken: testOldRefreshToken,
|
|
TokenExpiresAt: &expiresAt,
|
|
ScrobbleEnabled: true,
|
|
}}
|
|
provider := &scrobblerStub{
|
|
refreshTokens: TokenSet{
|
|
AccessToken: "new-access",
|
|
RefreshToken: "new-refresh",
|
|
TokenExpiresAt: &refreshedExpiresAt,
|
|
},
|
|
stopConns: make(chan Connection, 1),
|
|
}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
service.now = func() time.Time { return now }
|
|
|
|
err := service.ScrobbleStop(context.Background(), ScrobbleEvent{
|
|
PlaybackSessionID: "playback-1",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
MediaItemID: testMovieMediaID,
|
|
HistoryID: "history-1",
|
|
PositionSeconds: 120,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ScrobbleStop: %v", err)
|
|
}
|
|
if !provider.refreshed {
|
|
t.Fatal("provider was not asked to refresh the expired token")
|
|
}
|
|
updated := repo.connections[connectionKey("trakt", 7, "profile-1")]
|
|
if updated.AccessToken != "new-access" || updated.RefreshToken != "new-refresh" {
|
|
t.Fatalf("stored tokens = %q/%q, want refreshed tokens", updated.AccessToken, updated.RefreshToken)
|
|
}
|
|
|
|
select {
|
|
case conn := <-provider.stopConns:
|
|
if conn.AccessToken != "new-access" {
|
|
t.Fatalf("scrobble used access token %q, want refreshed token", conn.AccessToken)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for scrobble dispatch")
|
|
}
|
|
}
|
|
|
|
func TestServiceSweepOpenScrobblesRetriesProviderStop(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
repo.connections[connectionKey("trakt", 7, "profile-1")] = Connection{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
AccessToken: testAccessToken,
|
|
ScrobbleEnabled: true,
|
|
}
|
|
repo.scrobbleSessions = []ScrobbleSession{{
|
|
PlaybackSessionID: "playback-1",
|
|
ConnectionID: "conn-1",
|
|
MediaItemID: testMovieMediaID,
|
|
Kind: "movie",
|
|
TMDBID: "603",
|
|
HistoryID: "history-1",
|
|
LastProgress: 5400,
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
}}
|
|
provider := scrobblerStub{stopEvents: make(chan ScrobbleEvent, 1)}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
|
service := NewService(repo, reg)
|
|
service.now = func() time.Time { return now }
|
|
|
|
if err := service.SweepOpenScrobbles(context.Background()); err != nil {
|
|
t.Fatalf("SweepOpenScrobbles: %v", err)
|
|
}
|
|
|
|
select {
|
|
case event := <-provider.stopEvents:
|
|
if event.PlaybackSessionID != "playback-1" || event.UserID != 7 || event.ProfileID != "profile-1" {
|
|
t.Fatalf("stop event ownership = %+v, want playback/profile context", event)
|
|
}
|
|
if event.TMDBID != "603" || event.DurationSeconds != 7200 || !event.Completed {
|
|
t.Fatalf("stop event media fields = %+v, want persisted event metadata", event)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for provider stop retry")
|
|
}
|
|
foundClosed := false
|
|
updates := repo.scrobbleUpdatesSnapshot()
|
|
for _, update := range updates {
|
|
if update.action == "stop" && update.stopSentAt != nil {
|
|
foundClosed = true
|
|
}
|
|
}
|
|
if !foundClosed {
|
|
t.Fatalf("scrobble updates = %+v, want successful stop to mark session closed", updates)
|
|
}
|
|
}
|
|
|
|
func TestServiceSweepOpenScrobblesKeepsFailedStopOpen(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
repo.connections[connectionKey("trakt", 7, "profile-1")] = Connection{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
AccessToken: testAccessToken,
|
|
ScrobbleEnabled: true,
|
|
}
|
|
repo.scrobbleSessions = []ScrobbleSession{{
|
|
PlaybackSessionID: "playback-1",
|
|
ConnectionID: "conn-1",
|
|
MediaItemID: testMovieMediaID,
|
|
Kind: "movie",
|
|
TMDBID: "603",
|
|
HistoryID: "history-1",
|
|
LastProgress: 5400,
|
|
DurationSeconds: 7200,
|
|
}}
|
|
provider := scrobblerStub{stopErr: errors.New("provider offline")}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg)
|
|
|
|
if err := service.SweepOpenScrobbles(context.Background()); err != nil {
|
|
t.Fatalf("SweepOpenScrobbles: %v", err)
|
|
}
|
|
updates := repo.scrobbleUpdatesSnapshot()
|
|
for _, update := range updates {
|
|
if update.stopSentAt != nil {
|
|
t.Fatalf("stop_sent_at was set despite provider failure: %+v", updates)
|
|
}
|
|
if update.lastError == "provider offline" {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("scrobble updates = %+v, want provider error recorded", updates)
|
|
}
|
|
|
|
func TestAppendWarningSummarizesDuplicateReasons(t *testing.T) {
|
|
got := appendWarning("", []string{
|
|
"missing season or episode number",
|
|
"missing season or episode number",
|
|
"no tmdb_id match for \"92820\"",
|
|
})
|
|
want := "missing season or episode number (2 items); no tmdb_id match for \"92820\""
|
|
if got != want {
|
|
t.Fatalf("appendWarning() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
type completedHistoryListerStub struct {
|
|
rows []userstore.WatchHistoryEntry
|
|
queries []userstore.CompletedHistoryQuery
|
|
}
|
|
|
|
func (s *completedHistoryListerStub) ListCompletedHistory(_ context.Context, query userstore.CompletedHistoryQuery) ([]userstore.WatchHistoryEntry, error) {
|
|
s.queries = append(s.queries, query)
|
|
start := query.Offset
|
|
if start >= len(s.rows) {
|
|
return nil, nil
|
|
}
|
|
limit := query.Limit
|
|
if limit <= 0 {
|
|
limit = len(s.rows)
|
|
}
|
|
end := start + limit
|
|
if end > len(s.rows) {
|
|
end = len(s.rows)
|
|
}
|
|
return s.rows[start:end], nil
|
|
}
|
|
|
|
func TestHasVisibleCompletedHistoryAtOrAfterScopesTargetAndPaginates(t *testing.T) {
|
|
at := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
|
rows := make([]userstore.WatchHistoryEntry, 501)
|
|
for i := 0; i < 500; i++ {
|
|
rows[i] = userstore.WatchHistoryEntry{
|
|
ProfileID: "profile-1",
|
|
MediaItemID: testMovieMediaID,
|
|
WatchedAt: at.Add(-time.Duration(500-i) * time.Hour).Format(time.RFC3339),
|
|
Completed: true,
|
|
}
|
|
}
|
|
rows[500] = userstore.WatchHistoryEntry{
|
|
ProfileID: "profile-1",
|
|
MediaItemID: testMovieMediaID,
|
|
WatchedAt: at.Add(time.Minute).Format(time.RFC3339),
|
|
Completed: true,
|
|
}
|
|
store := &completedHistoryListerStub{rows: rows}
|
|
|
|
found, err := hasVisibleCompletedHistoryAtOrAfter(context.Background(), store, "profile-1", testMovieMediaID, at)
|
|
if err != nil {
|
|
t.Fatalf("hasVisibleCompletedHistoryAtOrAfter: %v", err)
|
|
}
|
|
if !found {
|
|
t.Fatal("found = false, want true")
|
|
}
|
|
if len(store.queries) != 2 {
|
|
t.Fatalf("query count = %d, want 2", len(store.queries))
|
|
}
|
|
for _, query := range store.queries {
|
|
if query.ProfileID != "profile-1" || len(query.MediaItemIDs) != 1 || query.MediaItemIDs[0] != testMovieMediaID {
|
|
t.Fatalf("query was not scoped to target media item: %+v", query)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListAllCompletedHistoryPaginatesUntilExhausted(t *testing.T) {
|
|
rows := make([]userstore.WatchHistoryEntry, 501)
|
|
for i := range rows {
|
|
rows[i] = userstore.WatchHistoryEntry{
|
|
ID: "history-" + strconv.Itoa(i),
|
|
ProfileID: "profile-1",
|
|
MediaItemID: "movie-" + strconv.Itoa(i),
|
|
Completed: true,
|
|
}
|
|
}
|
|
store := &completedHistoryListerStub{rows: rows}
|
|
|
|
got, err := listAllCompletedHistory(context.Background(), store, userstore.CompletedHistoryQuery{
|
|
ProfileID: "profile-1",
|
|
ExcludeSources: []userstore.WatchHistorySource{userstore.WatchHistorySourceTrakt},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("listAllCompletedHistory: %v", err)
|
|
}
|
|
if len(got) != len(rows) {
|
|
t.Fatalf("history len = %d, want %d", len(got), len(rows))
|
|
}
|
|
if len(store.queries) != 2 {
|
|
t.Fatalf("query count = %d, want 2", len(store.queries))
|
|
}
|
|
if store.queries[0].Limit != completedHistoryPageSize || store.queries[0].Offset != 0 {
|
|
t.Fatalf("first query = %+v, want first page", store.queries[0])
|
|
}
|
|
if store.queries[1].Limit != completedHistoryPageSize || store.queries[1].Offset != completedHistoryPageSize {
|
|
t.Fatalf("second query = %+v, want second page", store.queries[1])
|
|
}
|
|
for _, query := range store.queries {
|
|
if query.ProfileID != "profile-1" || len(query.ExcludeSources) != 1 ||
|
|
query.ExcludeSources[0] != userstore.WatchHistorySourceTrakt {
|
|
t.Fatalf("query did not preserve export filters: %+v", query)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHistorySourceForProviderDefaultsAndUsesProviderSource(t *testing.T) {
|
|
if got := historySourceForProvider(watchedExporterStub{source: userstore.WatchHistorySourceSimkl}); got != userstore.WatchHistorySourceSimkl {
|
|
t.Fatalf("historySourceForProvider(simkl) = %q, want simkl", got)
|
|
}
|
|
if got := historySourceForProvider(struct{}{}); got != userstore.WatchHistorySourceImport {
|
|
t.Fatalf("historySourceForProvider(no source) = %q, want import", got)
|
|
}
|
|
}
|
|
|
|
// rateLimitedImporterStub rate-limits watched import and records whether the
|
|
// progress flow was still attempted afterwards.
|
|
type rateLimitedImporterStub struct {
|
|
progressCalled *bool
|
|
fetchCalls *int
|
|
}
|
|
|
|
func (p rateLimitedImporterStub) Key() string { return "trakt" }
|
|
func (p rateLimitedImporterStub) DisplayName() string { return "Trakt" }
|
|
func (p rateLimitedImporterStub) Capabilities() Capabilities {
|
|
return Capabilities{ImportWatched: true, ImportProgress: true}
|
|
}
|
|
|
|
func (p rateLimitedImporterStub) FetchWatched(context.Context, ServerConfig, Connection) ([]RemoteWatch, error) {
|
|
if p.fetchCalls != nil {
|
|
*p.fetchCalls++
|
|
}
|
|
return nil, RateLimitedError{Provider: "trakt", RetryAfter: 30 * time.Minute}
|
|
}
|
|
|
|
func (p rateLimitedImporterStub) FetchProgress(context.Context, ServerConfig, Connection) ([]RemoteProgress, error) {
|
|
*p.progressCalled = true
|
|
return nil, nil
|
|
}
|
|
|
|
func (p rateLimitedImporterStub) HistorySource() userstore.WatchHistorySource {
|
|
return userstore.WatchHistorySourceTrakt
|
|
}
|
|
|
|
func TestServicePluginAPIKeyConnectRejectsEmptyReturnedCredential(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
registry := NewRegistry()
|
|
provider := emptyPluginAPIKeyProvider{}
|
|
if err := registry.Register(provider); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := NewService(repo, registry)
|
|
if _, err := service.ConnectAPIKey(context.Background(), 7, "profile-1", provider.Key(), "input-secret"); err == nil {
|
|
t.Fatal("ConnectAPIKey error = nil")
|
|
}
|
|
if len(repo.connections) != 0 {
|
|
t.Fatalf("connections = %#v", repo.connections)
|
|
}
|
|
}
|
|
|
|
func TestServicePluginAPIKeyReconnectClearsConnectionError(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
client := &fakeWatchSyncPluginClient{exchangeResponse: &pluginv1.WatchSyncCredentialResponse{
|
|
Credentials: &pluginv1.WatchSyncCredentials{AccessToken: "new-access", TokenType: testBearerTokenType},
|
|
Account: &pluginv1.WatchSyncAccount{ExternalSubject: "account-1", Username: testPluginUsername},
|
|
}}
|
|
provider := testPluginProvider(t, client)
|
|
registry := NewRegistry()
|
|
if err := registry.Register(provider); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conn := Connection{
|
|
ID: "conn-1",
|
|
Provider: provider.Key(),
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
LastError: testReconnectRequired,
|
|
}
|
|
repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)] = conn
|
|
|
|
updated, err := NewService(repo, registry).ConnectAPIKey(context.Background(), conn.UserID, conn.ProfileID, provider.Key(), "replacement-key")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if updated.AccessToken != "new-access" || updated.LastError != "" {
|
|
t.Fatalf("connection = %#v", updated)
|
|
}
|
|
}
|
|
|
|
func TestServiceSyncConnectionDefersOnRateLimit(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
defer db.Close()
|
|
if err := userdb.InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
|
|
repo := newServiceFakeRepo()
|
|
progressCalled := false
|
|
provider := rateLimitedImporterStub{progressCalled: &progressCalled}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
service := NewService(repo, reg).
|
|
WithMatcher(matchedMatcherStub{mediaItemID: testMovieMediaID}).
|
|
WithWatchState(noOpWatchState{}).
|
|
WithUserStoreProvider(staticStoreProvider{store: userdb.NewSQLiteUserStore(db)})
|
|
service.now = func() time.Time { return now }
|
|
conn := Connection{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
AccessToken: testAccessToken,
|
|
ImportWatchedEnabled: true,
|
|
ImportProgressEnabled: true,
|
|
}
|
|
repo.connections[connectionKey("trakt", 7, "profile-1")] = conn
|
|
|
|
err = service.SyncConnection(context.Background(), conn, "scheduled")
|
|
if err == nil {
|
|
t.Fatal("SyncConnection error = nil, want rate limit failure")
|
|
}
|
|
if progressCalled {
|
|
t.Fatal("progress import ran after the watched import was rate limited")
|
|
}
|
|
updated := repo.connections[connectionKey("trakt", 7, "profile-1")]
|
|
wantUntil := now.Add(30 * time.Minute)
|
|
if updated.RateLimitedUntil == nil || !updated.RateLimitedUntil.Equal(wantUntil) {
|
|
t.Fatalf("RateLimitedUntil = %v, want %v", updated.RateLimitedUntil, wantUntil)
|
|
}
|
|
if !strings.Contains(updated.LastError, "rate limit") {
|
|
t.Fatalf("LastError = %q, want rate limit message", updated.LastError)
|
|
}
|
|
|
|
// A manual sync during the deferral must be refused with the remaining wait.
|
|
_, err = service.RequestManualSync(context.Background(), 7, "profile-1", "trakt")
|
|
var cooldown SyncCooldownError
|
|
if !errors.As(err, &cooldown) {
|
|
t.Fatalf("RequestManualSync error = %v, want SyncCooldownError", err)
|
|
}
|
|
if cooldown.RetryAfterSeconds != 30*60 {
|
|
t.Fatalf("RetryAfterSeconds = %d, want %d", cooldown.RetryAfterSeconds, 30*60)
|
|
}
|
|
}
|
|
|
|
func TestServiceLocalWatchEventCommitsPartialResultAndDefersRateLimit(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
provider := watchedExporterStub{
|
|
exportErr: RateLimitedError{Provider: "trakt", RetryAfter: 72 * time.Hour},
|
|
exportResult: ExportResult{Sent: []string{"history-1"}},
|
|
}
|
|
registry := NewRegistry()
|
|
if err := registry.Register(provider); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)
|
|
service := NewService(repo, registry)
|
|
service.now = func() time.Time { return now }
|
|
conn := Connection{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ProviderAccountID: testProviderAccountID,
|
|
ExportWatchedEnabled: true,
|
|
}
|
|
repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)] = conn
|
|
|
|
if err := service.processLocalWatchEvent(context.Background(), LocalWatchEvent{
|
|
Kind: LocalWatchEventMarkedWatched,
|
|
UserID: conn.UserID,
|
|
ProfileID: conn.ProfileID,
|
|
Plays: []LocalPlay{{
|
|
HistoryID: "history-1",
|
|
MediaItemID: testMovieMediaID,
|
|
ProviderItemKey: testMovieProviderItemKey,
|
|
}},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(repo.historyExports) != 1 || repo.historyExports[0].Status != historyExportStatusSent {
|
|
t.Fatalf("history exports = %#v", repo.historyExports)
|
|
}
|
|
updated := repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)]
|
|
wantUntil := now.Add(24 * time.Hour)
|
|
if updated.RateLimitedUntil == nil || !updated.RateLimitedUntil.Equal(wantUntil) {
|
|
t.Fatalf("RateLimitedUntil = %v, want %v", updated.RateLimitedUntil, wantUntil)
|
|
}
|
|
}
|
|
|
|
func TestServicePluginTransportFailureLeavesExportPending(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
service := NewService(repo, NewRegistry())
|
|
conn := Connection{ID: "conn-1"}
|
|
err := service.exportLocalPlays(context.Background(), conn, ServerConfig{}, watchedExporterStub{
|
|
exportErr: retryableProviderError{message: "watch sync plugin is unavailable"},
|
|
}, []LocalPlay{{
|
|
HistoryID: "history-1",
|
|
MediaItemID: testMovieMediaID,
|
|
ProviderItemKey: testMovieProviderItemKey,
|
|
}})
|
|
if !isRetryableProviderError(err) {
|
|
t.Fatalf("error = %#v", err)
|
|
}
|
|
if len(repo.historyExports) != 1 || repo.historyExports[0].Status != historyExportStatusPending || repo.historyExports[0].AttemptCount != 0 {
|
|
t.Fatalf("history exports = %#v", repo.historyExports)
|
|
}
|
|
}
|
|
|
|
func TestServicePluginInvalidCredentialLeavesExportPendingAndRecordsConnectionError(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
client := &fakeWatchSyncPluginClient{applyResponse: &pluginv1.WatchSyncApplyEventsResponse{
|
|
Fault: &pluginv1.WatchSyncFault{
|
|
Code: pluginv1.WatchSyncFaultCode_WATCH_SYNC_FAULT_CODE_INVALID_CREDENTIAL,
|
|
SafeMessage: "credential revoked",
|
|
},
|
|
}}
|
|
provider := testPluginProvider(t, client)
|
|
registry := NewRegistry()
|
|
if err := registry.Register(provider); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := NewService(repo, registry)
|
|
conn := Connection{
|
|
ID: "conn-1",
|
|
Provider: provider.Key(),
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
AccessToken: testSecretValue,
|
|
ExportWatchedEnabled: true,
|
|
}
|
|
repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)] = conn
|
|
err := service.processLocalWatchEvent(context.Background(), LocalWatchEvent{
|
|
Kind: LocalWatchEventMarkedWatched,
|
|
UserID: conn.UserID,
|
|
ProfileID: conn.ProfileID,
|
|
Plays: []LocalPlay{{
|
|
HistoryID: "history-1",
|
|
MediaItemID: testMovieMediaID,
|
|
ProviderItemKey: testMovieProviderItemKey,
|
|
Kind: historyimport.KindMovie,
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(repo.historyExports) != 1 || repo.historyExports[0].Status != historyExportStatusPending || repo.historyExports[0].AttemptCount != 0 {
|
|
t.Fatalf("history exports = %#v", repo.historyExports)
|
|
}
|
|
updated := repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)]
|
|
if updated.LastError != "credential revoked" {
|
|
t.Fatalf("LastError = %q", updated.LastError)
|
|
}
|
|
}
|
|
|
|
func TestServiceExportLocalPlaysReturnsStatusPersistenceFailure(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
repo.markHistoryStatusErr = errors.New("persist failed")
|
|
service := NewService(repo, NewRegistry())
|
|
conn := Connection{ID: "conn-1"}
|
|
err := service.exportLocalPlays(context.Background(), conn, ServerConfig{}, watchedExporterStub{
|
|
exportErr: errors.New("provider offline"),
|
|
}, []LocalPlay{{
|
|
HistoryID: "history-1",
|
|
MediaItemID: testMovieMediaID,
|
|
ProviderItemKey: testMovieProviderItemKey,
|
|
}})
|
|
if err == nil {
|
|
t.Fatal("exportLocalPlays error = nil, want combined export and persistence failure")
|
|
}
|
|
if !strings.Contains(err.Error(), "provider offline") || !strings.Contains(err.Error(), "persist failed") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
if len(repo.historyExports) != 1 || repo.historyExports[0].Status != historyExportStatusPending || repo.historyExports[0].AttemptCount != 0 {
|
|
t.Fatalf("history exports = %#v", repo.historyExports)
|
|
}
|
|
}
|
|
|
|
func TestServiceFakeRepoMarkHistoryExportSatisfiedByScrobbleSkipsSent(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
repo.historyExports = []HistoryExport{{
|
|
ID: testHistoryExportID,
|
|
ConnectionID: "conn-1",
|
|
HistoryID: "history-1",
|
|
Status: historyExportStatusSent,
|
|
}}
|
|
if err := repo.MarkHistoryExportSatisfiedByScrobble(context.Background(), "conn-1", "history-1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if repo.historyExports[0].Status != historyExportStatusSent {
|
|
t.Fatalf("history exports = %#v", repo.historyExports)
|
|
}
|
|
}
|
|
|
|
func TestServiceFakeRepoPreservesNotFoundHistoryExport(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
repo.historyExports = []HistoryExport{{
|
|
ID: testHistoryExportID,
|
|
ConnectionID: "conn-1",
|
|
HistoryID: "history-1",
|
|
Status: historyExportStatusNotFound,
|
|
}}
|
|
if err := repo.UpsertHistoryExports(context.Background(), []HistoryExport{{
|
|
ConnectionID: "conn-1",
|
|
HistoryID: "history-1",
|
|
Status: historyExportStatusPending,
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repo.MarkHistoryExportStatus(context.Background(), testHistoryExportID, historyExportStatusFailed, "retry"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repo.MarkHistoryExportSatisfiedByScrobble(context.Background(), "conn-1", "history-1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if repo.historyExports[0].Status != historyExportStatusNotFound || repo.historyExports[0].AttemptCount != 0 {
|
|
t.Fatalf("history exports = %#v", repo.historyExports)
|
|
}
|
|
}
|
|
|
|
func TestServiceScrobbleRateLimitDefersConnection(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
now := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)
|
|
service := NewService(repo, NewRegistry())
|
|
service.now = func() time.Time { return now }
|
|
conn := Connection{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ProviderAccountID: testProviderAccountID,
|
|
}
|
|
repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)] = conn
|
|
err := service.dispatchScrobble(
|
|
context.Background(),
|
|
scrobblerStub{stopErr: RateLimitedError{Provider: conn.Provider, RetryAfter: 30 * time.Minute}},
|
|
ServerConfig{}, conn,
|
|
ScrobbleEvent{PlaybackSessionID: testPlaybackSessionID},
|
|
scrobbleActionStop,
|
|
nil,
|
|
)
|
|
if _, ok := AsRateLimited(err); !ok {
|
|
t.Fatalf("error = %#v", err)
|
|
}
|
|
updated := repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)]
|
|
wantUntil := now.Add(30 * time.Minute)
|
|
if updated.RateLimitedUntil == nil || !updated.RateLimitedUntil.Equal(wantUntil) {
|
|
t.Fatalf("RateLimitedUntil = %v, want %v", updated.RateLimitedUntil, wantUntil)
|
|
}
|
|
}
|
|
|
|
func TestServiceScrobbleInvalidCredentialRecordsConnectionError(t *testing.T) {
|
|
repo := newServiceFakeRepo()
|
|
service := NewService(repo, NewRegistry())
|
|
conn := Connection{
|
|
ID: "conn-1",
|
|
Provider: "plugin:15:probe",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
}
|
|
repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)] = conn
|
|
fault := watchSyncProviderFaultError{
|
|
code: pluginv1.WatchSyncFaultCode_WATCH_SYNC_FAULT_CODE_INVALID_CREDENTIAL,
|
|
message: testReconnectRequired,
|
|
}
|
|
err := service.dispatchScrobble(
|
|
context.Background(), scrobblerStub{stopErr: fault}, ServerConfig{}, conn,
|
|
ScrobbleEvent{PlaybackSessionID: testPlaybackSessionID}, scrobbleActionStop, nil,
|
|
)
|
|
if !isWatchSyncInvalidCredentialError(err) {
|
|
t.Fatalf("error = %#v", err)
|
|
}
|
|
updated := repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)]
|
|
if updated.LastError != fault.Error() {
|
|
t.Fatalf("LastError = %q, want %q", updated.LastError, fault.Error())
|
|
}
|
|
}
|
|
|
|
func TestServiceSyncConnectionLeavesExportsPendingOnRateLimit(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
defer db.Close()
|
|
if err := userdb.InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
if err := userdb.AddHistory(db, userstore.WatchHistoryEntry{
|
|
ID: "history-1",
|
|
ProfileID: "profile-1",
|
|
MediaItemID: testMovieMediaID,
|
|
WatchedAt: "2026-05-04T12:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourcePlayback,
|
|
Identity: userstore.WatchIdentity{
|
|
StableType: "movie",
|
|
ProviderIDs: map[string]string{"tmdb": "603"},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory: %v", err)
|
|
}
|
|
|
|
repo := newServiceFakeRepo()
|
|
provider := watchedExporterStub{exportErr: RateLimitedError{Provider: "trakt", RetryAfter: time.Hour}}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
service := NewService(repo, reg).WithUserStoreProvider(staticStoreProvider{
|
|
store: userdb.NewSQLiteUserStore(db),
|
|
})
|
|
conn := Connection{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
AccessToken: testAccessToken,
|
|
ExportWatchedEnabled: true,
|
|
}
|
|
repo.connections[connectionKey("trakt", 7, "profile-1")] = conn
|
|
|
|
if err := service.SyncConnection(context.Background(), conn, "scheduled"); err == nil {
|
|
t.Fatal("SyncConnection error = nil, want rate limit failure")
|
|
}
|
|
if len(repo.historyExports) != 1 || repo.historyExports[0].Status != historyExportStatusPending {
|
|
t.Fatalf("history exports = %+v, want one still-pending export", repo.historyExports)
|
|
}
|
|
updated := repo.connections[connectionKey("trakt", 7, "profile-1")]
|
|
if updated.RateLimitedUntil == nil {
|
|
t.Fatal("RateLimitedUntil not set after rate-limited export")
|
|
}
|
|
}
|
|
|
|
func TestSyncDueConnectionsSkipsSiblingsOfRateLimitedAccount(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
defer db.Close()
|
|
if err := userdb.InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
|
|
repo := newServiceFakeRepo()
|
|
fetchCalls := 0
|
|
progressCalled := false
|
|
provider := rateLimitedImporterStub{progressCalled: &progressCalled, fetchCalls: &fetchCalls}
|
|
reg := NewRegistry()
|
|
if err := reg.Register(provider); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
service := NewService(repo, reg).
|
|
WithMatcher(matchedMatcherStub{mediaItemID: testMovieMediaID}).
|
|
WithWatchState(noOpWatchState{}).
|
|
WithUserStoreProvider(staticStoreProvider{store: userdb.NewSQLiteUserStore(db)})
|
|
service.now = func() time.Time { return now }
|
|
|
|
// Two household profiles share one MDBList-style API key (same provider
|
|
// account); the second must not sync after the first exhausts the quota.
|
|
first := Connection{
|
|
ID: "conn-1",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-1",
|
|
ProviderAccountID: "acct-1",
|
|
AccessToken: testAccessToken,
|
|
ImportWatchedEnabled: true,
|
|
}
|
|
second := Connection{
|
|
ID: "conn-2",
|
|
Provider: "trakt",
|
|
UserID: 7,
|
|
ProfileID: "profile-2",
|
|
ProviderAccountID: "acct-1",
|
|
AccessToken: testAccessToken,
|
|
ImportWatchedEnabled: true,
|
|
}
|
|
repo.connections[connectionKey("trakt", 7, "profile-1")] = first
|
|
repo.connections[connectionKey("trakt", 7, "profile-2")] = second
|
|
repo.dueConnections = []Connection{first, second}
|
|
|
|
if err := service.SyncDueConnections(context.Background()); err != nil {
|
|
t.Fatalf("SyncDueConnections: %v", err)
|
|
}
|
|
if fetchCalls != 1 {
|
|
t.Fatalf("fetch calls = %d, want 1 (sibling connection must be skipped)", fetchCalls)
|
|
}
|
|
sibling := repo.connections[connectionKey("trakt", 7, "profile-2")]
|
|
wantUntil := now.Add(30 * time.Minute)
|
|
if sibling.RateLimitedUntil == nil || !sibling.RateLimitedUntil.Equal(wantUntil) {
|
|
t.Fatalf("sibling RateLimitedUntil = %v, want %v", sibling.RateLimitedUntil, wantUntil)
|
|
}
|
|
}
|
|
|
|
type favoriteBatchProviderStub struct {
|
|
batch FavoriteImportBatch
|
|
}
|
|
|
|
func (favoriteBatchProviderStub) Key() string { return "plugin:4:list" }
|
|
func (favoriteBatchProviderStub) DisplayName() string { return "List" }
|
|
func (favoriteBatchProviderStub) Capabilities() Capabilities {
|
|
return Capabilities{ImportFavorites: true}
|
|
}
|
|
func (p favoriteBatchProviderStub) FetchFavorites(context.Context, ServerConfig, Connection) ([]RemoteFavorite, error) {
|
|
return p.batch.Rows, nil
|
|
}
|
|
func (p favoriteBatchProviderStub) FetchFavoritesBatch(context.Context, ServerConfig, Connection) (FavoriteImportBatch, error) {
|
|
return p.batch, nil
|
|
}
|
|
|
|
func TestServiceAppliesIncrementalFavoriteTombstone(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
if err := userdb.InitSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store := userdb.NewSQLiteUserStore(db)
|
|
ctx := context.Background()
|
|
if _, err := store.AddFavoriteAt(ctx, "profile-1", testMovieMediaID, time.Now().UTC()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repo := newServiceFakeRepo()
|
|
conn := Connection{
|
|
ID: "conn-1", Provider: "plugin:4:list", UserID: 7, ProfileID: "profile-1",
|
|
ImportFavoritesEnabled: true, SyncFavoriteRemovalsEnabled: true,
|
|
}
|
|
repo.listItemStates = []ListItemState{{
|
|
ConnectionID: conn.ID, ListKind: ListKindFavorites, MediaItemID: testMovieMediaID,
|
|
ProviderItemKey: testMovieProviderItemKey, RemotePresent: true, LocalPresent: true,
|
|
}}
|
|
service := NewService(repo, NewRegistry()).
|
|
WithMatcher(matchedMatcherStub{mediaItemID: testMovieMediaID}).
|
|
WithUserStoreProvider(staticStoreProvider{store: store})
|
|
provider := favoriteBatchProviderStub{batch: FavoriteImportBatch{
|
|
Rows: []RemoteFavorite{{ProviderItemKey: testMovieProviderItemKey, Removed: true}},
|
|
UpdatedCursors: map[string]string{pluginFavoritesCursorKey: "cursor-2"},
|
|
Incremental: true,
|
|
}}
|
|
result, err := service.importList(ctx, conn, ServerConfig{}, provider, service.favoritesBinding())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Removed != 1 {
|
|
t.Fatalf("result = %#v", result)
|
|
}
|
|
favorites, err := store.ListFavorites(ctx, conn.ProfileID, 10, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(favorites) != 0 || repo.listItemStates[0].RemotePresent || repo.listItemStates[0].LocalPresent {
|
|
t.Fatalf("favorites=%#v state=%#v", favorites, repo.listItemStates[0])
|
|
}
|
|
updated := repo.connections[connectionKey(conn.Provider, conn.UserID, conn.ProfileID)]
|
|
if updated.SyncCursors[pluginFavoritesCursorKey] != "cursor-2" {
|
|
t.Fatalf("connection cursors = %#v", updated.SyncCursors)
|
|
}
|
|
}
|
|
|
|
func TestServiceIncrementalFavoriteAbsenceIsNotRemoval(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
if err := userdb.InitSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store := userdb.NewSQLiteUserStore(db)
|
|
ctx := context.Background()
|
|
if _, err := store.AddFavoriteAt(ctx, "profile-1", testMovieMediaID, time.Now().UTC()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repo := newServiceFakeRepo()
|
|
conn := Connection{
|
|
ID: "conn-1", Provider: "plugin:4:list", UserID: 7, ProfileID: "profile-1",
|
|
ImportFavoritesEnabled: true, SyncFavoriteRemovalsEnabled: true,
|
|
}
|
|
repo.listItemStates = []ListItemState{{
|
|
ConnectionID: conn.ID, ListKind: ListKindFavorites, MediaItemID: testMovieMediaID,
|
|
ProviderItemKey: testMovieProviderItemKey, RemotePresent: true, LocalPresent: true,
|
|
}}
|
|
service := NewService(repo, NewRegistry()).
|
|
WithMatcher(matchedMatcherStub{mediaItemID: testMovieMediaID}).
|
|
WithUserStoreProvider(staticStoreProvider{store: store})
|
|
provider := favoriteBatchProviderStub{batch: FavoriteImportBatch{Incremental: true}}
|
|
result, err := service.importList(ctx, conn, ServerConfig{}, provider, service.favoritesBinding())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
favorites, err := store.ListFavorites(ctx, conn.ProfileID, 10, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Removed != 0 || len(favorites) != 1 || !repo.listItemStates[0].RemotePresent || !repo.listItemStates[0].LocalPresent {
|
|
t.Fatalf("result=%#v favorites=%#v state=%#v", result, favorites, repo.listItemStates[0])
|
|
}
|
|
}
|