fix: resolve email settings architecture violations and add tests/docs

- Fix API-002 in admin/settings.py: use service layer for DB delete
- Fix API-001/API-003 in vendor/email_settings.py: add Pydantic response
  models, remove HTTPException raises
- Fix SVC-002/SVC-006 in vendor_email_settings_service.py: use domain
  exceptions, change db.commit() to db.flush()
- Add unit tests for VendorEmailSettingsService
- Add integration tests for vendor and admin email settings APIs
- Add user guide (docs/guides/email-settings.md)
- Add developer guide (docs/implementation/email-settings.md)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-05 22:38:10 +01:00
parent 36603178c3
commit 84a523cd7b
9 changed files with 1765 additions and 85 deletions

View File

@@ -0,0 +1,290 @@
# tests/integration/api/v1/admin/test_email_settings.py
"""Integration tests for admin email settings API."""
import pytest
from models.database.admin import AdminSetting
# =============================================================================
# GET EMAIL STATUS TESTS
# =============================================================================
@pytest.mark.integration
@pytest.mark.email
class TestGetAdminEmailStatus:
"""Test suite for GET /admin/settings/email/status endpoint."""
def test_get_status_unauthenticated(self, client):
"""Test getting status without auth fails."""
response = client.get("/api/v1/admin/settings/email/status")
assert response.status_code == 401
def test_get_status_non_admin(self, client, auth_headers):
"""Test getting status as non-admin fails."""
response = client.get(
"/api/v1/admin/settings/email/status",
headers=auth_headers,
)
assert response.status_code == 403
def test_get_status_admin(self, client, admin_headers):
"""Test getting status as admin succeeds."""
response = client.get(
"/api/v1/admin/settings/email/status",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert "provider" in data
assert "from_email" in data
assert "enabled" in data
assert "is_configured" in data
def test_get_status_has_db_overrides_flag(self, client, admin_headers):
"""Test status includes has_db_overrides flag."""
response = client.get(
"/api/v1/admin/settings/email/status",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert "has_db_overrides" in data
# Initially should be False (no DB settings)
assert data["has_db_overrides"] is False
# =============================================================================
# UPDATE EMAIL SETTINGS TESTS
# =============================================================================
@pytest.mark.integration
@pytest.mark.email
class TestUpdateAdminEmailSettings:
"""Test suite for PUT /admin/settings/email/settings endpoint."""
def test_update_unauthenticated(self, client):
"""Test updating settings without auth fails."""
response = client.put(
"/api/v1/admin/settings/email/settings",
json={"from_email": "test@example.com"},
)
assert response.status_code == 401
def test_update_non_admin(self, client, auth_headers):
"""Test updating settings as non-admin fails."""
response = client.put(
"/api/v1/admin/settings/email/settings",
headers=auth_headers,
json={"from_email": "test@example.com"},
)
assert response.status_code == 403
def test_update_settings_admin(self, client, admin_headers, db):
"""Test updating settings as admin succeeds."""
response = client.put(
"/api/v1/admin/settings/email/settings",
headers=admin_headers,
json={
"from_email": "platform@example.com",
"from_name": "Test Platform",
"provider": "smtp",
"smtp_host": "smtp.test.com",
"smtp_port": 587,
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "updated_keys" in data
assert "from_email" in data["updated_keys"]
# Verify settings were stored in DB
setting = (
db.query(AdminSetting)
.filter(AdminSetting.key == "email_from_address")
.first()
)
assert setting is not None
assert setting.value == "platform@example.com"
def test_update_partial_settings(self, client, admin_headers):
"""Test updating only some settings."""
response = client.put(
"/api/v1/admin/settings/email/settings",
headers=admin_headers,
json={
"enabled": False,
"debug": True,
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "enabled" in data["updated_keys"]
assert "debug" in data["updated_keys"]
def test_status_shows_db_overrides(self, client, admin_headers):
"""Test that status shows DB overrides after update."""
# First, set a DB override
client.put(
"/api/v1/admin/settings/email/settings",
headers=admin_headers,
json={"from_email": "override@example.com"},
)
# Check status
response = client.get(
"/api/v1/admin/settings/email/status",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["has_db_overrides"] is True
assert data["from_email"] == "override@example.com"
# =============================================================================
# RESET EMAIL SETTINGS TESTS
# =============================================================================
@pytest.mark.integration
@pytest.mark.email
class TestResetAdminEmailSettings:
"""Test suite for DELETE /admin/settings/email/settings endpoint."""
def test_reset_unauthenticated(self, client):
"""Test resetting settings without auth fails."""
response = client.delete("/api/v1/admin/settings/email/settings")
assert response.status_code == 401
def test_reset_non_admin(self, client, auth_headers):
"""Test resetting settings as non-admin fails."""
response = client.delete(
"/api/v1/admin/settings/email/settings",
headers=auth_headers,
)
assert response.status_code == 403
def test_reset_settings_admin(self, client, admin_headers, db):
"""Test resetting settings as admin."""
# First, create some DB overrides
client.put(
"/api/v1/admin/settings/email/settings",
headers=admin_headers,
json={
"from_email": "tobereset@example.com",
"provider": "sendgrid",
},
)
# Verify they exist
setting = (
db.query(AdminSetting)
.filter(AdminSetting.key == "email_from_address")
.first()
)
assert setting is not None
# Reset
response = client.delete(
"/api/v1/admin/settings/email/settings",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
# Verify they're gone
db.expire_all()
setting = (
db.query(AdminSetting)
.filter(AdminSetting.key == "email_from_address")
.first()
)
assert setting is None
def test_status_after_reset(self, client, admin_headers):
"""Test status after reset shows no DB overrides."""
# Set an override
client.put(
"/api/v1/admin/settings/email/settings",
headers=admin_headers,
json={"from_email": "override@example.com"},
)
# Reset
client.delete(
"/api/v1/admin/settings/email/settings",
headers=admin_headers,
)
# Check status
response = client.get(
"/api/v1/admin/settings/email/status",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["has_db_overrides"] is False
# =============================================================================
# TEST EMAIL TESTS
# =============================================================================
@pytest.mark.integration
@pytest.mark.email
class TestSendAdminTestEmail:
"""Test suite for POST /admin/settings/email/test endpoint."""
def test_send_test_unauthenticated(self, client):
"""Test sending test email without auth fails."""
response = client.post(
"/api/v1/admin/settings/email/test",
json={"to_email": "test@example.com"},
)
assert response.status_code == 401
def test_send_test_non_admin(self, client, auth_headers):
"""Test sending test email as non-admin fails."""
response = client.post(
"/api/v1/admin/settings/email/test",
headers=auth_headers,
json={"to_email": "test@example.com"},
)
assert response.status_code == 403
def test_send_test_invalid_email(self, client, admin_headers):
"""Test sending to invalid email format fails."""
response = client.post(
"/api/v1/admin/settings/email/test",
headers=admin_headers,
json={"to_email": "not-an-email"},
)
assert response.status_code == 422 # Validation error
def test_send_test_admin(self, client, admin_headers):
"""Test sending test email as admin."""
response = client.post(
"/api/v1/admin/settings/email/test",
headers=admin_headers,
json={"to_email": "test@example.com"},
)
# May fail if email not configured, but should not be 401/403
assert response.status_code in (200, 500)
data = response.json()
assert "success" in data
assert "message" in data

View File

@@ -0,0 +1,347 @@
# tests/integration/api/v1/vendor/test_email_settings.py
"""Integration tests for vendor email settings API."""
import pytest
from datetime import datetime, timezone
from models.database import VendorEmailSettings
# =============================================================================
# FIXTURES
# =============================================================================
@pytest.fixture
def vendor_email_settings(db, test_vendor_with_vendor_user):
"""Create email settings for vendor owned by test vendor user."""
settings = VendorEmailSettings(
vendor_id=test_vendor_with_vendor_user.id,
from_email="vendor@example.com",
from_name="Vendor Test",
provider="smtp",
smtp_host="smtp.example.com",
smtp_port=587,
smtp_username="vendoruser",
smtp_password="vendorpass",
smtp_use_tls=True,
is_configured=True,
is_verified=False,
)
db.add(settings)
db.commit()
db.refresh(settings)
return settings
@pytest.fixture
def verified_vendor_email_settings(db, test_vendor_with_vendor_user):
"""Create verified email settings."""
settings = VendorEmailSettings(
vendor_id=test_vendor_with_vendor_user.id,
from_email="verified@example.com",
from_name="Verified Sender",
provider="smtp",
smtp_host="smtp.example.com",
smtp_port=587,
smtp_username="testuser",
smtp_password="testpass",
smtp_use_tls=True,
is_configured=True,
is_verified=True,
last_verified_at=datetime.now(timezone.utc),
)
db.add(settings)
db.commit()
db.refresh(settings)
return settings
# =============================================================================
# GET EMAIL SETTINGS TESTS
# =============================================================================
@pytest.mark.integration
@pytest.mark.email
class TestGetEmailSettings:
"""Test suite for GET /email-settings endpoint."""
def test_get_settings_not_configured(
self, client, vendor_auth_headers, test_vendor_with_vendor_user
):
"""Test getting settings when not configured."""
response = client.get(
"/api/v1/vendor/email-settings",
headers=vendor_auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["configured"] is False
assert data["settings"] is None
def test_get_settings_configured(
self, client, vendor_auth_headers, vendor_email_settings
):
"""Test getting configured settings."""
response = client.get(
"/api/v1/vendor/email-settings",
headers=vendor_auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["configured"] is True
assert data["settings"]["from_email"] == "vendor@example.com"
# Password should be masked
assert "vendorpass" not in str(data)
def test_get_settings_unauthenticated(self, client):
"""Test getting settings without auth fails."""
response = client.get("/api/v1/vendor/email-settings")
assert response.status_code == 401
# =============================================================================
# GET STATUS TESTS
# =============================================================================
@pytest.mark.integration
@pytest.mark.email
class TestGetEmailStatus:
"""Test suite for GET /email-settings/status endpoint."""
def test_get_status_not_configured(
self, client, vendor_auth_headers, test_vendor_with_vendor_user
):
"""Test status when not configured."""
response = client.get(
"/api/v1/vendor/email-settings/status",
headers=vendor_auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["is_configured"] is False
assert data["is_verified"] is False
def test_get_status_configured_unverified(
self, client, vendor_auth_headers, vendor_email_settings
):
"""Test status when configured but not verified."""
response = client.get(
"/api/v1/vendor/email-settings/status",
headers=vendor_auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["is_configured"] is True
assert data["is_verified"] is False
def test_get_status_verified(
self, client, vendor_auth_headers, verified_vendor_email_settings
):
"""Test status when verified."""
response = client.get(
"/api/v1/vendor/email-settings/status",
headers=vendor_auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["is_configured"] is True
assert data["is_verified"] is True
# =============================================================================
# GET PROVIDERS TESTS
# =============================================================================
@pytest.mark.integration
@pytest.mark.email
class TestGetProviders:
"""Test suite for GET /email-settings/providers endpoint."""
def test_get_providers(self, client, vendor_auth_headers):
"""Test getting available providers."""
response = client.get(
"/api/v1/vendor/email-settings/providers",
headers=vendor_auth_headers,
)
assert response.status_code == 200
data = response.json()
assert "providers" in data
assert len(data["providers"]) >= 1
# SMTP should always be available
smtp = next((p for p in data["providers"] if p["code"] == "smtp"), None)
assert smtp is not None
assert smtp["available"] is True
# =============================================================================
# UPDATE EMAIL SETTINGS TESTS
# =============================================================================
@pytest.mark.integration
@pytest.mark.email
class TestUpdateEmailSettings:
"""Test suite for PUT /email-settings endpoint."""
def test_create_settings(
self, client, vendor_auth_headers, test_vendor_with_vendor_user
):
"""Test creating new email settings."""
response = client.put(
"/api/v1/vendor/email-settings",
headers=vendor_auth_headers,
json={
"from_email": "new@example.com",
"from_name": "New Vendor",
"provider": "smtp",
"smtp_host": "smtp.example.com",
"smtp_port": 587,
"smtp_username": "user",
"smtp_password": "pass",
"smtp_use_tls": True,
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["settings"]["from_email"] == "new@example.com"
def test_update_existing_settings(
self, client, vendor_auth_headers, vendor_email_settings
):
"""Test updating existing settings."""
response = client.put(
"/api/v1/vendor/email-settings",
headers=vendor_auth_headers,
json={
"from_email": "updated@example.com",
"from_name": "Updated Name",
"provider": "smtp",
},
)
assert response.status_code == 200
data = response.json()
assert data["settings"]["from_email"] == "updated@example.com"
assert data["settings"]["from_name"] == "Updated Name"
def test_premium_provider_rejected_for_basic_tier(
self, client, vendor_auth_headers, test_vendor_with_vendor_user
):
"""Test premium provider rejected without Business tier."""
response = client.put(
"/api/v1/vendor/email-settings",
headers=vendor_auth_headers,
json={
"from_email": "test@example.com",
"from_name": "Test",
"provider": "sendgrid",
"sendgrid_api_key": "test-key",
},
)
# Should fail with 403 (AuthorizationException)
assert response.status_code == 403
def test_invalid_email_rejected(self, client, vendor_auth_headers):
"""Test invalid email format rejected."""
response = client.put(
"/api/v1/vendor/email-settings",
headers=vendor_auth_headers,
json={
"from_email": "not-an-email",
"from_name": "Test",
},
)
assert response.status_code == 422 # Validation error
# =============================================================================
# DELETE EMAIL SETTINGS TESTS
# =============================================================================
@pytest.mark.integration
@pytest.mark.email
class TestDeleteEmailSettings:
"""Test suite for DELETE /email-settings endpoint."""
def test_delete_settings(
self, client, vendor_auth_headers, vendor_email_settings, db
):
"""Test deleting email settings."""
response = client.delete(
"/api/v1/vendor/email-settings",
headers=vendor_auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
# Verify deletion
settings = (
db.query(VendorEmailSettings)
.filter(VendorEmailSettings.vendor_id == vendor_email_settings.vendor_id)
.first()
)
assert settings is None
def test_delete_settings_not_found(
self, client, vendor_auth_headers, test_vendor_with_vendor_user
):
"""Test deleting non-existent settings returns 404."""
response = client.delete(
"/api/v1/vendor/email-settings",
headers=vendor_auth_headers,
)
assert response.status_code == 404
# =============================================================================
# VERIFY EMAIL SETTINGS TESTS
# =============================================================================
@pytest.mark.integration
@pytest.mark.email
class TestVerifyEmailSettings:
"""Test suite for POST /email-settings/verify endpoint."""
def test_verify_not_configured(
self, client, vendor_auth_headers, test_vendor_with_vendor_user
):
"""Test verification fails when settings not configured."""
response = client.post(
"/api/v1/vendor/email-settings/verify",
headers=vendor_auth_headers,
json={"test_email": "test@example.com"},
)
assert response.status_code == 404
def test_verify_invalid_email(
self, client, vendor_auth_headers, vendor_email_settings
):
"""Test verification with invalid email address."""
response = client.post(
"/api/v1/vendor/email-settings/verify",
headers=vendor_auth_headers,
json={"test_email": "not-an-email"},
)
assert response.status_code == 422 # Validation error