From c0e2b916e0666ec30a47ba830880468eefc69c29 Mon Sep 17 00:00:00 2001 From: Finfet Date: Sat, 3 Jan 2026 16:01:14 -0500 Subject: [PATCH] Fix instant submission and refactor spotify --- service/spotify/spotify.go | 425 ++++++---- service/spotify/spotify_test.go | 1352 +++++++++++++++++++++++++++++++ 2 files changed, 1604 insertions(+), 173 deletions(-) create mode 100644 service/spotify/spotify_test.go diff --git a/service/spotify/spotify.go b/service/spotify/spotify.go index a38ba55..9fe30ce 100644 --- a/service/spotify/spotify.go +++ b/service/spotify/spotify.go @@ -28,34 +28,65 @@ import ( "github.com/teal-fm/piper/session" ) +// Maximum delta time to add per poll cycle (prevents spurious accumulation if polling is delayed) +const maxDeltaMs int64 = 30000 + +// Maximum delta time that can be skipped upon first appearance of a track. +const maxSkipDeltaMs int64 = 30000 + +// userPlayState tracks the listening state for a user, including accumulated +// listening time per track +type userPlayState struct { + track *models.Track // Full track info for now-playing and stamping + accumulatedMs int64 // Accumulated listening time in ms + lastPollTime time.Time // When we last polled (for delta calculation) + hasStamped bool // Whether we've stamped this "play cycle" + isPaused bool // Whether currently paused +} + +// SpotifyTrackResponse contains track info and playback state from Spotify +type SpotifyTrackResponse struct { + Track *models.Track + IsPlaying bool +} + +// stateAction describes what external actions to take after state computation +type stateAction struct { + clearNowPlaying bool + publishNowPlaying bool + stampTrack bool + track *models.Track + accumulatedMs int64 +} + type Service struct { - DB *db.DB - atprotoService *atprotoauth.AuthService // Added field - mb *musicbrainz.Service // Added field - playingNowService interface { + DB *db.DB + atprotoAuthService *atprotoauth.AuthService // Added field + mb *musicbrainz.Service // Added field + playingNowService interface { PublishPlayingNow(ctx context.Context, userID int64, track *models.Track) error ClearPlayingNow(ctx context.Context, userID int64) error } // Added field for playing now service - userTracks map[int64]*models.Track - userTokens map[int64]string - mu sync.RWMutex - logger *log.Logger + userPlayStates map[int64]*userPlayState + userTokens map[int64]string + mu sync.RWMutex + logger *log.Logger } -func NewSpotifyService(database *db.DB, atprotoService *atprotoauth.AuthService, musicBrainzService *musicbrainz.Service, playingNowService interface { +func NewSpotifyService(database *db.DB, atprotoAuthService *atprotoauth.AuthService, musicBrainzService *musicbrainz.Service, playingNowService interface { PublishPlayingNow(ctx context.Context, userID int64, track *models.Track) error ClearPlayingNow(ctx context.Context, userID int64) error }) *Service { logger := log.New(os.Stdout, "spotify: ", log.LstdFlags|log.Lmsgprefix) return &Service{ - DB: database, - atprotoService: atprotoService, - mb: musicBrainzService, - playingNowService: playingNowService, - userTracks: make(map[int64]*models.Track), - userTokens: make(map[int64]string), - logger: logger, + DB: database, + atprotoAuthService: atprotoAuthService, + mb: musicBrainzService, + playingNowService: playingNowService, + userPlayStates: make(map[int64]*userPlayState), + userTokens: make(map[int64]string), + logger: logger, } } @@ -67,7 +98,7 @@ func (s *Service) SubmitTrackToPDS(did string, mostRecentAtProtoSessionID string } // Use shared atproto service for submission - return atprotoservice.SubmitPlayToPDS(ctx, did, mostRecentAtProtoSessionID, track, s.atprotoService) + return atprotoservice.SubmitPlayToPDS(ctx, did, mostRecentAtProtoSessionID, track, s.atprotoAuthService) } func (s *Service) SetAccessToken(token string, refreshToken string, userId int64, hasSession bool) (int64, error) { @@ -360,10 +391,10 @@ func (s *Service) HandleCurrentTrack(w http.ResponseWriter, r *http.Request) { } s.mu.RLock() - track, exists := s.userTracks[userID] + state, exists := s.userPlayStates[userID] s.mu.RUnlock() - if !exists || track == nil { + if !exists || state == nil || state.track == nil { _, err := fmt.Fprintf(w, "No track currently playing") if err != nil { s.logger.Printf("Error writing response: %v", err) @@ -373,7 +404,7 @@ func (s *Service) HandleCurrentTrack(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - err := json.NewEncoder(w).Encode(track) + err := json.NewEncoder(w).Encode(state.track) if err != nil { s.logger.Printf("Error encoding response: %v", err) return @@ -402,7 +433,7 @@ func (s *Service) HandleTrackHistory(w http.ResponseWriter, r *http.Request) { } } -func (s *Service) FetchCurrentTrack(userID int64) (*models.Track, error) { +func (s *Service) FetchCurrentTrack(userID int64) (*SpotifyTrackResponse, error) { s.mu.RLock() token, exists := s.userTokens[userID] s.mu.RUnlock() @@ -510,9 +541,7 @@ func (s *Service) FetchCurrentTrack(userID int64) (*models.Track, error) { if err != nil { return nil, fmt.Errorf("failed to unmarshal spotify response: %w", err) } - if response.IsPlaying == false { - return nil, nil - } + var artists []models.Artist for _, artist := range response.Item.Artists { artists = append(artists, models.Artist{ @@ -523,7 +552,7 @@ func (s *Service) FetchCurrentTrack(userID int64) (*models.Track, error) { // ignore tracks with no artists (podcasts, audiobooks, etc) if len(artists) == 0 { - return nil, nil + return &SpotifyTrackResponse{Track: nil, IsPlaying: response.IsPlaying}, nil } // assemble Track @@ -540,181 +569,231 @@ func (s *Service) FetchCurrentTrack(userID int64) (*models.Track, error) { Timestamp: time.Now().UTC(), } - return track, nil + return &SpotifyTrackResponse{Track: track, IsPlaying: response.IsPlaying}, nil } -func (s *Service) fetchAllUserTracks(ctx context.Context) { - // copy userIDs to avoid holding the lock too long - s.mu.RLock() - userIDs := make([]int64, 0, len(s.userTokens)) - for userID := range s.userTokens { - userIDs = append(userIDs, userID) +func getFirstArtist(track *models.Track) string { + if track != nil && len(track.Artist) > 0 { + return track.Artist[0].Name } - s.mu.RUnlock() + return "Unknown Artist" +} - for _, userID := range userIDs { - if ctx.Err() != nil { - s.logger.Printf("Context cancelled before starting fetch for user id %d.", userID) - break // Exit loop if context is cancelled - } +// computeStateUpdate holds the lock, updates user play state based on the +// Spotify response, and returns the actions that should be taken after +// releasing the lock. This separates state computation from external I/O. +// More importantly, this allows for holding a lock while doing early returns +// through the use of defer. +func (s *Service) computeStateUpdate(userID int64, resp *SpotifyTrackResponse) stateAction { + now := time.Now() + var action stateAction - track, err := s.FetchCurrentTrack(userID) - if err != nil { - s.logger.Printf("Error fetching track for user %d: %v", userID, err) - continue - } + s.mu.Lock() + defer s.mu.Unlock() - if track == nil { - // No track currently playing - clear playing now status - if s.playingNowService != nil { - if err := s.playingNowService.ClearPlayingNow(ctx, userID); err != nil { - s.logger.Printf("Error clearing playing now for user %d: %v", userID, err) - } - } - continue + state := s.userPlayStates[userID] + + // No track from Spotify (nothing playing, not even paused) + if resp == nil || resp.Track == nil { + // If the user already has some state, pause it + if state != nil { + state.isPaused = true + action.clearNowPlaying = true } + return action + } - s.mu.RLock() - currentTrack := s.userTracks[userID] - s.mu.RUnlock() + track := resp.Track + action.track = track - if currentTrack == nil { - currentTracks, _ := s.DB.GetRecentTracks(userID, 1) - if len(currentTracks) > 0 { - currentTrack = currentTracks[0] + // Track is paused + if !resp.IsPlaying { + if state != nil && state.track != nil && state.track.URL == track.URL { + // Same song paused - preserve state, mark paused + state.isPaused = true + } else { + // Different song paused or no prior state - create new state but paused + // + // We use the track's progress rather than 0 to account + // for time missed due to polling latency. A cap is + // used to prevent instant skips past the stamping point + s.userPlayStates[userID] = &userPlayState{ + track: track, + accumulatedMs: min(track.ProgressMs, maxSkipDeltaMs), + lastPollTime: now, + hasStamped: false, + isPaused: true, } } + action.clearNowPlaying = true + return action + } + + // Track is playing + + isNewTrack := state == nil || state.track == nil || state.track.URL != track.URL + if isNewTrack { + // New song - reset state + // + // We use the track's progress rather than 0 to account + // for time missed due to polling latency. A cap is + // used to prevent instant skips past the stamping point + s.userPlayStates[userID] = &userPlayState{ + track: track, + accumulatedMs: min(track.ProgressMs, maxSkipDeltaMs), + lastPollTime: now, + hasStamped: false, + isPaused: false, + } + state = s.userPlayStates[userID] + action.publishNowPlaying = true + s.logger.Printf("Track changed for user %d: %s by %s", userID, track.Name, getFirstArtist(track)) + } else { + // Same song continuing + state.track = track + if state.isPaused { + // Resuming from pause - just mark as playing, don't add delta yet + // + // This technically causes a loss of acc time due to + // polling latency, but there isn't really a safe way + // to fix this. If the user stops and starts the same + // song many times, this would cause issues. That's + // likely rare though. + state.isPaused = false + action.publishNowPlaying = true + } else { + // Was already playing - add delta time + // (capped to prevent spurious large accumulation from server issues) + deltaMs := min(now.Sub(state.lastPollTime).Milliseconds(), maxDeltaMs) + state.accumulatedMs += deltaMs + } + state.lastPollTime = now + } - // if flagged true, we have a new track - isNewTrack := currentTrack == nil || - currentTrack.Name != track.Name || - // just check the first one for now - currentTrack.Artist[0].Name != track.Artist[0].Name + // Check for song repeat (accumulated >= duration) + if state.accumulatedMs >= track.DurationMs { + // Subtract duration rather than setting to 0 to account for + // polling latency (leaves acc > 0 after in many cases). + state.accumulatedMs -= track.DurationMs + state.hasStamped = false + s.logger.Printf( + "Song repeat detected for user %d: %s (acc: %dms, dur: %dms)", + userID, track.Name, state.accumulatedMs, state.track.DurationMs, + ) + } - // we stamp a track iff we've played more than half (or 30 seconds whichever is greater) - isStamped := track.ProgressMs > track.DurationMs/2 && track.ProgressMs > 30000 + // Check for stamp threshold + // We stamp a track iff we've played more than half or 30 seconds, whichever is greater + stampThreshold := max(track.DurationMs/2, 30000) + if state.accumulatedMs > stampThreshold && !state.hasStamped { + state.hasStamped = true + action.stampTrack = true + action.accumulatedMs = state.accumulatedMs + } - // if currentTrack.Timestamp minus track.Timestamp is greater than 30 seconds - isLastTrackStamped := currentTrack != nil && time.Since(currentTrack.Timestamp) > 30*time.Second && - currentTrack.DurationMs > 30000 + return action +} - // just log when we stamp tracks - if isNewTrack && isLastTrackStamped && currentTrack != nil && !currentTrack.HasStamped { - artistName := "Unknown Artist" - if len(currentTrack.Artist) > 0 { - artistName = currentTrack.Artist[0].Name - } - s.logger.Printf("User %d stamped (previous) track: %s by %s", userID, currentTrack.Name, artistName) - currentTrack.HasStamped = true - if currentTrack.PlayID != 0 { - err := s.DB.UpdateTrack(currentTrack.PlayID, currentTrack) - if err != nil { - s.logger.Printf("Error updating track %d in DB: %v", currentTrack.PlayID, err) - return - } - s.logger.Printf("Updated!") - } - } +// fetchTrackForUser fetches the current track from Spotify, computes the +// state update, and executes any required external actions. +func (s *Service) fetchTrackForUser(ctx context.Context, userID int64) { + // Fetch from Spotify + resp, err := s.FetchCurrentTrack(userID) + if err != nil { + s.logger.Printf("Error fetching track for user %d: %v", userID, err) + return + } - if isStamped && currentTrack != nil && !currentTrack.HasStamped { - artistName := "Unknown Artist" - if len(track.Artist) > 0 { - artistName = track.Artist[0].Name - } - s.logger.Printf("User %d stamped track: %s by %s", userID, track.Name, artistName) - track.HasStamped = true - // if currenttrack has a playid and the last track is the same as the current track - if !isNewTrack && currentTrack.PlayID != 0 { - err := s.DB.UpdateTrack(currentTrack.PlayID, track) - if err != nil { - s.logger.Printf("Error updating track %d in DB: %v", currentTrack.PlayID, err) - return - } - - // Update in memory - s.mu.Lock() - s.userTracks[userID] = track - s.mu.Unlock() + // Compute state changes (holds lock internally) + action := s.computeStateUpdate(userID, resp) - // Update playing now status since track progress changed - if s.playingNowService != nil { - if err := s.playingNowService.PublishPlayingNow(ctx, userID, track); err != nil { - s.logger.Printf("Error updating playing now for user %d: %v", userID, err) - } - } + // Execute external calls based on computed actions (no lock held) + if action.clearNowPlaying && s.playingNowService != nil { + if err := s.playingNowService.ClearPlayingNow(ctx, userID); err != nil { + s.logger.Printf("Error clearing playing now for user %d: %v", userID, err) + } + } - s.logger.Printf("Updated!") - } + if action.publishNowPlaying && s.playingNowService != nil { + if err := s.playingNowService.PublishPlayingNow(ctx, userID, action.track); err != nil { + s.logger.Printf("Error publishing playing now for user %d: %v", userID, err) } + } - if isNewTrack { - id, err := s.DB.SaveTrack(userID, track) - if err != nil { - s.logger.Printf("Error saving track for user %d: %v", userID, err) - continue - } + if action.stampTrack { + s.logger.Printf( + "User %d stamped track: %s by %s (acc: %dms, dur: %dms)", + userID, action.track.Name, getFirstArtist(action.track), + action.accumulatedMs, action.track.DurationMs, + ) + s.stampTrack(ctx, userID, action.track) + } +} - track.PlayID = id +func (s *Service) fetchAllUserTracks(ctx context.Context) { + // copy userIDs to avoid holding the lock too long + s.mu.RLock() + userIDs := make([]int64, 0, len(s.userTokens)) + for userID := range s.userTokens { + userIDs = append(userIDs, userID) + } + s.mu.RUnlock() - s.mu.Lock() - s.userTracks[userID] = track - s.mu.Unlock() + for _, userID := range userIDs { + if ctx.Err() != nil { + s.logger.Printf("Context cancelled before starting fetch for user id %d.", userID) + break // Exit loop if context is cancelled + } - // Publish playing now status - if s.playingNowService != nil { - if err := s.playingNowService.PublishPlayingNow(ctx, userID, track); err != nil { - s.logger.Printf("Error publishing playing now for user %d: %v", userID, err) - } - } + s.fetchTrackForUser(ctx, userID) + } +} - // Submit to ATProto PDS - // The 'track' variable is *models.Track and has been saved to DB, PlayID is populated. - dbUser, errUser := s.DB.GetUserByID(userID) // Fetch user by their internal ID - if errUser != nil { - s.logger.Printf("User %d: Error fetching user details for PDS submission: %v", userID, errUser) - } else if dbUser == nil { - s.logger.Printf("User %d: User not found in DB. Skipping PDS submission.", userID) - } else if dbUser.ATProtoDID == nil || *dbUser.ATProtoDID == "" { - s.logger.Printf("User %d (%d): ATProto DID not set. Skipping PDS submission for track '%s'.", userID, dbUser.ATProtoDID, track.Name) - } else { - // User has a DID, proceed with hydration and submission - var trackToSubmitToPDS = track // Default to the original track (already *models.Track) - if s.mb != nil { // Check if MusicBrainz service is available - // musicbrainz.HydrateTrack expects models.Track as second argument, so we pass *track - // and it returns *models.Track - hydratedTrack, errHydrate := musicbrainz.HydrateTrack(s.mb, *track) - if errHydrate != nil { - s.logger.Printf("User %d (%d): Error hydrating track '%s' with MusicBrainz: %v. Proceeding with original track data for PDS.", userID, dbUser.ATProtoDID, track.Name, errHydrate) - } else { - s.logger.Printf("User %d (%d): Successfully hydrated track '%s' with MusicBrainz.", userID, dbUser.ATProtoDID, track.Name) - trackToSubmitToPDS = hydratedTrack // hydratedTrack is *models.Track - } - } else { - s.logger.Printf("User %d (%d): MusicBrainz service not configured. Proceeding with original track data for PDS.", userID, dbUser.ATProtoDID) - } - - artistName := "Unknown Artist" - if len(trackToSubmitToPDS.Artist) > 0 { - artistName = trackToSubmitToPDS.Artist[0].Name - } - - s.logger.Printf("User %d (%d): Attempting to submit track '%s' by %s to PDS (DID: %s)", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name, artistName, *dbUser.ATProtoDID) - // Use context.Background() for now, or pass down a context if available - if errPDS := s.SubmitTrackToPDS(*dbUser.ATProtoDID, *dbUser.MostRecentAtProtoSessionID, trackToSubmitToPDS, context.Background()); errPDS != nil { - s.logger.Printf("User %d (%d): Error submitting track '%s' to PDS: %v", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name, errPDS) - } else { - s.logger.Printf("User %d (%d): Successfully submitted track '%s' to PDS.", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name) - } - } - // End of PDS submission block +// stampTrack handles MusicBrainz hydration, DB save, and PDS submission for a stamped track. +func (s *Service) stampTrack(ctx context.Context, userID int64, track *models.Track) { + track.HasStamped = true - artistName := "Unknown Artist" - if len(track.Artist) > 0 { - artistName = track.Artist[0].Name - } - s.logger.Printf("User %d is listening to: %s by %s", userID, track.Name, artistName) + trackToSubmit := track + if s.mb != nil { + hydratedTrack, err := musicbrainz.HydrateTrack(s.mb, *track) + if err != nil { + s.logger.Printf("User %d: Error hydrating track '%s' with MusicBrainz: %v", userID, track.Name, err) + } else { + s.logger.Printf("User %d: Successfully hydrated track '%s'", userID, track.Name) + trackToSubmit = hydratedTrack } } + + // Save the track now that it is stamped and hydrated + if _, err := s.DB.SaveTrack(userID, trackToSubmit); err != nil { + s.logger.Printf("Error saving track for user %d: %v", userID, err) + return + } + + // Submit play record to ATProto PDS + + // Fetch the user + dbUser, err := s.DB.GetUserByID(userID) + if err != nil { + s.logger.Printf("User %d: Error fetching user for PDS: %v", userID, err) + return + } + if dbUser == nil { + s.logger.Printf("User %d: User not found in DB. Skipping PDS submission.", userID) + return + } + if dbUser.ATProtoDID == nil || *dbUser.ATProtoDID == "" { + // No DID configured, skip PDS submission silently + return + } + + // Perform submission to PDS + s.logger.Printf("User %d: Submitting track '%s' to PDS (DID: %s)", userID, trackToSubmit.Name, *dbUser.ATProtoDID) + if err := s.SubmitTrackToPDS(*dbUser.ATProtoDID, *dbUser.MostRecentAtProtoSessionID, trackToSubmit, ctx); err != nil { + s.logger.Printf("User %d: Error submitting to PDS: %v", userID, err) + } else { + s.logger.Printf("User %d: Successfully submitted track '%s' to PDS", userID, trackToSubmit.Name) + } } func (s *Service) StartListeningTracker(interval time.Duration) { diff --git a/service/spotify/spotify_test.go b/service/spotify/spotify_test.go new file mode 100644 index 0000000..2780f9c --- /dev/null +++ b/service/spotify/spotify_test.go @@ -0,0 +1,1352 @@ +package spotify + +import ( + "context" + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/teal-fm/piper/db" + "github.com/teal-fm/piper/models" + "github.com/teal-fm/piper/session" +) + +// ===== Mock Implementations ===== + +// publishCall records a call to PublishPlayingNow +type publishCall struct { + userID int64 + track *models.Track +} + +// mockPlayingNowService implements the playingNowService interface for testing +type mockPlayingNowService struct { + publishCalls []publishCall + clearCalls []int64 + publishErr error + clearErr error +} + +func (m *mockPlayingNowService) PublishPlayingNow(ctx context.Context, userID int64, track *models.Track) error { + m.publishCalls = append(m.publishCalls, publishCall{userID: userID, track: track}) + return m.publishErr +} + +func (m *mockPlayingNowService) ClearPlayingNow(ctx context.Context, userID int64) error { + m.clearCalls = append(m.clearCalls, userID) + return m.clearErr +} + +// ===== Test Helpers ===== + +func setupTestDB(t *testing.T) *db.DB { + database, err := db.New(":memory:") + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + + if err := database.Initialize(); err != nil { + t.Fatalf("Failed to initialize test database: %v", err) + } + + return database +} + +func createTestUser(t *testing.T, database *db.DB) int64 { + user := &models.User{ + Email: func() *string { s := "test@example.com"; return &s }(), + } + userID, err := database.CreateUser(user) + if err != nil { + t.Fatalf("Failed to create test user: %v", err) + } + return userID +} + +func createTestTrack(name, artistName, url string, durationMs, progressMs int64) *models.Track { + return &models.Track{ + Name: name, + Artist: []models.Artist{{Name: artistName, ID: "artist123"}}, + Album: "Test Album", + URL: url, + DurationMs: durationMs, + ProgressMs: progressMs, + ServiceBaseUrl: "open.spotify.com", + ISRC: "TEST1234567", + Timestamp: time.Now().UTC(), + } +} + +func newTestService(database *db.DB, playingNow *mockPlayingNowService) *Service { + return &Service{ + DB: database, + atprotoAuthService: nil, + mb: nil, + playingNowService: playingNow, + userPlayStates: make(map[int64]*userPlayState), + userTokens: make(map[int64]string), + logger: log.New(io.Discard, "", 0), + } +} + +func withUserContext(ctx context.Context, userID int64) context.Context { + return session.WithUserID(ctx, userID) +} + +// ===== getFirstArtist Tests ===== + +func TestGetFirstArtist(t *testing.T) { + testCases := []struct { + name string + track *models.Track + expected string + }{ + { + name: "nil track", + track: nil, + expected: "Unknown Artist", + }, + { + name: "empty artists", + track: &models.Track{ + Name: "Test Track", + Artist: []models.Artist{}, + }, + expected: "Unknown Artist", + }, + { + name: "one artist", + track: &models.Track{ + Name: "Test Track", + Artist: []models.Artist{{Name: "Daft Punk", ID: "123"}}, + }, + expected: "Daft Punk", + }, + { + name: "multiple artists", + track: &models.Track{ + Name: "Test Track", + Artist: []models.Artist{ + {Name: "Artist A", ID: "1"}, + {Name: "Artist B", ID: "2"}, + }, + }, + expected: "Artist A", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := getFirstArtist(tc.track) + if result != tc.expected { + t.Errorf("Expected '%s', got '%s'", tc.expected, result) + } + }) + } +} + +// ===== computeStateUpdate Tests ===== + +func TestComputeStateUpdate_NoPriorState(t *testing.T) { + t.Run("track playing, no prior state", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := int64(1) + + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 240000, 5000) + resp := &SpotifyTrackResponse{Track: track, IsPlaying: true} + + action := svc.computeStateUpdate(userID, resp) + + // Should publish now playing + if !action.publishNowPlaying { + t.Error("Expected publishNowPlaying to be true") + } + if action.clearNowPlaying { + t.Error("Expected clearNowPlaying to be false") + } + + // State should be created + state := svc.userPlayStates[userID] + if state == nil { + t.Fatal("Expected state to be created") + } + if state.isPaused { + t.Error("Expected isPaused to be false") + } + // accumulatedMs should be min(progressMs, maxSkipDeltaMs) + if state.accumulatedMs != 5000 { + t.Errorf("Expected accumulatedMs to be 5000, got %d", state.accumulatedMs) + } + }) + + t.Run("track playing with high progress, capped at maxSkipDeltaMs", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := int64(1) + + // Progress is 60s, should be capped at 30s + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 240000, 60000) + resp := &SpotifyTrackResponse{Track: track, IsPlaying: true} + + action := svc.computeStateUpdate(userID, resp) + + if !action.publishNowPlaying { + t.Error("Expected publishNowPlaying to be true") + } + + state := svc.userPlayStates[userID] + if state.accumulatedMs != maxSkipDeltaMs { + t.Errorf("Expected accumulatedMs to be capped at %d, got %d", maxSkipDeltaMs, state.accumulatedMs) + } + }) + + t.Run("track paused, no prior state", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := int64(1) + + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 240000, 5000) + resp := &SpotifyTrackResponse{Track: track, IsPlaying: false} + + action := svc.computeStateUpdate(userID, resp) + + if !action.clearNowPlaying { + t.Error("Expected clearNowPlaying to be true") + } + if action.publishNowPlaying { + t.Error("Expected publishNowPlaying to be false") + } + + state := svc.userPlayStates[userID] + if state == nil { + t.Fatal("Expected state to be created") + } + if !state.isPaused { + t.Error("Expected isPaused to be true") + } + }) + + t.Run("nil response", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := int64(1) + + action := svc.computeStateUpdate(userID, nil) + + // Should be a no-op + if action.clearNowPlaying { + t.Error("Expected clearNowPlaying to be false for nil response with no prior state") + } + if action.publishNowPlaying { + t.Error("Expected publishNowPlaying to be false") + } + if action.stampTrack { + t.Error("Expected stampTrack to be false") + } + }) + + t.Run("nil track in response", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := int64(1) + + resp := &SpotifyTrackResponse{Track: nil, IsPlaying: true} + action := svc.computeStateUpdate(userID, resp) + + // Should be a no-op + if action.clearNowPlaying { + t.Error("Expected clearNowPlaying to be false for nil track with no prior state") + } + if action.publishNowPlaying { + t.Error("Expected publishNowPlaying to be false") + } + if action.stampTrack { + t.Error("Expected stampTrack to be false") + } + }) +} + +func TestComputeStateUpdate_SameTrackContinues(t *testing.T) { + t.Run("same track still playing, accumulates time", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := int64(1) + + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 240000, 5000) + + // Set up existing state + pastTime := time.Now().Add(-10 * time.Second) // 10 seconds ago + svc.userPlayStates[userID] = &userPlayState{ + track: track, + accumulatedMs: 5000, + lastPollTime: pastTime, + hasStamped: false, + isPaused: false, + } + + resp := &SpotifyTrackResponse{Track: track, IsPlaying: true} + action := svc.computeStateUpdate(userID, resp) + + // Should not publish (same track continuing) + if action.publishNowPlaying { + t.Error("Expected publishNowPlaying to be false for same track continuing") + } + + state := svc.userPlayStates[userID] + // Should have added ~10s to accumulated (within tolerance) + if state.accumulatedMs != 15000 { + t.Errorf("Expected accumulatedMs to be %d, got %d", 15000, state.accumulatedMs) + } + }) + + t.Run("same track now paused", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := int64(1) + + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 240000, 5000) + + svc.userPlayStates[userID] = &userPlayState{ + track: track, + accumulatedMs: 60000, + lastPollTime: time.Now(), + hasStamped: false, + isPaused: false, + } + + resp := &SpotifyTrackResponse{Track: track, IsPlaying: false} + action := svc.computeStateUpdate(userID, resp) + + if !action.clearNowPlaying { + t.Error("Expected clearNowPlaying to be true") + } + + state := svc.userPlayStates[userID] + if !state.isPaused { + t.Error("Expected isPaused to be true") + } + }) + + t.Run("same track resumed from pause", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := int64(1) + + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 240000, 5000) + + svc.userPlayStates[userID] = &userPlayState{ + track: track, + accumulatedMs: 60000, + lastPollTime: time.Now(), + hasStamped: false, + isPaused: true, // Was paused + } + + resp := &SpotifyTrackResponse{Track: track, IsPlaying: true} + action := svc.computeStateUpdate(userID, resp) + + if !action.publishNowPlaying { + t.Error("Expected publishNowPlaying to be true when resuming") + } + + state := svc.userPlayStates[userID] + if state.isPaused { + t.Error("Expected isPaused to be false after resume") + } + }) + + t.Run("delta time capped at maxDeltaMs", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := int64(1) + + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 240000, 5000) + + // Set up state with lastPollTime 60 seconds ago + pastTime := time.Now().Add(-60 * time.Second) + svc.userPlayStates[userID] = &userPlayState{ + track: track, + accumulatedMs: 10000, + lastPollTime: pastTime, + hasStamped: false, + isPaused: false, + } + + resp := &SpotifyTrackResponse{Track: track, IsPlaying: true} + svc.computeStateUpdate(userID, resp) + + state := svc.userPlayStates[userID] + // Should be capped: 10000 + 30000 = 40000 (not 10000 + 60000) + if state.accumulatedMs != 10000+maxDeltaMs { // small tolerance + t.Errorf("Expected delta to be capped at maxDeltaMs, got accumulatedMs=%d", state.accumulatedMs) + } + }) +} + +func TestComputeStateUpdate_NewTrackDetected(t *testing.T) { + t.Run("different track URL", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := int64(1) + + oldTrack := createTestTrack("Old Song", "Old Artist", "http://spotify/track1", 240000, 120000) + newTrack := createTestTrack("New Song", "New Artist", "http://spotify/track2", 180000, 5000) + + svc.userPlayStates[userID] = &userPlayState{ + track: oldTrack, + accumulatedMs: 120000, + lastPollTime: time.Now(), + hasStamped: true, + isPaused: false, + } + + resp := &SpotifyTrackResponse{Track: newTrack, IsPlaying: true} + action := svc.computeStateUpdate(userID, resp) + + if !action.publishNowPlaying { + t.Error("Expected publishNowPlaying to be true for new track") + } + + state := svc.userPlayStates[userID] + if state.track.URL != newTrack.URL { + t.Error("Expected state to have new track") + } + if state.hasStamped { + t.Error("Expected hasStamped to be reset to false") + } + if state.accumulatedMs != 5000 { + t.Errorf("Expected accumulatedMs to be reset to progressMs (5000), got %d", state.accumulatedMs) + } + }) +} + +func TestComputeStateUpdate_SongRepeat(t *testing.T) { + t.Run("loop detected when accumulated >= duration", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := int64(1) + + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 180000, 5000) + + // Set accumulated to just under duration + svc.userPlayStates[userID] = &userPlayState{ + track: track, + accumulatedMs: 175000, + lastPollTime: time.Now().Add(-10 * time.Second), + hasStamped: true, + isPaused: false, + } + + resp := &SpotifyTrackResponse{Track: track, IsPlaying: true} + svc.computeStateUpdate(userID, resp) + + state := svc.userPlayStates[userID] + // After adding ~10s, accumulated should be ~185000, exceeding duration of 180000 + // So it should subtract duration: 185000 - 180000 = 5000 + if state.accumulatedMs != 5000 { + t.Errorf("Expected accumulatedMs to be reset below duration, got %d", state.accumulatedMs) + } + if state.hasStamped { + t.Error("Expected hasStamped to be reset to false after loop") + } + }) + + t.Run("overflow preserved after loop", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := int64(1) + + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 100000, 5000) + + // Set accumulated to duration + 5000 + svc.userPlayStates[userID] = &userPlayState{ + track: track, + accumulatedMs: 105000, + lastPollTime: time.Now(), // recent, so delta is small + hasStamped: true, + isPaused: false, + } + + resp := &SpotifyTrackResponse{Track: track, IsPlaying: true} + svc.computeStateUpdate(userID, resp) + + state := svc.userPlayStates[userID] + // Should have subtracted duration: 105000 - 100000 = 5000 + if state.accumulatedMs != 5000 { + t.Errorf("Expected accumulatedMs to be 5000 after loop, got %d", state.accumulatedMs) + } + }) +} + +func TestComputeStateUpdate_StampThreshold(t *testing.T) { + testCases := []struct { + name string + durationMs int64 + accumulatedMs int64 + hasStamped bool + expectStamp bool + }{ + { + name: "half duration on long track", + durationMs: 240000, // 4 min + accumulatedMs: 121000, // just over 2 min + hasStamped: false, + expectStamp: true, + }, + { + name: "30s threshold on medium track", + durationMs: 50000, // 50 sec track, threshold = max(25s, 30s) = 30s + accumulatedMs: 31000, // over 30s + hasStamped: false, + expectStamp: true, + }, + { + name: "below threshold", + durationMs: 240000, + accumulatedMs: 50000, // threshold is 120000 + hasStamped: false, + expectStamp: false, + }, + { + name: "already stamped", + durationMs: 240000, + accumulatedMs: 150000, + hasStamped: true, + expectStamp: false, + }, + { + name: "exactly at threshold should not stamp", + durationMs: 240000, + accumulatedMs: 120000, // exactly at threshold, needs to be > threshold + hasStamped: false, + expectStamp: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := int64(1) + + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", tc.durationMs, 5000) + + svc.userPlayStates[userID] = &userPlayState{ + track: track, + accumulatedMs: tc.accumulatedMs, + lastPollTime: time.Now(), // recent, so minimal delta added + hasStamped: tc.hasStamped, + isPaused: false, + } + + resp := &SpotifyTrackResponse{Track: track, IsPlaying: true} + action := svc.computeStateUpdate(userID, resp) + + if action.stampTrack != tc.expectStamp { + t.Errorf("Expected stampTrack=%v, got %v", tc.expectStamp, action.stampTrack) + } + + if tc.expectStamp { + state := svc.userPlayStates[userID] + if !state.hasStamped { + t.Error("Expected hasStamped to be true after stamping") + } + } + }) + } +} + +func TestComputeStateUpdate_EdgeCases(t *testing.T) { + t.Run("zero duration track", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := int64(1) + + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 0, 0) + + resp := &SpotifyTrackResponse{Track: track, IsPlaying: true} + action := svc.computeStateUpdate(userID, resp) + + // Should not panic, threshold should be max(0, 30000) = 30000 + if action.stampTrack { + t.Error("Should not stamp with 0 accumulated time") + } + }) + + t.Run("nil response with existing state clears now playing", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := int64(1) + + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 240000, 5000) + svc.userPlayStates[userID] = &userPlayState{ + track: track, + accumulatedMs: 60000, + lastPollTime: time.Now(), + hasStamped: false, + isPaused: false, + } + + action := svc.computeStateUpdate(userID, nil) + + if !action.clearNowPlaying { + t.Error("Expected clearNowPlaying to be true when response is nil with existing state") + } + + state := svc.userPlayStates[userID] + if !state.isPaused { + t.Error("Expected isPaused to be true") + } + }) +} + +// ===== HTTP Handler Tests ===== + +func TestHandleCurrentTrack(t *testing.T) { + t.Run("no auth returns unauthorized", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + + req := httptest.NewRequest(http.MethodGet, "/current", nil) + rr := httptest.NewRecorder() + + svc.HandleCurrentTrack(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code) + } + }) + + t.Run("no state returns no track playing", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := createTestUser(t, database) + + req := httptest.NewRequest(http.MethodGet, "/current", nil) + ctx := withUserContext(req.Context(), userID) + req = req.WithContext(ctx) + rr := httptest.NewRecorder() + + svc.HandleCurrentTrack(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) + } + if rr.Body.String() != "No track currently playing" { + t.Errorf("Expected 'No track currently playing', got '%s'", rr.Body.String()) + } + }) + + t.Run("nil track in state returns no track playing", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := createTestUser(t, database) + + svc.userPlayStates[userID] = &userPlayState{ + track: nil, + } + + req := httptest.NewRequest(http.MethodGet, "/current", nil) + ctx := withUserContext(req.Context(), userID) + req = req.WithContext(ctx) + rr := httptest.NewRecorder() + + svc.HandleCurrentTrack(rr, req) + + if rr.Body.String() != "No track currently playing" { + t.Errorf("Expected 'No track currently playing', got '%s'", rr.Body.String()) + } + }) + + t.Run("success returns track JSON", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := createTestUser(t, database) + + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 240000, 60000) + svc.userPlayStates[userID] = &userPlayState{ + track: track, + } + + req := httptest.NewRequest(http.MethodGet, "/current", nil) + ctx := withUserContext(req.Context(), userID) + req = req.WithContext(ctx) + rr := httptest.NewRecorder() + + svc.HandleCurrentTrack(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) + } + + contentType := rr.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("Expected Content-Type 'application/json', got '%s'", contentType) + } + + var returnedTrack models.Track + if err := json.Unmarshal(rr.Body.Bytes(), &returnedTrack); err != nil { + t.Fatalf("Failed to parse response JSON: %v", err) + } + + if returnedTrack.Name != "Test Song" { + t.Errorf("Expected track name 'Test Song', got '%s'", returnedTrack.Name) + } + }) +} + +func TestHandleTrackHistory(t *testing.T) { + t.Run("no auth returns unauthorized", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + + req := httptest.NewRequest(http.MethodGet, "/history", nil) + rr := httptest.NewRecorder() + + svc.HandleTrackHistory(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code) + } + }) + + t.Run("empty history returns empty array", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := createTestUser(t, database) + + req := httptest.NewRequest(http.MethodGet, "/history", nil) + ctx := withUserContext(req.Context(), userID) + req = req.WithContext(ctx) + rr := httptest.NewRecorder() + + svc.HandleTrackHistory(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) + } + + var tracks []*models.Track + if err := json.Unmarshal(rr.Body.Bytes(), &tracks); err != nil { + t.Fatalf("Failed to parse response JSON: %v", err) + } + + if len(tracks) != 0 { + t.Errorf("Expected empty array, got %d tracks", len(tracks)) + } + }) + + t.Run("success returns tracks", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userID := createTestUser(t, database) + + // Save some tracks to the database + track1 := createTestTrack("Track 1", "Artist 1", "http://spotify/track1", 180000, 0) + track2 := createTestTrack("Track 2", "Artist 2", "http://spotify/track2", 200000, 0) + + if _, err := database.SaveTrack(userID, track1); err != nil { + t.Fatalf("Failed to save track1: %v", err) + } + if _, err := database.SaveTrack(userID, track2); err != nil { + t.Fatalf("Failed to save track2: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/history", nil) + ctx := withUserContext(req.Context(), userID) + req = req.WithContext(ctx) + rr := httptest.NewRecorder() + + svc.HandleTrackHistory(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) + } + + contentType := rr.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("Expected Content-Type 'application/json', got '%s'", contentType) + } + + var tracks []*models.Track + if err := json.Unmarshal(rr.Body.Bytes(), &tracks); err != nil { + t.Fatalf("Failed to parse response JSON: %v", err) + } + + if len(tracks) != 2 { + t.Errorf("Expected 2 tracks, got %d", len(tracks)) + } + }) +} + +// ===== stampTrack Tests ===== + +func TestStampTrack(t *testing.T) { + t.Run("saves track to database with HasStamped true", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + // createTestUser does not assign a DID to the user. + // This prevents a PDS submission from occurring. + userID := createTestUser(t, database) + + track := createTestTrack("Stamp Test", "Test Artist", "http://spotify/track1", 240000, 0) + + svc.stampTrack(context.Background(), userID, track) + + // Verify track was saved + tracks, err := database.GetRecentTracks(userID, 10) + if err != nil { + t.Fatalf("Failed to get recent tracks: %v", err) + } + + if len(tracks) != 1 { + t.Fatalf("Expected 1 track, got %d", len(tracks)) + } + + if tracks[0].Name != "Stamp Test" { + t.Errorf("Expected track name 'Stamp Test', got '%s'", tracks[0].Name) + } + + if !tracks[0].HasStamped { + t.Error("Expected HasStamped to be true") + } + }) + + t.Run("without MusicBrainz service saves original track", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + svc.mb = nil // Explicitly nil, already should be but just in case + userID := createTestUser(t, database) + + track := createTestTrack("No MB Test", "Test Artist", "http://spotify/track1", 240000, 0) + + svc.stampTrack(context.Background(), userID, track) + + tracks, err := database.GetRecentTracks(userID, 10) + if err != nil { + t.Fatalf("Failed to get recent tracks: %v", err) + } + + if len(tracks) != 1 { + t.Fatalf("Expected 1 track, got %d", len(tracks)) + } + + // Track should be saved even without MB service + if tracks[0].Name != "No MB Test" { + t.Errorf("Expected track name 'No MB Test', got '%s'", tracks[0].Name) + } + }) +} + +// ===== Multi-User Tests ===== + +func TestComputeStateUpdate_MultipleUsersIsolation(t *testing.T) { + t.Run("two users with different tracks playing simultaneously", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userA := int64(1) + userB := int64(2) + + trackA := createTestTrack("Song A", "Artist A", "http://spotify/trackA", 240000, 5000) + trackB := createTestTrack("Song B", "Artist B", "http://spotify/trackB", 180000, 10000) + + respA := &SpotifyTrackResponse{Track: trackA, IsPlaying: true} + respB := &SpotifyTrackResponse{Track: trackB, IsPlaying: true} + + // Both users start playing + actionA := svc.computeStateUpdate(userA, respA) + actionB := svc.computeStateUpdate(userB, respB) + + // Both should publish now playing + if !actionA.publishNowPlaying { + t.Error("Expected User A to publishNowPlaying") + } + if !actionB.publishNowPlaying { + t.Error("Expected User B to publishNowPlaying") + } + + // Verify states are independent + stateA := svc.userPlayStates[userA] + stateB := svc.userPlayStates[userB] + + if stateA.track.URL != trackA.URL { + t.Errorf("User A has wrong track: expected %s, got %s", trackA.URL, stateA.track.URL) + } + if stateB.track.URL != trackB.URL { + t.Errorf("User B has wrong track: expected %s, got %s", trackB.URL, stateB.track.URL) + } + if stateA.accumulatedMs != 5000 { + t.Errorf("User A accumulatedMs: expected 5000, got %d", stateA.accumulatedMs) + } + if stateB.accumulatedMs != 10000 { + t.Errorf("User B accumulatedMs: expected 10000, got %d", stateB.accumulatedMs) + } + }) + + t.Run("one user's track change doesn't reset another user's accumulated time", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userA := int64(1) + userB := int64(2) + + trackA := createTestTrack("Song A", "Artist A", "http://spotify/trackA", 240000, 0) + trackB := createTestTrack("Song B", "Artist B", "http://spotify/trackB", 180000, 0) + + // Set up existing states + svc.userPlayStates[userA] = &userPlayState{ + track: trackA, + accumulatedMs: 100000, // 100 seconds accumulated + lastPollTime: time.Now(), + hasStamped: false, + isPaused: false, + } + svc.userPlayStates[userB] = &userPlayState{ + track: trackB, + accumulatedMs: 50000, // 50 seconds accumulated + lastPollTime: time.Now(), + hasStamped: false, + isPaused: false, + } + + // User A changes track + newTrackA := createTestTrack("New Song A", "Artist A", "http://spotify/trackA2", 200000, 5000) + respA := &SpotifyTrackResponse{Track: newTrackA, IsPlaying: true} + svc.computeStateUpdate(userA, respA) + + // User A should have reset state + stateA := svc.userPlayStates[userA] + if stateA.accumulatedMs != 5000 { + t.Errorf("User A should have reset accumulatedMs to 5000, got %d", stateA.accumulatedMs) + } + + // User B should be unchanged + stateB := svc.userPlayStates[userB] + if stateB.accumulatedMs != 50000 { + t.Errorf("User B accumulatedMs should remain 50000, got %d", stateB.accumulatedMs) + } + if stateB.track.URL != trackB.URL { + t.Errorf("User B track should be unchanged") + } + }) + + t.Run("one user's stamp doesn't affect another user's stamp status", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userA := int64(1) + userB := int64(2) + + trackA := createTestTrack("Song A", "Artist A", "http://spotify/trackA", 240000, 0) + trackB := createTestTrack("Song B", "Artist B", "http://spotify/trackB", 180000, 0) + + // User A is above stamp threshold, User B is below + svc.userPlayStates[userA] = &userPlayState{ + track: trackA, + accumulatedMs: 125000, // Above threshold (120000 for 4 min track) + lastPollTime: time.Now(), + hasStamped: false, + isPaused: false, + } + svc.userPlayStates[userB] = &userPlayState{ + track: trackB, + accumulatedMs: 50000, // Below threshold (90000 for 3 min track) + lastPollTime: time.Now(), + hasStamped: false, + isPaused: false, + } + + respA := &SpotifyTrackResponse{Track: trackA, IsPlaying: true} + respB := &SpotifyTrackResponse{Track: trackB, IsPlaying: true} + + actionA := svc.computeStateUpdate(userA, respA) + actionB := svc.computeStateUpdate(userB, respB) + + // User A should stamp + if !actionA.stampTrack { + t.Error("Expected User A to stamp") + } + if !svc.userPlayStates[userA].hasStamped { + t.Error("User A hasStamped should be true") + } + + // User B should NOT stamp + if actionB.stampTrack { + t.Error("User B should NOT stamp") + } + if svc.userPlayStates[userB].hasStamped { + t.Error("User B hasStamped should remain false") + } + }) +} + +func TestComputeStateUpdate_MultipleUsersDifferentStates(t *testing.T) { + t.Run("user A playing, user B paused", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userA := int64(1) + userB := int64(2) + + trackA := createTestTrack("Song A", "Artist A", "http://spotify/trackA", 240000, 5000) + trackB := createTestTrack("Song B", "Artist B", "http://spotify/trackB", 180000, 30000) + + respA := &SpotifyTrackResponse{Track: trackA, IsPlaying: true} + respB := &SpotifyTrackResponse{Track: trackB, IsPlaying: false} // paused + + actionA := svc.computeStateUpdate(userA, respA) + actionB := svc.computeStateUpdate(userB, respB) + + // User A should publish now playing + if !actionA.publishNowPlaying { + t.Error("User A should publishNowPlaying") + } + if actionA.clearNowPlaying { + t.Error("User A should NOT clearNowPlaying") + } + + // User B should clear now playing (paused) + if !actionB.clearNowPlaying { + t.Error("User B should clearNowPlaying") + } + if actionB.publishNowPlaying { + t.Error("User B should NOT publishNowPlaying") + } + + // Verify states + stateA := svc.userPlayStates[userA] + stateB := svc.userPlayStates[userB] + + if stateA.isPaused { + t.Error("User A should NOT be paused") + } + if !stateB.isPaused { + t.Error("User B should be paused") + } + }) + + t.Run("user A pauses while user B continues playing", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userA := int64(1) + userB := int64(2) + + trackA := createTestTrack("Song A", "Artist A", "http://spotify/trackA", 240000, 0) + trackB := createTestTrack("Song B", "Artist B", "http://spotify/trackB", 180000, 0) + + // Both users are playing + pastTime := time.Now().Add(-10 * time.Second) + svc.userPlayStates[userA] = &userPlayState{ + track: trackA, + accumulatedMs: 60000, + lastPollTime: pastTime, + hasStamped: false, + isPaused: false, + } + svc.userPlayStates[userB] = &userPlayState{ + track: trackB, + accumulatedMs: 40000, + lastPollTime: pastTime, + hasStamped: false, + isPaused: false, + } + + // User A pauses, User B continues + respA := &SpotifyTrackResponse{Track: trackA, IsPlaying: false} + respB := &SpotifyTrackResponse{Track: trackB, IsPlaying: true} + + actionA := svc.computeStateUpdate(userA, respA) + actionB := svc.computeStateUpdate(userB, respB) + + // User A should clear + if !actionA.clearNowPlaying { + t.Error("User A should clearNowPlaying") + } + + // User B should NOT clear and should NOT publish (same track continuing) + if actionB.clearNowPlaying { + t.Error("User B should NOT clearNowPlaying") + } + if actionB.publishNowPlaying { + t.Error("User B should NOT publishNowPlaying (same track continuing)") + } + + // User A should be paused, User B should not + if !svc.userPlayStates[userA].isPaused { + t.Error("User A should be paused") + } + if svc.userPlayStates[userB].isPaused { + t.Error("User B should NOT be paused") + } + + // User B should have accumulated more time (~10s) + stateB := svc.userPlayStates[userB] + if stateB.accumulatedMs != 50000 { + t.Errorf("User B accumulatedMs should be ~50000, got %d", stateB.accumulatedMs) + } + }) + + t.Run("user A resumes while user B is already playing", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userA := int64(1) + userB := int64(2) + + trackA := createTestTrack("Song A", "Artist A", "http://spotify/trackA", 240000, 0) + trackB := createTestTrack("Song B", "Artist B", "http://spotify/trackB", 180000, 0) + + // User A is paused, User B is playing + svc.userPlayStates[userA] = &userPlayState{ + track: trackA, + accumulatedMs: 60000, + lastPollTime: time.Now(), + hasStamped: false, + isPaused: true, + } + svc.userPlayStates[userB] = &userPlayState{ + track: trackB, + accumulatedMs: 40000, + lastPollTime: time.Now(), + hasStamped: false, + isPaused: false, + } + + // User A resumes, User B continues + respA := &SpotifyTrackResponse{Track: trackA, IsPlaying: true} + respB := &SpotifyTrackResponse{Track: trackB, IsPlaying: true} + + actionA := svc.computeStateUpdate(userA, respA) + actionB := svc.computeStateUpdate(userB, respB) + + // User A should publish (resuming from pause) + if !actionA.publishNowPlaying { + t.Error("User A should publishNowPlaying on resume") + } + + // User B should NOT publish (same track continuing) + if actionB.publishNowPlaying { + t.Error("User B should NOT publishNowPlaying (same track continuing)") + } + + // Both should not be paused + if svc.userPlayStates[userA].isPaused { + t.Error("User A should NOT be paused after resume") + } + if svc.userPlayStates[userB].isPaused { + t.Error("User B should NOT be paused") + } + }) +} + +func TestComputeStateUpdate_MultipleUsersStampThreshold(t *testing.T) { + t.Run("user A reaches stamp threshold, user B doesn't", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userA := int64(1) + userB := int64(2) + + // Both have same duration track (threshold = 120000) + trackA := createTestTrack("Song A", "Artist A", "http://spotify/trackA", 240000, 0) + trackB := createTestTrack("Song B", "Artist B", "http://spotify/trackB", 240000, 0) + + // User A is past threshold, User B is not + svc.userPlayStates[userA] = &userPlayState{ + track: trackA, + accumulatedMs: 125000, + lastPollTime: time.Now(), + hasStamped: false, + isPaused: false, + } + svc.userPlayStates[userB] = &userPlayState{ + track: trackB, + accumulatedMs: 60000, + lastPollTime: time.Now(), + hasStamped: false, + isPaused: false, + } + + respA := &SpotifyTrackResponse{Track: trackA, IsPlaying: true} + respB := &SpotifyTrackResponse{Track: trackB, IsPlaying: true} + + actionA := svc.computeStateUpdate(userA, respA) + actionB := svc.computeStateUpdate(userB, respB) + + if !actionA.stampTrack { + t.Error("User A should stamp") + } + if actionB.stampTrack { + t.Error("User B should NOT stamp") + } + }) + + t.Run("both users reach threshold at different accumulated times", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userA := int64(1) + userB := int64(2) + + // Different duration tracks, different thresholds + trackA := createTestTrack("Song A", "Artist A", "http://spotify/trackA", 240000, 0) // threshold = 120000 + trackB := createTestTrack("Song B", "Artist B", "http://spotify/trackB", 50000, 0) // threshold = 30000 (max(25000, 30000)) + + // Both are past their respective thresholds + svc.userPlayStates[userA] = &userPlayState{ + track: trackA, + accumulatedMs: 125000, + lastPollTime: time.Now(), + hasStamped: false, + isPaused: false, + } + svc.userPlayStates[userB] = &userPlayState{ + track: trackB, + accumulatedMs: 35000, + lastPollTime: time.Now(), + hasStamped: false, + isPaused: false, + } + + respA := &SpotifyTrackResponse{Track: trackA, IsPlaying: true} + respB := &SpotifyTrackResponse{Track: trackB, IsPlaying: true} + + actionA := svc.computeStateUpdate(userA, respA) + actionB := svc.computeStateUpdate(userB, respB) + + // Both should stamp + if !actionA.stampTrack { + t.Error("User A should stamp") + } + if !actionB.stampTrack { + t.Error("User B should stamp") + } + + // Both should have hasStamped = true + if !svc.userPlayStates[userA].hasStamped { + t.Error("User A hasStamped should be true") + } + if !svc.userPlayStates[userB].hasStamped { + t.Error("User B hasStamped should be true") + } + }) + + t.Run("one user loops track while another continues - independent loop detection", func(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + svc := newTestService(database, nil) + userA := int64(1) + userB := int64(2) + + // User A has a short track that will loop + trackA := createTestTrack("Short Song", "Artist A", "http://spotify/trackA", 100000, 0) + trackB := createTestTrack("Long Song", "Artist B", "http://spotify/trackB", 300000, 0) + + // User A is past their track duration (will trigger loop) + // User B is still in the middle of their track + svc.userPlayStates[userA] = &userPlayState{ + track: trackA, + accumulatedMs: 105000, // Past 100000 duration + lastPollTime: time.Now(), + hasStamped: true, + isPaused: false, + } + svc.userPlayStates[userB] = &userPlayState{ + track: trackB, + accumulatedMs: 100000, // Still less than 300000 duration + lastPollTime: time.Now(), + hasStamped: false, + isPaused: false, + } + + respA := &SpotifyTrackResponse{Track: trackA, IsPlaying: true} + respB := &SpotifyTrackResponse{Track: trackB, IsPlaying: true} + + svc.computeStateUpdate(userA, respA) + svc.computeStateUpdate(userB, respB) + + stateA := svc.userPlayStates[userA] + stateB := svc.userPlayStates[userB] + + // User A should have looped: accumulatedMs reduced, hasStamped reset + if stateA.accumulatedMs >= trackA.DurationMs { + t.Errorf("User A should have looped, accumulatedMs=%d should be < %d", stateA.accumulatedMs, trackA.DurationMs) + } + if stateA.hasStamped { + t.Error("User A hasStamped should be reset to false after loop") + } + + // User B should NOT have looped + if stateB.accumulatedMs < 100000 { + t.Errorf("User B should NOT have looped, accumulatedMs=%d", stateB.accumulatedMs) + } + // User B should still not be stamped (threshold is 150000 for 300000ms track) + if stateB.hasStamped { + t.Error("User B hasStamped should still be false (not reached threshold yet)") + } + }) +} -- 2.51.2