""" Test suite for the VIDEO_RETENTION feature. Tests code paths, configuration, and template correctness for video retention. Does NOT require a running server or real video files - uses static analysis and mocking where possible. Run with: python tests/test_video_retention.py """ import os import re import sys import json import unittest from unittest.mock import patch, MagicMock from pathlib import Path # Find project root TEST_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_ROOT = os.path.dirname(TEST_DIR) sys.path.insert(0, PROJECT_ROOT) class TestVideoRetentionConfig(unittest.TestCase): """Test that VIDEO_RETENTION env var is read correctly everywhere.""" ALL_FILES = [ 'src/app.py', 'src/tasks/processing.py', 'src/api/system.py', 'src/api/recordings.py', 'src/file_monitor.py', ] def _read_file(self, rel_path): with open(os.path.join(PROJECT_ROOT, rel_path), 'r') as f: return f.read() def test_env_var_read_in_all_entry_points(self): """VIDEO_RETENTION env var is read in all files that need it.""" for rel_path in self.ALL_FILES: content = self._read_file(rel_path) self.assertIn("VIDEO_RETENTION", content, f"VIDEO_RETENTION missing from {rel_path}") def test_exposed_in_api_config(self): """VIDEO_RETENTION is exposed in the /api/config response.""" content = self._read_file('src/api/system.py') self.assertIn("'video_retention': VIDEO_RETENTION", content) def test_default_is_false(self): """All VIDEO_RETENTION reads default to 'false'.""" for rel_path in self.ALL_FILES: content = self._read_file(rel_path) match = re.search(r"VIDEO_RETENTION\s*=\s*os\.environ\.get\('VIDEO_RETENTION',\s*'(\w+)'\)", content) if match: self.assertEqual(match.group(1), 'false', f"Default should be 'false' in {rel_path}") class TestProcessingPipelineVideoRetention(unittest.TestCase): """Test processing.py video retention code paths via static analysis.""" @classmethod def setUpClass(cls): with open(os.path.join(PROJECT_ROOT, 'src/tasks/processing.py'), 'r') as f: cls.content = f.read() def test_video_retention_true_keeps_original(self): """When VIDEO_RETENTION=True, recording.audio_path is set to original filepath.""" # The VIDEO_RETENTION=True branch should set recording.audio_path = filepath self.assertIn('recording.audio_path = filepath', self.content) def test_video_retention_true_extracts_without_cleanup(self): """When VIDEO_RETENTION=True, extract_audio_from_video is called with cleanup_original=False.""" self.assertIn('extract_audio_from_video(filepath, cleanup_original=False)', self.content) def test_video_retention_false_extracts_with_cleanup(self): """When VIDEO_RETENTION=False, extract_audio_from_video is called with default cleanup.""" self.assertIn('extract_audio_from_video(filepath)', self.content) def test_temp_audio_cleanup_after_transcription(self): """Temp audio from video retention is cleaned up after transcription.""" self.assertIn('is_video and VIDEO_RETENTION and audio_filepath', self.content) self.assertIn('Cleaned up temp audio from video retention', self.content) def test_audio_filepath_initialized_to_none(self): """audio_filepath is initialized to None before the is_video check.""" # Find the initialization line self.assertIn('audio_filepath = None', self.content) def test_video_mime_type_set_for_retention(self): """When retaining video, mime_type reflects actual video type.""" self.assertIn("mimetypes.guess_type(filepath)[0] or 'video/mp4'", self.content) def test_duration_uses_recording_audio_path(self): """Duration lookup uses recording.audio_path (always valid), not filepath.""" self.assertIn('chunking_service.get_audio_duration(recording.audio_path)', self.content) # Should NOT use bare filepath for duration (pre-existing bug was fixed) self.assertNotIn('chunking_service.get_audio_duration(filepath)', self.content) class TestUploadHandlerVideoRetention(unittest.TestCase): """Test recordings.py upload handler video retention code paths.""" @classmethod def setUpClass(cls): with open(os.path.join(PROJECT_ROOT, 'src/api/recordings.py'), 'r') as f: cls.content = f.read() def test_upload_handler_skips_conversion_for_video_retention(self): """Upload handler skips convert_if_needed for videos when retention is on.""" self.assertIn('VIDEO_RETENTION and has_video', self.content) self.assertIn('skipping conversion', self.content) def test_upload_handler_has_video_from_codec_info(self): """Upload handler reads has_video from codec_info probe.""" self.assertIn("has_video = codec_info.get('has_video', False)", self.content) def test_convert_if_needed_still_in_else_branch(self): """convert_if_needed still runs for non-video files or when retention is off.""" self.assertIn('convert_if_needed(', self.content) def test_processing_pipeline_still_converts_audio(self): """Processing pipeline runs convert_if_needed on extracted audio (the safety net).""" proc_content = open(os.path.join(PROJECT_ROOT, 'src/tasks/processing.py')).read() # After the video extraction block, convert_if_needed runs on actual_filepath self.assertIn('conversion_result = convert_if_needed(\n' ' filepath=actual_filepath,', proc_content) class TestFileMonitorVideoRetention(unittest.TestCase): """Test file_monitor.py video retention code paths.""" @classmethod def setUpClass(cls): with open(os.path.join(PROJECT_ROOT, 'src/file_monitor.py'), 'r') as f: cls.content = f.read() def test_video_retention_skips_conversion(self): """When VIDEO_RETENTION=True and has_video=True, convert_if_needed is skipped.""" # Should have the guard: if VIDEO_RETENTION and has_video: ... skip conversion self.assertIn('VIDEO_RETENTION and has_video', self.content) self.assertIn('skipping conversion', self.content) def test_no_double_extraction(self): """File monitor does NOT call convert_if_needed for videos when retention is on.""" # The convert_if_needed call should be in the else branch lines = self.content.split('\n') in_retention_skip_block = False found_convert_in_else = False for i, line in enumerate(lines): if 'VIDEO_RETENTION and has_video' in line and 'if' in line: in_retention_skip_block = True elif in_retention_skip_block and 'else:' in line: in_retention_skip_block = False found_convert_in_else = True elif in_retention_skip_block and 'convert_if_needed' in line: self.fail(f"convert_if_needed called inside VIDEO_RETENTION skip block at line {i+1}") self.assertTrue(found_convert_in_else, "Should have else branch after video retention skip") def test_no_video_retention_param_in_convert_call(self): """convert_if_needed should NOT receive a video_retention parameter.""" # Ensure the old video_retention parameter isn't being passed self.assertNotIn('video_retention=VIDEO_RETENTION', self.content) class TestAudioConversionNotModified(unittest.TestCase): """Verify audio_conversion.py was fully reverted (no video_retention parameter).""" @classmethod def setUpClass(cls): with open(os.path.join(PROJECT_ROOT, 'src/utils/audio_conversion.py'), 'r') as f: cls.content = f.read() def test_no_video_retention_parameter(self): """convert_if_needed should not have a video_retention parameter.""" self.assertNotIn('video_retention', self.content) def test_no_should_delete_original(self): """No should_delete_original variable should exist.""" self.assertNotIn('should_delete_original', self.content) class TestSendFileConditional(unittest.TestCase): """Test that send_file calls use conditional=True for range request support.""" def _read_file(self, rel_path): with open(os.path.join(PROJECT_ROOT, rel_path), 'r') as f: return f.read() def test_recordings_streaming_has_conditional(self): """Streaming send_file in recordings.py has conditional=True.""" content = self._read_file('src/api/recordings.py') # Find the non-download send_file call self.assertIn('send_file(recording.audio_path, conditional=True)', content) def test_recordings_download_has_conditional(self): """Download send_file in recordings.py has conditional=True.""" content = self._read_file('src/api/recordings.py') self.assertIn('as_attachment=True, download_name=filename, conditional=True', content) def test_shares_has_conditional(self): """send_file in shares.py has conditional=True.""" content = self._read_file('src/api/shares.py') self.assertIn('send_file(recording.audio_path, conditional=True)', content) class TestFrontendTemplates(unittest.TestCase): """Test that frontend templates correctly switch between video and audio.""" TEMPLATE_FILES = [ 'templates/components/detail/desktop-right-panel.html', 'templates/components/detail/audio-player.html', 'templates/modals/speaker-modal.html', 'templates/share.html', ] def _read_template(self, rel_path): with open(os.path.join(PROJECT_ROOT, rel_path), 'r') as f: return f.read() def test_all_templates_use_dynamic_component(self): """All player templates use for video/audio switching.""" for tmpl in self.TEMPLATE_FILES: content = self._read_template(tmpl) self.assertIn("", content, f"Missing in {tmpl}") def test_no_bare_audio_elements_in_main_players(self): """Main player templates should not have bare