"""Tests for Site Management API (core/site_api.py).""" from unittest.mock import AsyncMock, MagicMock, patch import pytest from core.site_api import ( MAX_SITES_PER_USER, create_user_site, delete_user_site, get_credential_fields, get_user_sites, validate_credentials, validate_site_connection, ) # ── Fixtures ────────────────────────────────────────────────── @pytest.fixture def mock_db(): """Create and patch a mock database instance.""" db = AsyncMock() db.count_sites_by_user = AsyncMock(return_value=0) db.get_site_by_alias = AsyncMock(return_value=None) db.get_site = AsyncMock( return_value={ "id": "site-uuid-001", "user_id": "user-uuid-001", "plugin_type": "wordpress", "alias": "myblog", "url": "https://myblog.example.com", "credentials": b"encrypted-blob", "status": "active", "status_msg": "Connection verified", "created_at": "2026-02-19T12:00:00Z", } ) db.get_sites_by_user = AsyncMock( return_value=[ { "id": "site-uuid-001", "user_id": "user-uuid-001", "plugin_type": "wordpress", "alias": "myblog", "url": "https://myblog.example.com", "credentials": b"encrypted-blob", "status": "active", "created_at": "2026-02-19T12:00:00Z", }, ] ) db.delete_site = AsyncMock(return_value=True) db.execute = AsyncMock() db.update_site_status = AsyncMock() with patch("core.database.get_database", return_value=db): yield db @pytest.fixture def mock_encryption(): """Create and patch a mock encryption instance.""" enc = MagicMock() enc.encrypt_credentials = MagicMock(return_value=b"encrypted-blob") enc.decrypt_credentials = MagicMock( return_value={ "username": "admin", "app_password": "xxxx xxxx xxxx xxxx", } ) with patch("core.encryption.get_credential_encryption", return_value=enc): yield enc # ── Credential Field Definitions ───────────────────────────── class TestCredentialFields: """Test credential field definitions and retrieval.""" @pytest.mark.unit def test_get_credential_fields_all_plugins(self): """All 9 plugin types should return non-empty credential field lists.""" expected_plugins = [ "wordpress", "woocommerce", "wordpress_advanced", "gitea", "n8n", "supabase", "openpanel", "appwrite", "directus", ] for plugin_type in expected_plugins: fields = get_credential_fields(plugin_type) assert len(fields) > 0, f"{plugin_type} returned empty fields" for field in fields: assert "name" in field assert "label" in field assert "type" in field assert "required" in field @pytest.mark.unit def test_get_credential_fields_invalid(self): """Unknown plugin type should raise ValueError.""" with pytest.raises(ValueError, match="Unknown plugin type"): get_credential_fields("nonexistent_plugin") # ── Credential Validation ──────────────────────────────────── class TestCredentialValidation: """Test credential validation logic.""" @pytest.mark.unit def test_validate_credentials_valid(self): """Valid WordPress credentials should pass validation.""" valid, errors = validate_credentials( "wordpress", { "username": "admin", "app_password": "xxxx xxxx xxxx xxxx", }, ) assert valid is True assert errors == [] @pytest.mark.unit def test_validate_credentials_missing_required(self): """Missing required fields should return errors.""" valid, errors = validate_credentials( "wordpress", { "username": "admin", # app_password is missing }, ) assert valid is False assert len(errors) == 1 assert "Application Password" in errors[0] @pytest.mark.unit def test_validate_credentials_optional_field(self): """Optional fields (like container) should not cause errors when missing.""" valid, errors = validate_credentials( "wordpress_advanced", { "username": "admin", "app_password": "xxxx xxxx xxxx xxxx", # container is optional — should not cause error }, ) assert valid is True assert errors == [] @pytest.mark.unit def test_validate_credentials_empty_string_required(self): """Empty string for required field should fail validation.""" valid, errors = validate_credentials( "wordpress", { "username": "admin", "app_password": " ", # whitespace only }, ) assert valid is False assert len(errors) == 1 # ── Site Creation ──────────────────────────────────────────── class TestSiteCreation: """Test site creation flow.""" @pytest.mark.unit async def test_create_site_success(self, mock_db, mock_encryption): """Creating a site with skip_validation should succeed.""" result = await create_user_site( user_id="user-uuid-001", plugin_type="wordpress", alias="myblog", url="https://myblog.example.com", credentials={"username": "admin", "app_password": "xxxx xxxx xxxx xxxx"}, skip_validation=True, ) assert result["alias"] == "myblog" assert result["plugin_type"] == "wordpress" # Credentials blob should be stripped from the response assert "credentials" not in result # DB execute should have been called to insert mock_db.execute.assert_called_once() # Encryption should have been used mock_encryption.encrypt_credentials.assert_called_once() @pytest.mark.unit async def test_create_site_alias_too_short(self, mock_db, mock_encryption): """Alias shorter than 2 characters should raise ValueError.""" with pytest.raises(ValueError, match="2-50 characters"): await create_user_site( user_id="user-uuid-001", plugin_type="wordpress", alias="a", url="https://example.com", credentials={"username": "admin", "app_password": "xxxx"}, skip_validation=True, ) @pytest.mark.unit async def test_create_site_alias_invalid_chars(self, mock_db, mock_encryption): """Alias with invalid characters should raise ValueError.""" with pytest.raises(ValueError, match="letters, numbers, hyphens, and underscores"): await create_user_site( user_id="user-uuid-001", plugin_type="wordpress", alias="my blog!", url="https://example.com", credentials={"username": "admin", "app_password": "xxxx"}, skip_validation=True, ) @pytest.mark.unit async def test_create_site_max_limit(self, mock_db, mock_encryption): """Exceeding MAX_SITES_PER_USER should raise ValueError.""" mock_db.count_sites_by_user.return_value = MAX_SITES_PER_USER with pytest.raises(ValueError, match="Site limit reached"): await create_user_site( user_id="user-uuid-001", plugin_type="wordpress", alias="toomany", url="https://example.com", credentials={"username": "admin", "app_password": "xxxx"}, skip_validation=True, ) @pytest.mark.unit async def test_create_site_duplicate_alias(self, mock_db, mock_encryption): """Duplicate alias for the same user should raise ValueError.""" mock_db.get_site_by_alias.return_value = {"id": "existing-site", "alias": "myblog"} with pytest.raises(ValueError, match="already in use"): await create_user_site( user_id="user-uuid-001", plugin_type="wordpress", alias="myblog", url="https://example.com", credentials={"username": "admin", "app_password": "xxxx"}, skip_validation=True, ) # ── Site Retrieval and Deletion ────────────────────────────── class TestSiteRetrievalDeletion: """Test site list and delete operations.""" @pytest.mark.unit async def test_get_user_sites(self, mock_db): """get_user_sites should return sites without credential blobs.""" sites = await get_user_sites("user-uuid-001") assert len(sites) == 1 assert sites[0]["alias"] == "myblog" # Credentials should be stripped assert "credentials" not in sites[0] @pytest.mark.unit async def test_delete_user_site(self, mock_db): """delete_user_site should return True when site exists.""" deleted = await delete_user_site("site-uuid-001", "user-uuid-001") assert deleted is True mock_db.delete_site.assert_called_once_with("site-uuid-001", "user-uuid-001") @pytest.mark.unit async def test_delete_user_site_not_found(self, mock_db): """delete_user_site should return False when site doesn't exist.""" mock_db.delete_site.return_value = False deleted = await delete_user_site("nonexistent", "user-uuid-001") assert deleted is False # ── Connection Validation ──────────────────────────────────── class TestConnectionValidation: """Test live connection validation with mocked HTTP.""" @pytest.mark.unit async def test_validate_connection_success(self): """Successful HTTP response should return (True, 'OK').""" mock_response = AsyncMock() mock_response.status = 200 mock_response.text = AsyncMock(return_value="OK") mock_response.__aenter__ = AsyncMock(return_value=mock_response) mock_response.__aexit__ = AsyncMock(return_value=False) mock_session = AsyncMock() mock_session.get = MagicMock(return_value=mock_response) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) with patch("core.site_api.aiohttp.ClientSession", return_value=mock_session): ok, msg = await validate_site_connection( "wordpress", "https://myblog.example.com", {"username": "admin", "app_password": "xxxx"}, ) assert ok is True assert msg == "OK" @pytest.mark.unit async def test_validate_connection_timeout(self): """Timeout should return (False, message about timeout).""" mock_session = AsyncMock() mock_session.get = MagicMock(side_effect=TimeoutError("timed out")) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) with patch("core.site_api.aiohttp.ClientSession", return_value=mock_session): ok, msg = await validate_site_connection( "wordpress", "https://slow-site.example.com", {"username": "admin", "app_password": "xxxx"}, ) assert ok is False assert "timed out" in msg.lower() @pytest.mark.unit async def test_validate_connection_auth_failure(self): """HTTP 401 should return (False, message about authentication).""" mock_response = AsyncMock() mock_response.status = 401 mock_response.text = AsyncMock(return_value="Unauthorized") mock_response.__aenter__ = AsyncMock(return_value=mock_response) mock_response.__aexit__ = AsyncMock(return_value=False) mock_session = AsyncMock() mock_session.get = MagicMock(return_value=mock_response) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) with patch("core.site_api.aiohttp.ClientSession", return_value=mock_session): ok, msg = await validate_site_connection( "wordpress", "https://myblog.example.com", {"username": "admin", "app_password": "wrong"}, ) assert ok is False assert "authentication" in msg.lower()