Files
silo-server/internal/watchstate/service_test.go
T

311 lines
8.8 KiB
Go

package watchstate
import (
"context"
"database/sql"
"errors"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userdb"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type testStoreProvider struct {
store userstore.UserStore
}
func (p testStoreProvider) ForUser(context.Context, int) (userstore.UserStore, error) {
return p.store, nil
}
func (p testStoreProvider) Close() error {
return nil
}
type testItemRepo struct {
items map[string]*models.MediaItem
}
func (r testItemRepo) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) {
item, ok := r.items[contentID]
if !ok {
return nil, catalog.ErrItemNotFound
}
return item, nil
}
type testEpisodeRepo struct {
episodes map[string]*models.Episode
byKey map[string]*models.Episode
}
func (r testEpisodeRepo) GetByID(_ context.Context, contentID string) (*models.Episode, error) {
episode, ok := r.episodes[contentID]
if !ok {
return nil, catalog.ErrEpisodeNotFound
}
return episode, nil
}
func (r testEpisodeRepo) GetBySeriesAndNumber(_ context.Context, seriesID string, season, episode int) (*models.Episode, error) {
got, ok := r.byKey[seriesID]
if !ok || got.SeasonNumber != season || got.EpisodeNumber != episode {
return nil, catalog.ErrEpisodeNotFound
}
return got, nil
}
type testProviderIDRepo struct {
ids map[string][]*models.MediaItemProviderID
err error
}
func (r testProviderIDRepo) GetByContentID(_ context.Context, contentID string) ([]*models.MediaItemProviderID, error) {
if r.err != nil {
return nil, r.err
}
return r.ids[contentID], nil
}
func (r testProviderIDRepo) FindContentIDByProviderIDs(_ context.Context, providerIDs map[string]string, itemType, _ string) (string, error) {
for contentID, rows := range r.ids {
for _, row := range rows {
if row.ItemType != itemType {
continue
}
if providerIDs[row.Provider] == row.ProviderID {
return contentID, nil
}
}
}
return "", nil
}
func TestRecordPlaybackStopAddsMovieIdentity(t *testing.T) {
store, db := newTestUserStore(t)
defer db.Close()
service := NewService(testStoreProvider{store: store}).WithStableIdentityResolver(NewStableIdentityResolver(
testItemRepo{items: map[string]*models.MediaItem{
"movie-1": {ContentID: "movie-1", Type: "movie"},
}},
testEpisodeRepo{},
testProviderIDRepo{ids: map[string][]*models.MediaItemProviderID{
"movie-1": {
{ContentID: "movie-1", ItemType: "movie", Provider: "tmdb", ProviderID: "603"},
},
}},
))
result, err := service.RecordPlaybackStop(
context.Background(),
1,
"profile-1",
"movie-1",
7200,
7200,
time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC),
userstore.VersionHints{},
userstore.ProgressThresholds{},
)
if err != nil {
t.Fatalf("RecordPlaybackStop: %v", err)
}
if !result.Completed || result.HistoryID == "" {
t.Fatalf("RecordPlaybackStop result = %+v", result)
}
history, err := store.ListHistory(context.Background(), "profile-1", 10, 0)
if err != nil {
t.Fatalf("ListHistory: %v", err)
}
if len(history) != 1 {
t.Fatalf("history len = %d, want 1", len(history))
}
if history[0].Identity.StableType != "movie" {
t.Fatalf("Identity.StableType = %q, want movie", history[0].Identity.StableType)
}
if got := history[0].Identity.ProviderIDs["tmdb"]; got != "603" {
t.Fatalf("Identity.ProviderIDs[tmdb] = %q, want 603", got)
}
}
func TestManualMarkWatchedAddsEpisodeIdentity(t *testing.T) {
store, db := newTestUserStore(t)
defer db.Close()
episode := &models.Episode{
ContentID: "episode-1",
SeriesID: "series-1",
SeasonNumber: 2,
EpisodeNumber: 7,
}
service := NewService(testStoreProvider{store: store}).WithStableIdentityResolver(NewStableIdentityResolver(
testItemRepo{},
testEpisodeRepo{episodes: map[string]*models.Episode{"episode-1": episode}},
testProviderIDRepo{ids: map[string][]*models.MediaItemProviderID{
"series-1": {
{ContentID: "series-1", ItemType: "series", Provider: "tvdb", ProviderID: "765"},
},
}},
))
err := service.RecordManualMarkWatched(
context.Background(),
1,
"profile-1",
[]LeafWatchTarget{{MediaItemID: "episode-1", DurationSeconds: 1800}},
time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC),
)
if err != nil {
t.Fatalf("RecordManualMarkWatched: %v", err)
}
history, err := store.ListHistory(context.Background(), "profile-1", 10, 0)
if err != nil {
t.Fatalf("ListHistory: %v", err)
}
if len(history) != 1 {
t.Fatalf("history len = %d, want 1", len(history))
}
if history[0].Identity.StableType != "episode" {
t.Fatalf("Identity.StableType = %q, want episode", history[0].Identity.StableType)
}
if len(history[0].Identity.ProviderIDs) != 0 {
t.Fatalf("Identity.ProviderIDs = %#v, want empty", history[0].Identity.ProviderIDs)
}
if got := history[0].Identity.SeriesProviderIDs["tvdb"]; got != "765" {
t.Fatalf("Identity.SeriesProviderIDs[tvdb] = %q, want 765", got)
}
if history[0].Identity.Season == nil || *history[0].Identity.Season != 2 {
t.Fatalf("Identity.Season = %v, want 2", history[0].Identity.Season)
}
if history[0].Identity.Episode == nil || *history[0].Identity.Episode != 7 {
t.Fatalf("Identity.Episode = %v, want 7", history[0].Identity.Episode)
}
}
func TestIdentityLookupFailureDoesNotBlockHistory(t *testing.T) {
store, db := newTestUserStore(t)
defer db.Close()
service := NewService(testStoreProvider{store: store}).WithStableIdentityResolver(NewStableIdentityResolver(
testItemRepo{items: map[string]*models.MediaItem{
"movie-1": {ContentID: "movie-1", Type: "movie"},
}},
testEpisodeRepo{},
testProviderIDRepo{err: errors.New("catalog unavailable")},
))
err := service.RecordManualMarkWatched(
context.Background(),
1,
"profile-1",
[]LeafWatchTarget{{MediaItemID: "movie-1", DurationSeconds: 7200}},
time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC),
)
if err != nil {
t.Fatalf("RecordManualMarkWatched: %v", err)
}
history, err := store.ListHistory(context.Background(), "profile-1", 10, 0)
if err != nil {
t.Fatalf("ListHistory: %v", err)
}
if len(history) != 1 {
t.Fatalf("history len = %d, want 1", len(history))
}
if history[0].Identity.StableType != "" ||
len(history[0].Identity.ProviderIDs) != 0 ||
len(history[0].Identity.SeriesProviderIDs) != 0 {
t.Fatalf("identity = %+v, want empty", history[0].Identity)
}
}
func TestStableIdentityResolverWithoutItemRepoDoesNotAssumeMovie(t *testing.T) {
resolver := NewStableIdentityResolver(
nil,
testEpisodeRepo{},
testProviderIDRepo{ids: map[string][]*models.MediaItemProviderID{
"unknown-1": {
{ContentID: "unknown-1", ItemType: "movie", Provider: "tmdb", ProviderID: "603"},
},
}},
)
identity := resolver.ResolveHistoryIdentity(context.Background(), "unknown-1")
if identity.StableType != "" ||
len(identity.ProviderIDs) != 0 ||
len(identity.SeriesProviderIDs) != 0 {
t.Fatalf("identity = %+v, want empty", identity)
}
}
func TestStableIdentityResolverResolvesEpisodeContentID(t *testing.T) {
episode := &models.Episode{
ContentID: "episode-1",
SeriesID: "series-1",
SeasonNumber: 2,
EpisodeNumber: 7,
}
resolver := NewStableIdentityResolver(
testItemRepo{},
testEpisodeRepo{byKey: map[string]*models.Episode{"series-1": episode}},
testProviderIDRepo{ids: map[string][]*models.MediaItemProviderID{
"series-1": {
{ContentID: "series-1", ItemType: "series", Provider: "tmdb", ProviderID: "123"},
},
}},
)
contentID, err := resolver.ResolveEpisodeContentID(context.Background(), map[string]string{"tmdb": "123"}, 2, 7)
if err != nil {
t.Fatalf("ResolveEpisodeContentID: %v", err)
}
if contentID != "episode-1" {
t.Fatalf("contentID = %q, want episode-1", contentID)
}
}
func TestStableIdentityResolverResolvesSeasonZeroSpecial(t *testing.T) {
episode := &models.Episode{
ContentID: "special-1",
SeriesID: "series-1",
SeasonNumber: 0,
EpisodeNumber: 1,
}
resolver := NewStableIdentityResolver(
testItemRepo{},
testEpisodeRepo{byKey: map[string]*models.Episode{"series-1": episode}},
testProviderIDRepo{ids: map[string][]*models.MediaItemProviderID{
"series-1": {
{ContentID: "series-1", ItemType: "series", Provider: "tmdb", ProviderID: "123"},
},
}},
)
contentID, err := resolver.ResolveEpisodeContentID(context.Background(), map[string]string{"tmdb": "123"}, 0, 1)
if err != nil {
t.Fatalf("ResolveEpisodeContentID: %v", err)
}
if contentID != "special-1" {
t.Fatalf("contentID = %q, want special-1", contentID)
}
}
func newTestUserStore(t *testing.T) (userstore.UserStore, *sql.DB) {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := userdb.InitSchema(db); err != nil {
db.Close()
t.Fatalf("InitSchema: %v", err)
}
return userdb.NewSQLiteUserStore(db), db
}