package dlna import ( "context" "sync" "testing" "time" "github.com/stretchr/testify/assert" ) // mockSceneWriter is a mock implementation of SceneActivityWriter type mockSceneWriter struct { mu sync.Mutex saveActivityCalls []saveActivityCall addViewsCalls []addViewsCall } type saveActivityCall struct { sceneID int resumeTime *float64 playDuration *float64 } type addViewsCall struct { sceneID int dates []time.Time } func (m *mockSceneWriter) SaveActivity(_ context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error) { m.mu.Lock() m.saveActivityCalls = append(m.saveActivityCalls, saveActivityCall{ sceneID: sceneID, resumeTime: resumeTime, playDuration: playDuration, }) m.mu.Unlock() return true, nil } func (m *mockSceneWriter) AddViews(_ context.Context, sceneID int, dates []time.Time) ([]time.Time, error) { m.mu.Lock() m.addViewsCalls = append(m.addViewsCalls, addViewsCall{ sceneID: sceneID, dates: dates, }) m.mu.Unlock() return dates, nil } // mockConfig is a mock implementation of ActivityConfig type mockConfig struct { enabled bool minPlayPercent int } func (c *mockConfig) GetDLNAActivityTrackingEnabled() bool { return c.enabled } func (c *mockConfig) GetMinimumPlayPercent() int { return c.minPlayPercent } func TestStreamSession_PercentWatched(t *testing.T) { now := time.Now() tests := []struct { name string startTime time.Time lastActivity time.Time videoDuration float64 expected float64 }{ { name: "no video duration", startTime: now.Add(-60 * time.Second), lastActivity: now, videoDuration: 0, expected: 0, }, { name: "half watched", startTime: now.Add(-60 * time.Second), lastActivity: now, videoDuration: 120.0, // 2 minutes, watched for 1 minute = 50% expected: 50.0, }, { name: "fully watched", startTime: now.Add(-120 * time.Second), lastActivity: now, videoDuration: 120.0, // 2 minutes, watched for 2 minutes = 100% expected: 100.0, }, { name: "quarter watched", startTime: now.Add(-30 * time.Second), lastActivity: now, videoDuration: 120.0, // 2 minutes, watched for 30 seconds = 25% expected: 25.0, }, { name: "elapsed exceeds duration - capped at 100%", startTime: now.Add(-180 * time.Second), lastActivity: now, videoDuration: 120.0, // 2 minutes, but 3 minutes elapsed = capped at 100% expected: 100.0, }, { name: "no elapsed time", startTime: now, lastActivity: now, videoDuration: 120.0, expected: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { session := &streamSession{ StartTime: tt.startTime, LastActivity: tt.lastActivity, VideoDuration: tt.videoDuration, } result := session.percentWatched() assert.InDelta(t, tt.expected, result, 0.01) }) } } func TestStreamSession_EstimatedResumeTime(t *testing.T) { now := time.Now() tests := []struct { name string startTime time.Time lastActivity time.Time videoDuration float64 expected float64 }{ { name: "no elapsed time", startTime: now, lastActivity: now, videoDuration: 120.0, expected: 0, }, { name: "half way through", startTime: now.Add(-60 * time.Second), lastActivity: now, videoDuration: 120.0, // 2 minutes, watched for 1 minute = resume at 60s expected: 60.0, }, { name: "quarter way through", startTime: now.Add(-30 * time.Second), lastActivity: now, videoDuration: 120.0, // 2 minutes, watched for 30 seconds = resume at 30s expected: 30.0, }, { name: "98% complete - should reset to 0", startTime: now.Add(-118 * time.Second), lastActivity: now, videoDuration: 120.0, // 98.3% elapsed, should reset expected: 0, }, { name: "100% complete - should reset to 0", startTime: now.Add(-120 * time.Second), lastActivity: now, videoDuration: 120.0, expected: 0, }, { name: "elapsed exceeds duration - capped and reset to 0", startTime: now.Add(-180 * time.Second), lastActivity: now, videoDuration: 120.0, // 150% elapsed, capped at 100%, reset to 0 expected: 0, }, { name: "no video duration", startTime: now.Add(-60 * time.Second), lastActivity: now, videoDuration: 0, expected: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { session := &streamSession{ StartTime: tt.startTime, LastActivity: tt.lastActivity, VideoDuration: tt.videoDuration, } result := session.estimatedResumeTime() assert.InDelta(t, tt.expected, result, 1.0) // Allow 1 second tolerance }) } } func TestSessionKey(t *testing.T) { key := sessionKey("192.168.1.100", 42) assert.Equal(t, "192.168.1.100:42", key) } func TestActivityTracker_RecordRequest(t *testing.T) { config := &mockConfig{enabled: true, minPlayPercent: 50} // Create tracker without starting the goroutine (for unit testing) tracker := &ActivityTracker{ txnManager: nil, // Don't need DB for this test sceneWriter: nil, config: config, sessionTimeout: DefaultSessionTimeout, sessions: make(map[string]*streamSession), } // Record first request - should create new session tracker.RecordRequest(42, "192.168.1.100", 120.0) tracker.mutex.RLock() session := tracker.sessions["192.168.1.100:42"] tracker.mutex.RUnlock() assert.NotNil(t, session) assert.Equal(t, 42, session.SceneID) assert.Equal(t, "192.168.1.100", session.ClientIP) assert.Equal(t, 120.0, session.VideoDuration) assert.False(t, session.StartTime.IsZero()) assert.False(t, session.LastActivity.IsZero()) // Record second request - should update LastActivity firstActivity := session.LastActivity time.Sleep(10 * time.Millisecond) tracker.RecordRequest(42, "192.168.1.100", 120.0) tracker.mutex.RLock() session = tracker.sessions["192.168.1.100:42"] tracker.mutex.RUnlock() assert.True(t, session.LastActivity.After(firstActivity)) } func TestActivityTracker_DisabledTracking(t *testing.T) { config := &mockConfig{enabled: false, minPlayPercent: 50} // Create tracker without starting the goroutine (for unit testing) tracker := &ActivityTracker{ txnManager: nil, sceneWriter: nil, config: config, sessionTimeout: DefaultSessionTimeout, sessions: make(map[string]*streamSession), } // Record request - should be ignored when tracking is disabled tracker.RecordRequest(42, "192.168.1.100", 120.0) tracker.mutex.RLock() sessionCount := len(tracker.sessions) tracker.mutex.RUnlock() assert.Equal(t, 0, sessionCount) } func TestActivityTracker_SessionExpiration(t *testing.T) { // For this test, we'll test the session expiration logic directly // without the full transaction manager integration sceneWriter := &mockSceneWriter{} config := &mockConfig{enabled: true, minPlayPercent: 10} // Create a tracker with nil txnManager - we'll test processCompletedSession separately // Here we just verify the session management logic tracker := &ActivityTracker{ txnManager: nil, // Skip DB calls for this test sceneWriter: sceneWriter, config: config, sessionTimeout: 100 * time.Millisecond, sessions: make(map[string]*streamSession), } // Manually add a session // Use a short video duration (1 second) so the test can verify expiration quickly. now := time.Now() tracker.sessions["192.168.1.100:42"] = &streamSession{ SceneID: 42, ClientIP: "192.168.1.100", StartTime: now.Add(-5 * time.Second), // Started 5 seconds ago LastActivity: now.Add(-200 * time.Millisecond), // Last activity 200ms ago (> 100ms timeout) VideoDuration: 1.0, // Short video so timeSinceStart > videoDuration } // Verify session exists assert.Len(t, tracker.sessions, 1) // Process expired sessions - this will try to save activity but txnManager is nil // so it will skip the DB calls but still remove the session tracker.processExpiredSessions() // Verify session was removed (even though DB calls were skipped) assert.Len(t, tracker.sessions, 0) } func TestActivityTracker_SessionExpiration_StoppedEarly(t *testing.T) { // Test that sessions expire when user stops watching early (before video ends) // This was a bug where sessions wouldn't expire until video duration passed config := &mockConfig{enabled: true, minPlayPercent: 10} tracker := &ActivityTracker{ txnManager: nil, sceneWriter: nil, config: config, sessionTimeout: 100 * time.Millisecond, sessions: make(map[string]*streamSession), } // User started watching a 30-minute video but stopped after 5 seconds now := time.Now() tracker.sessions["192.168.1.100:42"] = &streamSession{ SceneID: 42, ClientIP: "192.168.1.100", StartTime: now.Add(-5 * time.Second), // Started 5 seconds ago LastActivity: now.Add(-200 * time.Millisecond), // Last activity 200ms ago (> 100ms timeout) VideoDuration: 1800.0, // 30 minute video - much longer than elapsed time } assert.Len(t, tracker.sessions, 1) // Session should expire because timeSinceActivity > timeout // Even though the video is 30 minutes and only 5 seconds have passed tracker.processExpiredSessions() // Verify session was expired assert.Len(t, tracker.sessions, 0, "Session should expire when user stops early, not wait for video duration") } func TestActivityTracker_MinimumPlayPercentThreshold(t *testing.T) { // Test the threshold logic without full transaction integration config := &mockConfig{enabled: true, minPlayPercent: 75} // High threshold tracker := &ActivityTracker{ txnManager: nil, sceneWriter: nil, config: config, sessionTimeout: 50 * time.Millisecond, sessions: make(map[string]*streamSession), } // Test that getMinimumPlayPercent returns the configured value assert.Equal(t, 75, tracker.getMinimumPlayPercent()) // Create a session with 30% watched (36 seconds of a 120 second video) now := time.Now() session := &streamSession{ SceneID: 42, StartTime: now.Add(-36 * time.Second), LastActivity: now, VideoDuration: 120.0, } // 30% is below 75% threshold percentWatched := session.percentWatched() assert.InDelta(t, 30.0, percentWatched, 0.1) assert.False(t, percentWatched >= float64(tracker.getMinimumPlayPercent())) } func TestActivityTracker_MultipleSessions(t *testing.T) { config := &mockConfig{enabled: true, minPlayPercent: 50} // Create tracker without starting the goroutine (for unit testing) tracker := &ActivityTracker{ txnManager: nil, sceneWriter: nil, config: config, sessionTimeout: DefaultSessionTimeout, sessions: make(map[string]*streamSession), } // Different clients watching same scene tracker.RecordRequest(42, "192.168.1.100", 120.0) tracker.RecordRequest(42, "192.168.1.101", 120.0) // Same client watching different scenes tracker.RecordRequest(43, "192.168.1.100", 180.0) tracker.mutex.RLock() assert.Len(t, tracker.sessions, 3) tracker.mutex.RUnlock() } func TestActivityTracker_ShortSessionIgnored(t *testing.T) { // Test that short sessions are ignored // Create a session with only ~0.8% watched (1 second of a 120 second video) now := time.Now() session := &streamSession{ SceneID: 42, ClientIP: "192.168.1.100", StartTime: now.Add(-1 * time.Second), // Only 1 second LastActivity: now, VideoDuration: 120.0, // 2 minutes } // Verify percent watched is below threshold (1s / 120s = 0.83%) assert.InDelta(t, 0.83, session.percentWatched(), 0.1) // Verify elapsed time is short elapsed := session.LastActivity.Sub(session.StartTime).Seconds() assert.InDelta(t, 1.0, elapsed, 0.5) // Both are below the minimum thresholds (1% and 5 seconds) percentWatched := session.percentWatched() shouldSkip := percentWatched < 1 && elapsed < 5 assert.True(t, shouldSkip, "Short session should be skipped") }