From 3a63c4dfb2e71149f687b52745fe78e8d5d6c1c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 11:34:07 +0000 Subject: [PATCH 1/2] Initial plan From 19b4ea4f6b0ff6c5ebcf2b560fb533f0aabe8a34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 12:15:39 +0000 Subject: [PATCH 2/2] test(backup): add comprehensive unit tests for app/views/backup.py Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- tests/test_views_backup.py | 273 +++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 tests/test_views_backup.py diff --git a/tests/test_views_backup.py b/tests/test_views_backup.py new file mode 100644 index 00000000..48a9e410 --- /dev/null +++ b/tests/test_views_backup.py @@ -0,0 +1,273 @@ +"""Tests for app/views/backup.py module.""" + +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException +from fastapi.responses import RedirectResponse + +# --------------------------------------------------------------------------- +# backup_dashboard route +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestBackupDashboard: + """Unit tests for the backup_dashboard view handler.""" + + def _make_admin_request(self): + """Return a mock request with an admin user in session.""" + mock_request = MagicMock() + mock_request.session = {"user": {"email": "admin@example.com", "is_admin": True}} + return mock_request + + def _make_mock_db(self, records=None): + """Return a mock DB session whose query chain returns *records*.""" + if records is None: + records = [] + mock_db = MagicMock() + mock_db.query.return_value.order_by.return_value.limit.return_value.all.return_value = records + return mock_db + + @pytest.mark.asyncio + async def test_redirects_non_admin_to_home(self): + """backup_dashboard redirects to '/' when user is not an admin.""" + from app.views.backup import backup_dashboard + + mock_request = MagicMock() + mock_request.session = {"user": {"email": "user@example.com", "is_admin": False}} + mock_db = self._make_mock_db() + + result = await backup_dashboard(mock_request, db=mock_db) + + assert isinstance(result, RedirectResponse) + assert result.status_code == 302 + assert result.headers["location"] == "/" + + @pytest.mark.asyncio + async def test_redirects_when_no_user_in_session(self): + """backup_dashboard redirects to '/' when no user is in the session.""" + from app.views.backup import backup_dashboard + + mock_request = MagicMock() + mock_request.session = {} + mock_db = self._make_mock_db() + + result = await backup_dashboard(mock_request, db=mock_db) + + assert isinstance(result, RedirectResponse) + assert result.status_code == 302 + + @pytest.mark.asyncio + async def test_returns_template_for_admin_empty_records(self): + """backup_dashboard renders template with empty records and zero counts.""" + from app.views.backup import backup_dashboard + + mock_request = self._make_admin_request() + mock_db = self._make_mock_db(records=[]) + mock_template_response = MagicMock() + + with patch("app.views.backup.templates") as mock_templates: + mock_templates.TemplateResponse.return_value = mock_template_response + result = await backup_dashboard(mock_request, db=mock_db) + + assert result is mock_template_response + mock_templates.TemplateResponse.assert_called_once() + _tpl_name, context = mock_templates.TemplateResponse.call_args[0] + assert _tpl_name == "backup.html" + assert context["request"] is mock_request + assert context["records"] == [] + assert context["counts"] == {"hourly": 0, "daily": 0, "weekly": 0} + assert context["total_size"] == 0 + + @pytest.mark.asyncio + async def test_counts_backup_types_correctly(self): + """backup_dashboard counts records per backup_type (hourly/daily/weekly).""" + from app.views.backup import backup_dashboard + + def _rec(btype): + r = MagicMock() + r.backup_type = btype + r.local_path = None + r.size_bytes = 100 + return r + + records = [ + _rec("hourly"), + _rec("hourly"), + _rec("daily"), + _rec("weekly"), + _rec("weekly"), + _rec("weekly"), + ] + + mock_request = self._make_admin_request() + mock_db = self._make_mock_db(records=records) + + with patch("app.views.backup.templates") as mock_templates: + mock_templates.TemplateResponse.return_value = MagicMock() + await backup_dashboard(mock_request, db=mock_db) + + _tpl_name, context = mock_templates.TemplateResponse.call_args[0] + assert context["counts"] == {"hourly": 2, "daily": 1, "weekly": 3} + + @pytest.mark.asyncio + async def test_unknown_backup_type_not_counted(self): + """backup_dashboard ignores backup_type values not in hourly/daily/weekly.""" + from app.views.backup import backup_dashboard + + r = MagicMock() + r.backup_type = "unknown_type" + r.local_path = None + r.size_bytes = 0 + records = [r] + + mock_request = self._make_admin_request() + mock_db = self._make_mock_db(records=records) + + with patch("app.views.backup.templates") as mock_templates: + mock_templates.TemplateResponse.return_value = MagicMock() + await backup_dashboard(mock_request, db=mock_db) + + _tpl_name, context = mock_templates.TemplateResponse.call_args[0] + assert context["counts"] == {"hourly": 0, "daily": 0, "weekly": 0} + + @pytest.mark.asyncio + async def test_total_size_includes_existing_local_files(self): + """backup_dashboard sums size_bytes only for records with an existing local_path.""" + from app.views.backup import backup_dashboard + + r_exists = MagicMock() + r_exists.backup_type = "hourly" + r_exists.local_path = "/tmp/backup_exists.db.gz" + r_exists.size_bytes = 512 + + r_missing = MagicMock() + r_missing.backup_type = "daily" + r_missing.local_path = "/tmp/backup_missing.db.gz" + r_missing.size_bytes = 1024 + + r_no_path = MagicMock() + r_no_path.backup_type = "weekly" + r_no_path.local_path = None + r_no_path.size_bytes = 2048 + + records = [r_exists, r_missing, r_no_path] + mock_request = self._make_admin_request() + mock_db = self._make_mock_db(records=records) + + def _fake_exists(path): + return path == "/tmp/backup_exists.db.gz" + + with ( + patch("app.views.backup.templates") as mock_templates, + patch("app.views.backup.os.path.exists", side_effect=_fake_exists), + ): + mock_templates.TemplateResponse.return_value = MagicMock() + await backup_dashboard(mock_request, db=mock_db) + + _tpl_name, context = mock_templates.TemplateResponse.call_args[0] + # Only r_exists (512) should be counted; r_missing path doesn't exist; r_no_path has no path + assert context["total_size"] == 512 + + @pytest.mark.asyncio + async def test_total_size_zero_when_no_local_files_exist(self): + """backup_dashboard total_size is 0 when no local files are present.""" + from app.views.backup import backup_dashboard + + r = MagicMock() + r.backup_type = "hourly" + r.local_path = "/tmp/nonexistent.db.gz" + r.size_bytes = 999 + + mock_request = self._make_admin_request() + mock_db = self._make_mock_db(records=[r]) + + with ( + patch("app.views.backup.templates") as mock_templates, + patch("app.views.backup.os.path.exists", return_value=False), + ): + mock_templates.TemplateResponse.return_value = MagicMock() + await backup_dashboard(mock_request, db=mock_db) + + _tpl_name, context = mock_templates.TemplateResponse.call_args[0] + assert context["total_size"] == 0 + + @pytest.mark.asyncio + async def test_template_context_contains_settings_values(self): + """backup_dashboard passes all expected settings fields to the template.""" + from app.views.backup import backup_dashboard + + mock_request = self._make_admin_request() + mock_db = self._make_mock_db(records=[]) + + with ( + patch("app.views.backup.templates") as mock_templates, + patch("app.views.backup.settings") as mock_settings, + ): + mock_settings.backup_enabled = True + mock_settings.backup_remote_destination = "s3" + mock_settings.backup_retain_hourly = 48 + mock_settings.backup_retain_daily = 14 + mock_settings.backup_retain_weekly = 8 + mock_settings.version = "1.2.3" + mock_templates.TemplateResponse.return_value = MagicMock() + + await backup_dashboard(mock_request, db=mock_db) + + _tpl_name, context = mock_templates.TemplateResponse.call_args[0] + assert context["backup_enabled"] is True + assert context["backup_remote_destination"] == "s3" + assert context["backup_retain_hourly"] == 48 + assert context["backup_retain_daily"] == 14 + assert context["backup_retain_weekly"] == 8 + assert context["app_version"] == "1.2.3" + + @pytest.mark.asyncio + async def test_raises_500_on_db_error(self): + """backup_dashboard raises HTTPException 500 when the DB query fails.""" + from app.views.backup import backup_dashboard + + mock_request = self._make_admin_request() + mock_db = MagicMock() + mock_db.query.side_effect = RuntimeError("DB connection lost") + + with pytest.raises(HTTPException) as exc_info: + await backup_dashboard(mock_request, db=mock_db) + + assert exc_info.value.status_code == 500 + assert "Failed to load backup dashboard" in exc_info.value.detail + + @pytest.mark.asyncio + async def test_logs_error_on_db_failure(self): + """backup_dashboard logs an error when the DB query raises.""" + from app.views.backup import backup_dashboard + + mock_request = self._make_admin_request() + mock_db = MagicMock() + mock_db.query.side_effect = RuntimeError("DB connection lost") + + with ( + patch("app.views.backup.logger") as mock_logger, + pytest.raises(HTTPException), + ): + await backup_dashboard(mock_request, db=mock_db) + + mock_logger.error.assert_called_once() + + @pytest.mark.asyncio + async def test_raises_500_on_template_error(self): + """backup_dashboard raises HTTPException 500 when template rendering fails.""" + from app.views.backup import backup_dashboard + + mock_request = self._make_admin_request() + mock_db = self._make_mock_db(records=[]) + + with patch("app.views.backup.templates") as mock_templates: + mock_templates.TemplateResponse.side_effect = RuntimeError("Template error") + + with pytest.raises(HTTPException) as exc_info: + await backup_dashboard(mock_request, db=mock_db) + + assert exc_info.value.status_code == 500 + assert "Failed to load backup dashboard" in exc_info.value.detail