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()