* Refine playback session handling and API responses * fix(watchstate): harden completed-history visibility
679 lines
20 KiB
Go
679 lines
20 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 TestManualMarkWatchedPreservesVisibleWatchedAt(t *testing.T) {
|
|
store, db := newTestUserStore(t)
|
|
defer db.Close()
|
|
|
|
watchedAt := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
|
service := NewService(testStoreProvider{store: store})
|
|
err := service.RecordManualMarkWatched(
|
|
context.Background(),
|
|
1,
|
|
"profile-1",
|
|
[]LeafWatchTarget{{MediaItemID: "movie-1", DurationSeconds: 7200}},
|
|
watchedAt,
|
|
)
|
|
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].WatchedAt != "2026-04-25T12:00:00Z" {
|
|
t.Fatalf("history watched_at = %q, want caller watchedAt", history[0].WatchedAt)
|
|
}
|
|
}
|
|
|
|
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 TestManualMarkUnwatchedSuppressesImportedHistoryButReturnsManualHistory(t *testing.T) {
|
|
store, db := newTestUserStore(t)
|
|
defer db.Close()
|
|
|
|
if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-1", Name: "Profile"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
if err := store.SetProgressAt(
|
|
context.Background(),
|
|
"profile-1",
|
|
"movie-1",
|
|
0,
|
|
7200,
|
|
true,
|
|
time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC),
|
|
); err != nil {
|
|
t.Fatalf("SetProgressAt: %v", err)
|
|
}
|
|
if err := store.AddHistory(context.Background(), userstore.WatchHistoryEntry{
|
|
ID: "trakt-history-1",
|
|
ProfileID: "profile-1",
|
|
MediaItemID: "movie-1",
|
|
WatchedAt: "2026-05-04T12:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourceTrakt,
|
|
Identity: userstore.WatchIdentity{
|
|
StableType: "movie",
|
|
ProviderIDs: map[string]string{"tmdb": "603"},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory: %v", err)
|
|
}
|
|
if err := store.AddHistory(context.Background(), userstore.WatchHistoryEntry{
|
|
ID: "simkl-history-1",
|
|
ProfileID: "profile-1",
|
|
MediaItemID: "movie-1",
|
|
WatchedAt: "2026-05-04T13:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourceSimkl,
|
|
Identity: userstore.WatchIdentity{
|
|
StableType: "movie",
|
|
ProviderIDs: map[string]string{"tmdb": "603"},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory: %v", err)
|
|
}
|
|
if err := store.AddHistory(context.Background(), userstore.WatchHistoryEntry{
|
|
ID: "manual-history-1",
|
|
ProfileID: "profile-1",
|
|
MediaItemID: "movie-1",
|
|
WatchedAt: "2026-05-04T14:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourceManual,
|
|
Identity: userstore.WatchIdentity{
|
|
StableType: "movie",
|
|
ProviderIDs: map[string]string{"tmdb": "603"},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory: %v", err)
|
|
}
|
|
|
|
service := NewService(testStoreProvider{store: store})
|
|
result, err := service.RecordManualMarkUnwatchedWithResult(context.Background(), 1, "profile-1", []string{"movie-1"})
|
|
if err != nil {
|
|
t.Fatalf("RecordManualMarkUnwatchedWithResult: %v", err)
|
|
}
|
|
if len(result.Entries) != 1 || result.Entries[0].Source != userstore.WatchHistorySourceManual {
|
|
t.Fatalf("unwatch result entries = %+v, want only manual history for outbound sync", result.Entries)
|
|
}
|
|
|
|
progress, err := store.GetProgress(context.Background(), "profile-1", "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress: %v", err)
|
|
}
|
|
if progress != nil {
|
|
t.Fatalf("progress after unwatch = %+v, want nil", progress)
|
|
}
|
|
completedItems, err := store.ListCompletedHistoryItems(context.Background(), userstore.CompletedHistoryItemQuery{
|
|
ProfileID: "profile-1",
|
|
MediaItemIDs: []string{"movie-1"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListCompletedHistoryItems: %v", err)
|
|
}
|
|
if len(completedItems) != 0 {
|
|
t.Fatalf("completed items after unwatch = %v, want empty", completedItems)
|
|
}
|
|
}
|
|
|
|
func TestManualMarkUnwatchedReturnsOneOutboundEntryPerTarget(t *testing.T) {
|
|
store, db := newTestUserStore(t)
|
|
defer db.Close()
|
|
createWatchstateProfile(t, store)
|
|
|
|
for _, entry := range []userstore.WatchHistoryEntry{
|
|
{
|
|
ID: "manual-history-older",
|
|
ProfileID: "profile-1",
|
|
MediaItemID: "movie-1",
|
|
WatchedAt: "2026-05-04T12:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourceManual,
|
|
Identity: userstore.WatchIdentity{
|
|
StableType: "movie",
|
|
ProviderIDs: map[string]string{"tmdb": "603"},
|
|
},
|
|
},
|
|
{
|
|
ID: "manual-history-newer",
|
|
ProfileID: "profile-1",
|
|
MediaItemID: "movie-1",
|
|
WatchedAt: "2026-05-04T13:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourceManual,
|
|
Identity: userstore.WatchIdentity{
|
|
StableType: "movie",
|
|
ProviderIDs: map[string]string{"tmdb": "603"},
|
|
},
|
|
},
|
|
} {
|
|
if err := store.AddHistory(context.Background(), entry); err != nil {
|
|
t.Fatalf("AddHistory(%s): %v", entry.ID, err)
|
|
}
|
|
}
|
|
|
|
service := NewService(testStoreProvider{store: store})
|
|
result, err := service.RecordManualMarkUnwatchedWithResult(context.Background(), 1, "profile-1", []string{"movie-1"})
|
|
if err != nil {
|
|
t.Fatalf("RecordManualMarkUnwatchedWithResult: %v", err)
|
|
}
|
|
if len(result.Entries) != 1 {
|
|
t.Fatalf("unwatch result entries = %+v, want one representative entry", result.Entries)
|
|
}
|
|
if result.Entries[0].ID != "manual-history-newer" {
|
|
t.Fatalf("representative history id = %q, want newest manual history", result.Entries[0].ID)
|
|
}
|
|
}
|
|
|
|
func TestManualMarkWatchedAfterHiddenWatermarkIsVisible(t *testing.T) {
|
|
store, db := newTestUserStore(t)
|
|
defer db.Close()
|
|
createWatchstateProfile(t, store)
|
|
|
|
hiddenBefore := time.Now().UTC().Add(time.Second).Format(time.RFC3339)
|
|
if _, err := db.Exec(`
|
|
INSERT INTO hidden_history_items (profile_id, media_item_id, hidden_before, updated_at)
|
|
VALUES (?, ?, ?, ?)`,
|
|
"profile-1",
|
|
"movie-1",
|
|
hiddenBefore,
|
|
hiddenBefore,
|
|
); err != nil {
|
|
t.Fatalf("seed hidden watermark: %v", err)
|
|
}
|
|
|
|
service := NewService(testStoreProvider{store: store})
|
|
result, err := service.RecordManualMarkWatchedWithResult(
|
|
context.Background(),
|
|
1,
|
|
"profile-1",
|
|
[]LeafWatchTarget{{MediaItemID: "movie-1", DurationSeconds: 7200}},
|
|
time.Now().UTC(),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("RecordManualMarkWatchedWithResult: %v", err)
|
|
}
|
|
if len(result.Entries) != 1 {
|
|
t.Fatalf("result entries = %+v, want one history entry", result.Entries)
|
|
}
|
|
if result.Entries[0].WatchedAt <= hiddenBefore {
|
|
t.Fatalf("history watched_at = %q, want after hidden_before %q", result.Entries[0].WatchedAt, hiddenBefore)
|
|
}
|
|
|
|
progress, err := store.GetProgress(context.Background(), "profile-1", "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress: %v", err)
|
|
}
|
|
if progress == nil || !progress.Completed {
|
|
t.Fatalf("progress = %+v, want visible completed progress", progress)
|
|
}
|
|
history, err := store.ListHistory(context.Background(), "profile-1", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListHistory: %v", err)
|
|
}
|
|
if len(history) != 1 || history[0].WatchedAt <= hiddenBefore {
|
|
t.Fatalf("history = %+v, want visible history after hidden watermark %q", history, hiddenBefore)
|
|
}
|
|
}
|
|
|
|
func TestImportedWatchIfNewerDoesNotOverwriteNewerResume(t *testing.T) {
|
|
store, db := newTestUserStore(t)
|
|
defer db.Close()
|
|
createWatchstateProfile(t, store)
|
|
if err := store.SetProgressAt(
|
|
context.Background(),
|
|
"profile-1",
|
|
"movie-1",
|
|
1200,
|
|
7200,
|
|
false,
|
|
time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC),
|
|
); err != nil {
|
|
t.Fatalf("SetProgressAt: %v", err)
|
|
}
|
|
|
|
watchedAt := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
|
service := NewService(testStoreProvider{store: store})
|
|
created, err := service.RecordImportedWatchIfNewerWithSource(
|
|
context.Background(),
|
|
1,
|
|
"profile-1",
|
|
"movie-1",
|
|
7200,
|
|
0,
|
|
true,
|
|
watchedAt,
|
|
&watchedAt,
|
|
userstore.WatchHistorySourceTrakt,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("RecordImportedWatchIfNewerWithSource: %v", err)
|
|
}
|
|
if !created {
|
|
t.Fatal("created = false, want imported history row recorded")
|
|
}
|
|
progress, err := store.GetProgress(context.Background(), "profile-1", "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress: %v", err)
|
|
}
|
|
if progress == nil || progress.Completed || progress.PositionSeconds != 1200 {
|
|
t.Fatalf("progress after older import = %+v, want newer resume preserved", progress)
|
|
}
|
|
}
|
|
|
|
func TestImportedWatchIfNewerCompletesOlderResume(t *testing.T) {
|
|
store, db := newTestUserStore(t)
|
|
defer db.Close()
|
|
createWatchstateProfile(t, store)
|
|
if err := store.SetProgressAt(
|
|
context.Background(),
|
|
"profile-1",
|
|
"movie-1",
|
|
1200,
|
|
7200,
|
|
false,
|
|
time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC),
|
|
); err != nil {
|
|
t.Fatalf("SetProgressAt: %v", err)
|
|
}
|
|
|
|
watchedAt := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC)
|
|
service := NewService(testStoreProvider{store: store})
|
|
created, err := service.RecordImportedWatchIfNewerWithSource(
|
|
context.Background(),
|
|
1,
|
|
"profile-1",
|
|
"movie-1",
|
|
7200,
|
|
0,
|
|
true,
|
|
watchedAt,
|
|
&watchedAt,
|
|
userstore.WatchHistorySourceSimkl,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("RecordImportedWatchIfNewerWithSource: %v", err)
|
|
}
|
|
if !created {
|
|
t.Fatal("created = false, want imported history row recorded")
|
|
}
|
|
progress, err := store.GetProgress(context.Background(), "profile-1", "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress: %v", err)
|
|
}
|
|
if progress == nil || !progress.Completed || progress.PositionSeconds != 0 {
|
|
t.Fatalf("progress after newer import = %+v, want completed projection", progress)
|
|
}
|
|
}
|
|
|
|
func TestImportedWatchIfNewerSuppressesHiddenOlderWatch(t *testing.T) {
|
|
store, db := newTestUserStore(t)
|
|
defer db.Close()
|
|
createWatchstateProfile(t, store)
|
|
|
|
hiddenBefore := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC)
|
|
if err := store.RemoveHistoryItems(context.Background(), "profile-1", []string{"movie-1"}, hiddenBefore); err != nil {
|
|
t.Fatalf("RemoveHistoryItems: %v", err)
|
|
}
|
|
watchedAt := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
|
service := NewService(testStoreProvider{store: store})
|
|
created, err := service.RecordImportedWatchIfNewerWithSource(
|
|
context.Background(),
|
|
1,
|
|
"profile-1",
|
|
"movie-1",
|
|
7200,
|
|
0,
|
|
true,
|
|
watchedAt,
|
|
&watchedAt,
|
|
userstore.WatchHistorySourceTrakt,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("RecordImportedWatchIfNewerWithSource: %v", err)
|
|
}
|
|
if created {
|
|
t.Fatal("created = true, want hidden imported history skipped")
|
|
}
|
|
progress, err := store.GetProgress(context.Background(), "profile-1", "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress: %v", err)
|
|
}
|
|
if progress != nil {
|
|
t.Fatalf("progress after hidden import = %+v, want nil", progress)
|
|
}
|
|
completedItems, err := store.ListCompletedHistoryItems(context.Background(), userstore.CompletedHistoryItemQuery{
|
|
ProfileID: "profile-1",
|
|
MediaItemIDs: []string{"movie-1"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListCompletedHistoryItems: %v", err)
|
|
}
|
|
if len(completedItems) != 0 {
|
|
t.Fatalf("completed items = %v, want hidden import skipped", completedItems)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func createWatchstateProfile(t *testing.T, store userstore.UserStore) {
|
|
t.Helper()
|
|
if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-1", Name: "Profile"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
}
|