""" Tests for wizard DB persistence, settings export, and related functionality. """ from unittest.mock import MagicMock, patch import pytest from app.utils.settings_service import get_setting_from_db, save_setting_to_db # --------------------------------------------------------------------------- # TestSetupWizardDbPersist # --------------------------------------------------------------------------- @pytest.mark.unit class TestSetupWizardDbPersist: """Unit tests for setup_wizard_save POST handler DB persistence.""" @patch("app.views.wizard.notify_settings_updated") @patch("app.views.wizard.save_setting_to_db") def test_settings_saved_to_db(self, mock_save, mock_notify, client): """Test that settings are saved to DB via save_setting_to_db.""" mock_save.return_value = True response = client.post( "/setup", data={"step": "1", "database_url": "sqlite:///test.db"}, follow_redirects=False, ) assert response.status_code == 303 mock_save.assert_called() @patch("app.views.wizard.notify_settings_updated") @patch("app.views.wizard.save_setting_to_db") def test_notify_called_when_settings_saved(self, mock_save, mock_notify, client): """Test that notify_settings_updated is called when settings are saved.""" mock_save.return_value = True client.post( "/setup", data={"step": "1", "database_url": "sqlite:///test.db"}, follow_redirects=False, ) mock_notify.assert_called_once() @patch("app.views.wizard.notify_settings_updated") @patch("app.views.wizard.save_setting_to_db") def test_notify_not_called_when_no_settings_saved(self, mock_save, mock_notify, client): """Test that notify_settings_updated is NOT called when saved_count == 0.""" mock_save.return_value = False client.post( "/setup", data={"step": "1"}, # no values provided follow_redirects=False, ) mock_notify.assert_not_called() @patch("app.views.wizard.notify_settings_updated") @patch("app.views.wizard.token_hex") @patch("app.views.wizard.save_setting_to_db") def test_auto_generate_session_secret(self, mock_save, mock_token, mock_notify, client): """Test that session_secret auto-generate path produces a real token.""" mock_save.return_value = True mock_token.return_value = "deadbeef" * 8 client.post( "/setup", data={"step": "2", "session_secret": "auto-generate"}, follow_redirects=False, ) mock_token.assert_called_once() # Ensure save was called with the generated token, not 'auto-generate' for call_args in mock_save.call_args_list: args = call_args[0] if len(args) >= 2 and args[1] == "session_secret": assert args[2] != "auto-generate" # --------------------------------------------------------------------------- # TestSetupWizardUndoSkip # --------------------------------------------------------------------------- @pytest.mark.integration class TestSetupWizardUndoSkip: """Tests for /setup/undo-skip route.""" def test_undo_skip_removes_marker(self, client, db_session): """Test that undo-skip removes the _setup_wizard_skipped marker from DB.""" # First, put the marker in DB save_setting_to_db(db_session, "_setup_wizard_skipped", "true") assert get_setting_from_db(db_session, "_setup_wizard_skipped") == "true" # Undo skip via the route response = client.get("/setup/undo-skip", follow_redirects=False) # Should redirect assert response.status_code in (303, 200) def test_undo_skip_redirects_to_wizard(self, client): """Test that undo-skip redirects to /setup?step=1.""" response = client.get("/setup/undo-skip", follow_redirects=False) # The redirect should go to /setup?step=1 or /settings on error assert response.status_code in (303, 302) location = response.headers.get("location", "") assert "/setup" in location or "/settings" in location @patch("app.utils.settings_service.delete_setting_from_db") def test_undo_skip_calls_delete(self, mock_delete, client): """Test that undo-skip calls delete_setting_from_db.""" mock_delete.return_value = True # Just ensure the route exists and does not 404 response = client.get("/setup/undo-skip", follow_redirects=False) assert response.status_code != 404 # --------------------------------------------------------------------------- # TestDropboxSaveSettingsDbPersist # --------------------------------------------------------------------------- @pytest.mark.unit class TestDropboxSaveSettingsDbPersist: """Unit tests for save_dropbox_settings DB persistence.""" @patch("app.api.dropbox.settings") @patch("app.api.dropbox.notify_settings_updated") @patch("app.api.dropbox.save_setting_to_db") def test_db_written_even_when_env_missing(self, mock_save, mock_notify, mock_settings, client): """Test that DB is written even when .env doesn't exist (no exception).""" mock_save.return_value = True with patch("os.path.exists", return_value=False): response = client.post( "/api/dropbox/save-settings", data={"refresh_token": "test-refresh-token"}, follow_redirects=False, ) assert response.status_code == 200 data = response.json() assert data["status"] == "success" mock_save.assert_called() @patch("app.api.dropbox.settings") @patch("app.api.dropbox.notify_settings_updated") @patch("app.api.dropbox.save_setting_to_db") def test_notify_settings_updated_called(self, mock_save, mock_notify, mock_settings, client): """Test that notify_settings_updated is called.""" mock_save.return_value = True with patch("os.path.exists", return_value=False): client.post( "/api/dropbox/save-settings", data={"refresh_token": "test-refresh-token"}, follow_redirects=False, ) mock_notify.assert_called_once() @patch("app.api.dropbox.settings") @patch("app.api.dropbox.notify_settings_updated") @patch("app.api.dropbox.save_setting_to_db") def test_all_provided_values_persisted(self, mock_save, mock_notify, mock_settings, client): """Test that all provided values are persisted to DB.""" mock_save.return_value = True with patch("os.path.exists", return_value=False): client.post( "/api/dropbox/save-settings", data={ "refresh_token": "tok", "app_key": "key", "app_secret": "secret", "folder_path": "/uploads", }, follow_redirects=False, ) keys_saved = [call[0][1] for call in mock_save.call_args_list] assert "dropbox_refresh_token" in keys_saved assert "dropbox_app_key" in keys_saved assert "dropbox_app_secret" in keys_saved assert "dropbox_folder" in keys_saved # --------------------------------------------------------------------------- # TestGoogleDriveUpdateSettingsDbPersist # --------------------------------------------------------------------------- @pytest.mark.unit class TestGoogleDriveUpdateSettingsDbPersist: """Unit tests for update_google_drive_settings DB persistence.""" @patch("app.api.google_drive.settings") @patch("app.api.google_drive.notify_settings_updated") @patch("app.api.google_drive.save_setting_to_db") def test_db_written_for_each_provided_field(self, mock_save, mock_notify, mock_settings, client): """Test that DB is written for each provided field.""" mock_save.return_value = True response = client.post( "/api/google-drive/update-settings", data={ "refresh_token": "gdrive-refresh", "client_id": "client-id", "client_secret": "client-secret", "folder_id": "folder-123", "use_oauth": "true", }, follow_redirects=False, ) assert response.status_code == 200 keys_saved = [call[0][1] for call in mock_save.call_args_list] assert "google_drive_refresh_token" in keys_saved assert "google_drive_client_id" in keys_saved assert "google_drive_client_secret" in keys_saved assert "google_drive_folder_id" in keys_saved assert "google_drive_use_oauth" in keys_saved @patch("app.api.google_drive.settings") @patch("app.api.google_drive.notify_settings_updated") @patch("app.api.google_drive.save_setting_to_db") def test_use_oauth_saved_as_lowercase_string(self, mock_save, mock_notify, mock_settings, client): """Test that use_oauth is saved as 'true' or 'false' string.""" mock_save.return_value = True client.post( "/api/google-drive/update-settings", data={"refresh_token": "tok", "use_oauth": "true"}, follow_redirects=False, ) use_oauth_calls = [call for call in mock_save.call_args_list if call[0][1] == "google_drive_use_oauth"] assert len(use_oauth_calls) == 1 assert use_oauth_calls[0][0][2] in ("true", "false") @patch("app.api.google_drive.settings") @patch("app.api.google_drive.notify_settings_updated") @patch("app.api.google_drive.save_setting_to_db") def test_notify_called(self, mock_save, mock_notify, mock_settings, client): """Test that notify_settings_updated is called.""" mock_save.return_value = True client.post( "/api/google-drive/update-settings", data={"refresh_token": "tok"}, follow_redirects=False, ) mock_notify.assert_called_once() # --------------------------------------------------------------------------- # TestOneDriveSaveSettingsDbPersist # --------------------------------------------------------------------------- @pytest.mark.unit class TestOneDriveSaveSettingsDbPersist: """Unit tests for save_onedrive_settings DB persistence.""" @patch("app.api.onedrive.settings") @patch("app.api.onedrive.notify_settings_updated") @patch("app.api.onedrive.save_setting_to_db") def test_db_written_even_without_env_file(self, mock_save, mock_notify, mock_settings, client): """Test that DB is written even when .env file does not exist.""" mock_save.return_value = True with patch("os.path.exists", return_value=False): response = client.post( "/api/onedrive/save-settings", data={"refresh_token": "od-refresh", "tenant_id": "common"}, follow_redirects=False, ) assert response.status_code == 200 data = response.json() assert data["status"] == "success" mock_save.assert_called() @patch("app.api.onedrive.settings") @patch("app.api.onedrive.notify_settings_updated") @patch("app.api.onedrive.save_setting_to_db") def test_all_fields_persisted(self, mock_save, mock_notify, mock_settings, client): """Test that all provided fields are persisted to DB.""" mock_save.return_value = True with patch("os.path.exists", return_value=False): client.post( "/api/onedrive/save-settings", data={ "refresh_token": "tok", "client_id": "cid", "client_secret": "csec", "tenant_id": "my-tenant", "folder_path": "/docs", }, follow_redirects=False, ) keys_saved = [call[0][1] for call in mock_save.call_args_list] assert "onedrive_refresh_token" in keys_saved assert "onedrive_client_id" in keys_saved assert "onedrive_client_secret" in keys_saved assert "onedrive_tenant_id" in keys_saved assert "onedrive_folder_path" in keys_saved @patch("app.api.onedrive.settings") @patch("app.api.onedrive.notify_settings_updated") @patch("app.api.onedrive.save_setting_to_db") def test_notify_called(self, mock_save, mock_notify, mock_settings, client): """Test that notify_settings_updated is called.""" mock_save.return_value = True with patch("os.path.exists", return_value=False): client.post( "/api/onedrive/save-settings", data={"refresh_token": "tok"}, follow_redirects=False, ) mock_notify.assert_called_once() # --------------------------------------------------------------------------- # TestGetSettingsForExport # --------------------------------------------------------------------------- @pytest.mark.unit class TestGetSettingsForExport: """Unit tests for the get_settings_for_export service function.""" def test_source_db_returns_only_db_settings(self, db_session): """Test that source=db returns only DB-persisted settings.""" from app.utils.settings_service import get_settings_for_export save_setting_to_db(db_session, "workdir", "/tmp/test", changed_by="test") result = get_settings_for_export(db_session, source="db") assert "WORKDIR" in result assert result["WORKDIR"] == "/tmp/test" def test_source_effective_includes_metadata_keys(self, db_session): """Test that source=effective includes keys from SETTING_METADATA.""" from app.utils.settings_service import get_settings_for_export result = get_settings_for_export(db_session, source="effective") # The effective export should include keys from SETTING_METADATA that have values # At a minimum check it returns a dict assert isinstance(result, dict) # Keys should be uppercase for k in result: assert k == k.upper() def test_keys_are_uppercase(self, db_session): """Test that all keys are returned in uppercase.""" from app.utils.settings_service import get_settings_for_export save_setting_to_db(db_session, "workdir", "/tmp", changed_by="test") result = get_settings_for_export(db_session, source="db") for k in result: assert k == k.upper(), f"Key {k!r} is not uppercase" def test_none_values_excluded(self, db_session): """Test that None values are excluded from the export.""" from app.utils.settings_service import get_settings_for_export result = get_settings_for_export(db_session, source="db") for v in result.values(): assert v is not None def test_db_only_excludes_env_only_values(self, db_session): """Test that source=db does NOT include ENV-only values (only DB rows).""" from app.utils.settings_service import get_settings_for_export # Ensure no settings in DB result = get_settings_for_export(db_session, source="db") # DB is empty so result should be empty assert len(result) == 0 # --------------------------------------------------------------------------- # TestExportEnvEndpoint # --------------------------------------------------------------------------- @pytest.mark.unit class TestExportEnvEndpoint: """Unit tests for export_env_settings endpoint function.""" def test_requires_admin(self, client): """Test that the endpoint requires admin access (no session).""" response = client.get("/api/settings/export-env") assert response.status_code in (302, 401, 403) def test_returns_text_plain(self, db_session): """Test that the endpoint returns text/plain response.""" import asyncio from app.api.settings import export_env_settings mock_request = MagicMock() mock_admin = {"id": "admin", "is_admin": True} result = asyncio.run(export_env_settings(mock_request, db_session, mock_admin, source="db")) assert result.media_type == "text/plain" def test_content_disposition_header(self, db_session): """Test that the response includes a content-disposition attachment header.""" import asyncio from app.api.settings import export_env_settings mock_request = MagicMock() mock_admin = {"id": "admin", "is_admin": True} result = asyncio.run(export_env_settings(mock_request, db_session, mock_admin, source="db")) cd = result.headers.get("content-disposition", "") assert "attachment" in cd assert ".env" in cd def test_invalid_source_returns_400(self, db_session): """Test that an invalid source parameter raises HTTPException 400.""" import asyncio from fastapi import HTTPException from app.api.settings import export_env_settings mock_request = MagicMock() mock_admin = {"id": "admin", "is_admin": True} with pytest.raises(HTTPException) as exc_info: asyncio.run(export_env_settings(mock_request, db_session, mock_admin, source="invalid")) assert exc_info.value.status_code == 400 def test_default_source_is_db(self, db_session): """Test that default source is db (filename contains 'db').""" import asyncio from app.api.settings import export_env_settings mock_request = MagicMock() mock_admin = {"id": "admin", "is_admin": True} result = asyncio.run(export_env_settings(mock_request, db_session, mock_admin)) cd = result.headers.get("content-disposition", "") assert "db" in cd def test_effective_source_returns_response(self, db_session): """Test that source=effective returns a valid response.""" import asyncio from app.api.settings import export_env_settings mock_request = MagicMock() mock_admin = {"id": "admin", "is_admin": True} result = asyncio.run(export_env_settings(mock_request, db_session, mock_admin, source="effective")) assert result.media_type == "text/plain" def test_output_contains_docuelevate_header(self, db_session): """Test that the export output contains a DocuElevate header comment.""" import asyncio from app.api.settings import export_env_settings mock_request = MagicMock() mock_admin = {"id": "admin", "is_admin": True} result = asyncio.run(export_env_settings(mock_request, db_session, mock_admin, source="db")) assert b"DocuElevate" in result.body