diff --git a/backend/tasks/tests/conftest.py b/backend/tasks/tests/conftest.py index a0dd61e9b..f0edd04cb 100644 --- a/backend/tasks/tests/conftest.py +++ b/backend/tasks/tests/conftest.py @@ -1,5 +1,4 @@ -import zipfile -from io import BytesIO +import os import pytest from tasks.update_launchbox_metadata import UpdateLaunchboxMetadataTask @@ -13,93 +12,11 @@ def task(): @pytest.fixture def sample_zip_content(): - """Create sample ZIP content with XML files""" - zip_buffer = BytesIO() + test_dir = os.path.dirname(__file__) + sample_path = os.path.join(test_dir, "fixtures", "sample_metadata.zip") - with zipfile.ZipFile(zip_buffer, "w") as zip_file: - # Add Platforms.xml - platforms_xml = """ - - - Nintendo 64 - Console - 1996-06-23 - - - PlayStation - Console - 1994-12-03 - -""" - zip_file.writestr("Platforms.xml", platforms_xml) - - # Add Metadata.xml - metadata_xml = """ - - - 67890 - Crash Bandicoot - PlayStation - 1996-09-09 - Platformer - - - 12345 - Super Mario 64 - Nintendo 64 - 1996-06-23 - Platformer - - - Super Mario 64 (USA) - 12345 - - - 12345 - super_mario_64.jpg - Cover - - - 12345 - super_mario_64_screenshot.jpg - Screenshot - -""" - zip_file.writestr("Metadata.xml", metadata_xml) - - # Add Mame.xml - mame_xml = """ - - - mario.zip - mario - Super Mario Bros. - - - pacman.zip - pacman - Pac-Man - -""" - zip_file.writestr("Mame.xml", mame_xml) - - # Add Files.xml - files_xml = """ - - - super_mario_64.z64 - ROM - 8388608 - - - crash_bandicoot.bin - ROM - 524288000 - -""" - zip_file.writestr("Files.xml", files_xml) - - return zip_buffer.getvalue() + with open(sample_path, "rb") as f: + return f.read() @pytest.fixture diff --git a/backend/tasks/tests/fixtures/Platforms.xml b/backend/tasks/tests/fixtures/Platforms.xml new file mode 100644 index 000000000..434defa64 --- /dev/null +++ b/backend/tasks/tests/fixtures/Platforms.xml @@ -0,0 +1,11 @@ + + + + + Console + + + Valid Platform + + + diff --git a/backend/tasks/tests/fixtures/sample_metadata.zip b/backend/tasks/tests/fixtures/sample_metadata.zip new file mode 100644 index 000000000..a4fb8c689 Binary files /dev/null and b/backend/tasks/tests/fixtures/sample_metadata.zip differ diff --git a/backend/tasks/tests/fixtures/sample_metadata/Files.xml b/backend/tasks/tests/fixtures/sample_metadata/Files.xml new file mode 100644 index 000000000..90de95749 --- /dev/null +++ b/backend/tasks/tests/fixtures/sample_metadata/Files.xml @@ -0,0 +1,13 @@ + + + + super_mario_64.z64 + ROM + 8388608 + + + crash_bandicoot.bin + ROM + 524288000 + + diff --git a/backend/tasks/tests/fixtures/sample_metadata/Mame.xml b/backend/tasks/tests/fixtures/sample_metadata/Mame.xml new file mode 100644 index 000000000..7cf2f7eec --- /dev/null +++ b/backend/tasks/tests/fixtures/sample_metadata/Mame.xml @@ -0,0 +1,13 @@ + + + + mario.zip + mario + Super Mario Bros. + + + pacman.zip + pacman + Pac-Man + + diff --git a/backend/tasks/tests/fixtures/sample_metadata/Metadata.xml b/backend/tasks/tests/fixtures/sample_metadata/Metadata.xml new file mode 100644 index 000000000..ba7b54ad2 --- /dev/null +++ b/backend/tasks/tests/fixtures/sample_metadata/Metadata.xml @@ -0,0 +1,31 @@ + + + + 67890 + Crash Bandicoot + PlayStation + 1996-09-09 + Platformer + + + 12345 + Super Mario 64 + Nintendo 64 + 1996-06-23 + Platformer + + + Super Mario 64 (USA) + 12345 + + + 12345 + super_mario_64.jpg + Cover + + + 12345 + super_mario_64_screenshot.jpg + Screenshot + + diff --git a/backend/tasks/tests/fixtures/sample_metadata_with_empty_elements.zip b/backend/tasks/tests/fixtures/sample_metadata_with_empty_elements.zip new file mode 100644 index 000000000..571a76000 Binary files /dev/null and b/backend/tasks/tests/fixtures/sample_metadata_with_empty_elements.zip differ diff --git a/backend/tasks/tests/test_scan_library.py b/backend/tasks/tests/test_scan_library.py new file mode 100644 index 000000000..a93487885 --- /dev/null +++ b/backend/tasks/tests/test_scan_library.py @@ -0,0 +1,50 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from handler.scan_handler import ScanType +from tasks.scan_library import ScanLibraryTask, scan_library_task +from tasks.tasks import PeriodicTask + + +class TestScanLibraryTask: + @pytest.fixture + def task(self): + return ScanLibraryTask() + + def test_init(self, task): + """Test task initialization""" + assert task.func == "tasks.scan_library.scan_library_task.run" + assert task.description == "library scan" + + @patch("tasks.scan_library.ENABLE_SCHEDULED_RESCAN", True) + @patch("tasks.scan_library.scan_platforms") + @patch("tasks.scan_library.log") + async def test_run_enabled(self, mock_log, mock_scan_platforms, task): + """Test run when scheduled rescan is enabled""" + mock_scan_platforms.return_value = AsyncMock() + + await task.run() + + mock_log.info.assert_any_call("Scheduled library scan started...") + mock_scan_platforms.assert_called_once_with([], scan_type=ScanType.UNIDENTIFIED) + mock_log.info.assert_any_call("Scheduled library scan done") + + @patch("tasks.scan_library.ENABLE_SCHEDULED_RESCAN", False) + @patch("tasks.scan_library.scan_platforms") + @patch("tasks.scan_library.log") + async def test_run_disabled(self, mock_log, mock_scan_platforms, task): + """Test run when scheduled rescan is disabled""" + task.unschedule = MagicMock() + + await task.run() + + mock_log.info.assert_called_once_with( + "Scheduled library scan not enabled, unscheduling..." + ) + task.unschedule.assert_called_once() + mock_scan_platforms.assert_not_called() + + def test_task_instance(self): + """Test that the module-level task instance is created correctly""" + assert isinstance(scan_library_task, ScanLibraryTask) + assert scan_library_task.func == "tasks.scan_library.scan_library_task.run" diff --git a/backend/tasks/tests/test_tasks.py b/backend/tasks/tests/test_tasks.py new file mode 100644 index 000000000..c2ee0d964 --- /dev/null +++ b/backend/tasks/tests/test_tasks.py @@ -0,0 +1,300 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +from exceptions.task_exceptions import SchedulerException +from rq.job import Job +from tasks.tasks import PeriodicTask, RemoteFilePullTask, tasks_scheduler + + +class ConcretePeriodicTask(PeriodicTask): + """Concrete implementation for testing abstract PeriodicTask""" + + async def run(self, *args, **kwargs): + return "test_result" + + +class TestPeriodicTask: + @pytest.fixture + def task(self): + return ConcretePeriodicTask( + func="test.function", + description="test task", + enabled=True, + cron_string="0 0 * * *", + ) + + @pytest.fixture + def disabled_task(self): + return ConcretePeriodicTask( + func="test.disabled.function", + description="disabled task", + enabled=False, + cron_string="0 0 * * *", + ) + + def test_init(self, task): + """Test task initialization""" + assert task.func == "test.function" + assert task.description == "test task" + assert task.enabled is True + assert task.cron_string == "0 0 * * *" + + def test_init_default_description(self): + """Test that description defaults to func when not provided""" + task = ConcretePeriodicTask(func="test.function", description=None) + assert task.description == "test.function" + + @patch.object(tasks_scheduler, "get_jobs") + def test_get_existing_job_found(self, mock_get_jobs, task): + """Test finding an existing job""" + mock_job = MagicMock(spec=Job) + mock_job.func_name = "test.function" + mock_get_jobs.return_value = [mock_job] + + result = task._get_existing_job() + assert result == mock_job + + @patch.object(tasks_scheduler, "get_jobs") + def test_get_existing_job_not_found(self, mock_get_jobs, task): + """Test when no existing job is found""" + mock_job = MagicMock(spec=Job) + mock_job.func_name = "other.function" + mock_get_jobs.return_value = [mock_job] + + result = task._get_existing_job() + assert result is None + + @patch.object(tasks_scheduler, "get_jobs") + def test_get_existing_job_empty_list(self, mock_get_jobs, task): + """Test when no jobs exist""" + mock_get_jobs.return_value = [] + + result = task._get_existing_job() + assert result is None + + @patch.object(ConcretePeriodicTask, "_get_existing_job") + @patch.object(ConcretePeriodicTask, "schedule") + def test_init_enabled_no_existing_job( + self, mock_schedule, mock_get_existing_job, task + ): + """Test init when task is enabled and no existing job""" + mock_get_existing_job.return_value = None + mock_schedule.return_value = "scheduled" + + result = task.init() + + mock_schedule.assert_called_once() + assert result == "scheduled" + + @patch.object(ConcretePeriodicTask, "_get_existing_job") + @patch.object(ConcretePeriodicTask, "unschedule") + def test_init_disabled_with_existing_job( + self, mock_unschedule, mock_get_existing_job, disabled_task + ): + """Test init when task is disabled but has existing job""" + mock_job = MagicMock() + mock_get_existing_job.return_value = mock_job + mock_unschedule.return_value = "unscheduled" + + result = disabled_task.init() + + mock_unschedule.assert_called_once() + assert result == "unscheduled" + + @patch.object(ConcretePeriodicTask, "_get_existing_job") + def test_init_enabled_with_existing_job(self, mock_get_existing_job, task): + """Test init when task is enabled and job already exists""" + mock_job = MagicMock() + mock_get_existing_job.return_value = mock_job + + result = task.init() + + assert result is None # Should do nothing + + @patch.object(ConcretePeriodicTask, "_get_existing_job") + @patch.object(tasks_scheduler, "cron") + def test_schedule_success(self, mock_cron, mock_get_existing_job, task): + """Test successful scheduling""" + mock_get_existing_job.return_value = None + mock_cron.return_value = "scheduled_job" + + result = task.schedule() + + mock_cron.assert_called_once_with( + "0 0 * * *", func="test.function", repeat=None + ) + assert result == "scheduled_job" + + def test_schedule_not_enabled(self, disabled_task): + """Test scheduling when task is not enabled""" + with pytest.raises( + SchedulerException, match="Scheduled disabled task is not enabled." + ): + disabled_task.schedule() + + @patch.object(ConcretePeriodicTask, "_get_existing_job") + @patch("tasks.tasks.log") + def test_schedule_already_scheduled(self, mock_log, mock_get_existing_job, task): + """Test scheduling when job already exists""" + mock_job = MagicMock() + mock_get_existing_job.return_value = mock_job + + result = task.schedule() + + mock_log.info.assert_called_once_with("Test task is already scheduled.") + assert result is None + + def test_schedule_no_cron_string(self): + """Test scheduling with no cron string""" + task = ConcretePeriodicTask( + func="test.function", + description="test task", + enabled=True, + cron_string=None, + ) + + with patch.object(task, "_get_existing_job", return_value=None): + result = task.schedule() + assert result is None + + @patch.object(ConcretePeriodicTask, "_get_existing_job") + @patch.object(tasks_scheduler, "cancel") + @patch("tasks.tasks.log") + def test_unschedule_success( + self, mock_log, mock_cancel, mock_get_existing_job, task + ): + """Test successful unscheduling""" + mock_job = MagicMock() + mock_get_existing_job.return_value = mock_job + + task.unschedule() + + mock_cancel.assert_called_once_with(mock_job) + mock_log.info.assert_called_once_with("Test task unscheduled.") + + @patch.object(ConcretePeriodicTask, "_get_existing_job") + @patch("tasks.tasks.log") + def test_unschedule_not_scheduled(self, mock_log, mock_get_existing_job, task): + """Test unscheduling when no job exists""" + mock_get_existing_job.return_value = None + + task.unschedule() + + mock_log.info.assert_called_once_with("Test task is not scheduled.") + + async def test_run_abstract_method(self, task): + """Test that run method works in concrete implementation""" + result = await task.run() + assert result == "test_result" + + +class TestRemoteFilePullTask: + @pytest.fixture + def task(self): + return RemoteFilePullTask( + func="test.remote.function", + description="remote test task", + enabled=True, + cron_string="0 0 * * *", + url="https://example.com/data.json", + ) + + @pytest.fixture + def disabled_task(self): + return RemoteFilePullTask( + func="test.remote.disabled.function", + description="disabled remote task", + enabled=False, + url="https://example.com/data.json", + ) + + def test_init(self, task): + """Test RemoteFilePullTask initialization""" + assert task.func == "test.remote.function" + assert task.description == "remote test task" + assert task.enabled is True + assert task.url == "https://example.com/data.json" + + @patch("tasks.tasks.ctx_httpx_client") + @patch("tasks.tasks.log") + async def test_run_success(self, mock_log, mock_ctx_httpx_client, task): + """Test successful remote file pull""" + mock_client = AsyncMock() + mock_response = AsyncMock() + mock_response.content = b"test content" + mock_client.get.return_value = mock_response + mock_ctx_httpx_client.get.return_value = mock_client + + result = await task.run(force=True) + + mock_client.get.assert_called_once_with( + "https://example.com/data.json", timeout=120 + ) + mock_response.raise_for_status.assert_called_once() + mock_log.info.assert_called_once_with("Scheduled remote test task started...") + assert result == b"test content" + + @patch("tasks.tasks.ctx_httpx_client") + @patch("tasks.tasks.log") + async def test_run_http_error(self, mock_log, mock_ctx_httpx_client, task): + """Test handling of HTTP errors""" + mock_client = AsyncMock() + mock_client.get.side_effect = httpx.HTTPError("Connection failed") + mock_ctx_httpx_client.get.return_value = mock_client + + result = await task.run(force=True) + + mock_log.error.assert_called() + assert result is None + + @patch("tasks.tasks.ctx_httpx_client") + @patch("tasks.tasks.log") + async def test_run_response_error(self, mock_log, mock_ctx_httpx_client, task): + """Test handling of response status errors""" + mock_client = AsyncMock() + mock_response = MagicMock() + + # Create a proper HTTPStatusError + http_error = httpx.HTTPStatusError( + "404 Not Found", request=MagicMock(), response=MagicMock() + ) + mock_response.raise_for_status.side_effect = http_error + mock_client.get.return_value = mock_response + mock_ctx_httpx_client.get.return_value = mock_client + + result = await task.run(force=True) + + # Verify the specific error logging calls + mock_log.error.assert_any_call( + "Scheduled remote test task failed", exc_info=True + ) + mock_log.error.assert_any_call(http_error) + assert result is None + + @patch.object(RemoteFilePullTask, "unschedule") + @patch("tasks.tasks.log") + async def test_run_disabled_not_forced( + self, mock_log, mock_unschedule, disabled_task + ): + """Test run when task is disabled and not forced""" + result = await disabled_task.run(force=False) + + mock_log.info.assert_called_once_with( + "Scheduled disabled remote task not enabled, unscheduling..." + ) + mock_unschedule.assert_called_once() + assert result is None + + @patch("tasks.tasks.ctx_httpx_client") + async def test_run_disabled_but_forced(self, mock_ctx_httpx_client, disabled_task): + """Test run when task is disabled but forced""" + mock_client = AsyncMock() + mock_response = AsyncMock() + mock_response.content = b"forced content" + mock_client.get.return_value = mock_response + mock_ctx_httpx_client.get.return_value = mock_client + + result = await disabled_task.run(force=True) + + assert result == b"forced content" diff --git a/backend/tasks/tests/test_update_launchbox_metadata.py b/backend/tasks/tests/test_update_launchbox_metadata.py index e4cc91b02..f1847192b 100644 --- a/backend/tasks/tests/test_update_launchbox_metadata.py +++ b/backend/tasks/tests/test_update_launchbox_metadata.py @@ -1,7 +1,7 @@ -import zipfile -from io import BytesIO +import os from unittest.mock import AsyncMock, patch +import anyio import pytest from tasks.tasks import RemoteFilePullTask from tasks.update_launchbox_metadata import ( @@ -165,23 +165,13 @@ class TestUpdateLaunchboxMetadataTask: task, ): """Test handling of XML elements with empty or missing text""" - # Create XML with empty elements - zip_buffer = BytesIO() - with zipfile.ZipFile(zip_buffer, "w") as zip_file: - platforms_xml = """ - - - - Console - - - Valid Platform - - -""" - zip_file.writestr("Platforms.xml", platforms_xml) + test_dir = os.path.dirname(__file__) + sample_path = os.path.join( + test_dir, "fixtures", "sample_metadata_with_empty_elements.zip" + ) - mock_super_run.return_value = zip_buffer.getvalue() + async with await anyio.open_file(sample_path, "rb") as f: + mock_super_run.return_value = await f.read() # Create a mock pipeline with async context manager support mock_pipe = AsyncMock() @@ -214,19 +204,13 @@ class TestUpdateLaunchboxMetadataTask: task, ): """Test handling when some XML files are missing from the ZIP""" - # Create ZIP with only Platforms.xml - zip_buffer = BytesIO() - with zipfile.ZipFile(zip_buffer, "w") as zip_file: - platforms_xml = """ - - - Test Platform - Console - -""" - zip_file.writestr("Platforms.xml", platforms_xml) + test_dir = os.path.dirname(__file__) + sample_path = os.path.join( + test_dir, "fixtures", "sample_metadata_with_empty_elements.zip" + ) - mock_super_run.return_value = zip_buffer.getvalue() + async with await anyio.open_file(sample_path, "rb") as f: + mock_super_run.return_value = await f.read() # Create a mock pipeline with async context manager support mock_pipe = AsyncMock()