package recommendations import ( "context" "slices" "testing" "time" "github.com/Silo-Server/silo-server/internal/userstore" ) type fakeSignalRepo struct { canonical map[string]string fallbackWatched map[string]struct{} fallbackProgress []WatchProgressRow fallbackRecentCompleted []string fallbackRewatches []RewatchCount } func (r *fakeSignalRepo) GetWatchedItemIDSet(context.Context, int, string) (map[string]struct{}, error) { return r.fallbackWatched, nil } func (r *fakeSignalRepo) GetWatchProgressForUser(context.Context, int, string) ([]WatchProgressRow, error) { return r.fallbackProgress, nil } func (r *fakeSignalRepo) GetRecentCompletedItemIDs(context.Context, int, string, int) ([]string, error) { return r.fallbackRecentCompleted, nil } func (r *fakeSignalRepo) GetRewatchCounts(context.Context, int, string) ([]RewatchCount, error) { return r.fallbackRewatches, nil } func (r *fakeSignalRepo) ResolveCanonicalItemIDSet(_ context.Context, contentIDs []string) (map[string]struct{}, error) { set := make(map[string]struct{}, len(contentIDs)) for _, id := range contentIDs { if canonical, ok := r.canonical[id]; ok { set[canonical] = struct{}{} continue } set[id] = struct{}{} } return set, nil } type fakeSignalProvider struct { store userstore.UserStore } func (p fakeSignalProvider) ForUser(context.Context, int) (userstore.UserStore, error) { return p.store, nil } func (p fakeSignalProvider) Close() error { return nil } type fakeSignalStore struct { userstore.UserStore progress []userstore.WatchProgress history []userstore.WatchHistoryEntry profile *userstore.Profile } func (s *fakeSignalStore) ListProgress(_ context.Context, profileID, status string, limit, offset int) ([]userstore.WatchProgress, error) { filtered := make([]userstore.WatchProgress, 0, len(s.progress)) for _, progress := range s.progress { if progress.ProfileID != profileID { continue } switch status { case "completed": if !progress.Completed { continue } case "in_progress": if progress.Completed { continue } } filtered = append(filtered, progress) } slices.SortStableFunc(filtered, func(a, b userstore.WatchProgress) int { left := parseSignalTime(a.UpdatedAt, time.Time{}) right := parseSignalTime(b.UpdatedAt, time.Time{}) if left.After(right) { return -1 } if right.After(left) { return 1 } if a.MediaItemID < b.MediaItemID { return -1 } if a.MediaItemID > b.MediaItemID { return 1 } return 0 }) if offset >= len(filtered) { return []userstore.WatchProgress{}, nil } end := offset + limit if end > len(filtered) { end = len(filtered) } return filtered[offset:end], nil } func (s *fakeSignalStore) ListCompletedHistory(_ context.Context, query userstore.CompletedHistoryQuery) ([]userstore.WatchHistoryEntry, error) { filtered := make([]userstore.WatchHistoryEntry, 0, len(s.history)) for _, entry := range s.history { if entry.ProfileID == query.ProfileID && entry.Completed { filtered = append(filtered, entry) } } if query.Offset >= len(filtered) { return []userstore.WatchHistoryEntry{}, nil } end := query.Offset + query.Limit if end > len(filtered) { end = len(filtered) } return filtered[query.Offset:end], nil } func (s *fakeSignalStore) GetProfile(context.Context, string) (*userstore.Profile, error) { return s.profile, nil } func TestSignalReaderWatchedSetCanonicalizesStoreProgress(t *testing.T) { store := &fakeSignalStore{progress: []userstore.WatchProgress{ {ProfileID: "p1", MediaItemID: "episode-1", Completed: true}, {ProfileID: "p1", MediaItemID: "movie-half", PositionSeconds: 60, DurationSeconds: 100}, {ProfileID: "p1", MediaItemID: "movie-low", PositionSeconds: 40, DurationSeconds: 100}, {ProfileID: "other", MediaItemID: "other-complete", Completed: true}, }} repo := &fakeSignalRepo{canonical: map[string]string{ "episode-1": "series-1", }} reader := NewSignalReader(repo, fakeSignalProvider{store: store}) watched, err := reader.WatchedItemIDSet(context.Background(), 7, "p1") if err != nil { t.Fatalf("WatchedItemIDSet returned error: %v", err) } if _, ok := watched["series-1"]; !ok { t.Fatalf("expected episode progress to canonicalize to series, got %#v", watched) } if _, ok := watched["movie-half"]; !ok { t.Fatalf("expected half-watched movie in watched set, got %#v", watched) } if _, ok := watched["movie-low"]; ok { t.Fatalf("did not expect low-progress movie in watched set: %#v", watched) } } func TestSignalReaderRecentCompletedUsesStoreUpdatedOrder(t *testing.T) { store := &fakeSignalStore{progress: []userstore.WatchProgress{ {ProfileID: "p1", MediaItemID: "older", Completed: true, UpdatedAt: "2026-05-01T10:00:00Z"}, {ProfileID: "p1", MediaItemID: "newer", Completed: true, UpdatedAt: "2026-05-02T10:00:00Z"}, {ProfileID: "p1", MediaItemID: "newest", Completed: true, UpdatedAt: "2026-05-03T10:00:00Z"}, {ProfileID: "p1", MediaItemID: "unfinished", Completed: false, UpdatedAt: "2026-05-04T10:00:00Z"}, }} reader := NewSignalReader(&fakeSignalRepo{}, fakeSignalProvider{store: store}) ids, err := reader.RecentCompletedItemIDs(context.Background(), 7, "p1", 2) if err != nil { t.Fatalf("RecentCompletedItemIDs returned error: %v", err) } want := []string{"newest", "newer"} if !slices.Equal(ids, want) { t.Fatalf("recent completed = %#v, want %#v", ids, want) } } func TestSignalReaderRewatchCountsAggregatesCompletedHistory(t *testing.T) { store := &fakeSignalStore{history: []userstore.WatchHistoryEntry{ {ProfileID: "p1", MediaItemID: "rewatched", Completed: true, WatchedAt: "2026-05-01T10:00:00Z"}, {ProfileID: "p1", MediaItemID: "rewatched", Completed: true, WatchedAt: "2026-05-03T10:00:00Z"}, {ProfileID: "p1", MediaItemID: "once", Completed: true, WatchedAt: "2026-05-02T10:00:00Z"}, {ProfileID: "p1", MediaItemID: "unfinished", Completed: false, WatchedAt: "2026-05-04T10:00:00Z"}, }} reader := NewSignalReader(&fakeSignalRepo{}, fakeSignalProvider{store: store}) counts, err := reader.RewatchCounts(context.Background(), 7, "p1") if err != nil { t.Fatalf("RewatchCounts returned error: %v", err) } if len(counts) != 1 { t.Fatalf("got %#v, want exactly one rewatch count", counts) } if counts[0].MediaItemID != "rewatched" || counts[0].Count != 2 { t.Fatalf("unexpected rewatch count: %#v", counts[0]) } wantTime := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) if !counts[0].LastWatchedAt.Equal(wantTime) { t.Fatalf("last watched = %s, want %s", counts[0].LastWatchedAt, wantTime) } } func TestSignalReaderFallsBackWhenNoStoreProvider(t *testing.T) { repo := &fakeSignalRepo{fallbackRecentCompleted: []string{"from-repo"}} reader := NewSignalReader(repo, nil) ids, err := reader.RecentCompletedItemIDs(context.Background(), 7, "p1", 3) if err != nil { t.Fatalf("RecentCompletedItemIDs returned error: %v", err) } if !slices.Equal(ids, []string{"from-repo"}) { t.Fatalf("ids = %#v, want repo fallback", ids) } } func TestProfileAccessFilterUsesStoredStableProfileRestrictions(t *testing.T) { store := &fakeSignalStore{profile: &userstore.Profile{ ID: "p1", MaxContentRating: "PG-13", LibraryRestrictionsEnabled: true, AllowedLibraryIDs: []int{2, 5}, }} engine := &Engine{storeProvider: fakeSignalProvider{store: store}} filter := engine.profileAccessFilter(context.Background(), 7, "p1") if filter.UserID != 7 || filter.ProfileID != "p1" { t.Fatalf("unexpected filter identity: %#v", filter) } if filter.MaxContentRating != "PG-13" { t.Fatalf("MaxContentRating = %q, want PG-13", filter.MaxContentRating) } if !slices.Equal(filter.AllowedLibraryIDs, []int{2, 5}) { t.Fatalf("AllowedLibraryIDs = %#v, want [2 5]", filter.AllowedLibraryIDs) } if filter.DisabledLibraryIDs != nil { t.Fatalf("DisabledLibraryIDs should remain request-time only, got %#v", filter.DisabledLibraryIDs) } }