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