mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
feat(backend): saas users app settings api (#13021)
This commit is contained in:
@@ -47,6 +47,7 @@ from server.routes.org_invitations import ( # noqa: E402
|
||||
from server.routes.orgs import org_router # noqa: E402
|
||||
from server.routes.readiness import readiness_router # noqa: E402
|
||||
from server.routes.user import saas_user_router # noqa: E402
|
||||
from server.routes.user_app_settings import user_app_settings_router # noqa: E402
|
||||
from server.routes.verified_models import ( # noqa: E402
|
||||
api_router as verified_models_router,
|
||||
)
|
||||
@@ -79,6 +80,7 @@ base_app.include_router(api_router) # Add additional route for github auth
|
||||
base_app.include_router(oauth_router) # Add additional route for oauth callback
|
||||
base_app.include_router(oauth_device_router) # Add OAuth 2.0 Device Flow routes
|
||||
base_app.include_router(saas_user_router) # Add additional route SAAS user calls
|
||||
base_app.include_router(user_app_settings_router) # Add routes for user app settings
|
||||
base_app.include_router(
|
||||
billing_router
|
||||
) # Add routes for credit management and Stripe payment integration
|
||||
|
||||
115
enterprise/server/routes/user_app_settings.py
Normal file
115
enterprise/server/routes/user_app_settings.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Routes for user app settings API.
|
||||
|
||||
Provides endpoints for managing user-level app preferences:
|
||||
- GET /api/users/app - Retrieve current user's app settings
|
||||
- POST /api/users/app - Update current user's app settings
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from server.routes.user_app_settings_models import (
|
||||
UserAppSettingsResponse,
|
||||
UserAppSettingsUpdate,
|
||||
UserNotFoundError,
|
||||
)
|
||||
from server.services.user_app_settings_service import (
|
||||
UserAppSettingsService,
|
||||
UserAppSettingsServiceInjector,
|
||||
)
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
user_app_settings_router = APIRouter(prefix='/api/users')
|
||||
|
||||
# Create injector instance and dependency at module level
|
||||
_injector = UserAppSettingsServiceInjector()
|
||||
user_app_settings_service_dependency = Depends(_injector.depends)
|
||||
|
||||
|
||||
@user_app_settings_router.get('/app', response_model=UserAppSettingsResponse)
|
||||
async def get_user_app_settings(
|
||||
service: UserAppSettingsService = user_app_settings_service_dependency,
|
||||
) -> UserAppSettingsResponse:
|
||||
"""Get the current user's app settings.
|
||||
|
||||
Returns language, analytics consent, sound notifications, and git config.
|
||||
|
||||
Args:
|
||||
service: UserAppSettingsService (injected by dependency)
|
||||
|
||||
Returns:
|
||||
UserAppSettingsResponse: The user's app settings
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if user is not authenticated
|
||||
HTTPException: 404 if user not found
|
||||
HTTPException: 500 if retrieval fails
|
||||
"""
|
||||
try:
|
||||
return await service.get_user_app_settings()
|
||||
|
||||
except ValueError as e:
|
||||
# User not authenticated
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e),
|
||||
)
|
||||
except UserNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Unexpected error retrieving user app settings',
|
||||
extra={'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve user app settings',
|
||||
)
|
||||
|
||||
|
||||
@user_app_settings_router.post('/app', response_model=UserAppSettingsResponse)
|
||||
async def update_user_app_settings(
|
||||
update_data: UserAppSettingsUpdate,
|
||||
service: UserAppSettingsService = user_app_settings_service_dependency,
|
||||
) -> UserAppSettingsResponse:
|
||||
"""Update the current user's app settings (partial update).
|
||||
|
||||
Only provided fields will be updated. Pass null to clear a field.
|
||||
|
||||
Args:
|
||||
update_data: Fields to update
|
||||
service: UserAppSettingsService (injected by dependency)
|
||||
|
||||
Returns:
|
||||
UserAppSettingsResponse: The updated user's app settings
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if user is not authenticated
|
||||
HTTPException: 404 if user not found
|
||||
HTTPException: 500 if update fails
|
||||
"""
|
||||
try:
|
||||
return await service.update_user_app_settings(update_data)
|
||||
|
||||
except ValueError as e:
|
||||
# User not authenticated
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e),
|
||||
)
|
||||
except UserNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Failed to update user app settings',
|
||||
extra={'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update user app settings',
|
||||
)
|
||||
57
enterprise/server/routes/user_app_settings_models.py
Normal file
57
enterprise/server/routes/user_app_settings_models.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Pydantic models for user app settings API.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from storage.user import User
|
||||
|
||||
|
||||
class UserAppSettingsError(Exception):
|
||||
"""Base exception for user app settings errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UserNotFoundError(UserAppSettingsError):
|
||||
"""Raised when user is not found."""
|
||||
|
||||
def __init__(self, user_id: str):
|
||||
self.user_id = user_id
|
||||
super().__init__(f'User with id "{user_id}" not found')
|
||||
|
||||
|
||||
class UserAppSettingsUpdateError(UserAppSettingsError):
|
||||
"""Raised when user app settings update fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UserAppSettingsResponse(BaseModel):
|
||||
"""Response model for user app settings."""
|
||||
|
||||
language: str | None = None
|
||||
user_consents_to_analytics: bool | None = None
|
||||
enable_sound_notifications: bool | None = None
|
||||
git_user_name: str | None = None
|
||||
git_user_email: EmailStr | None = None
|
||||
|
||||
@classmethod
|
||||
def from_user(cls, user: User) -> 'UserAppSettingsResponse':
|
||||
"""Create response from User entity."""
|
||||
return cls(
|
||||
language=user.language,
|
||||
user_consents_to_analytics=user.user_consents_to_analytics,
|
||||
enable_sound_notifications=user.enable_sound_notifications,
|
||||
git_user_name=user.git_user_name,
|
||||
git_user_email=user.git_user_email,
|
||||
)
|
||||
|
||||
|
||||
class UserAppSettingsUpdate(BaseModel):
|
||||
"""Request model for updating user app settings (partial update)."""
|
||||
|
||||
language: str | None = None
|
||||
user_consents_to_analytics: bool | None = None
|
||||
enable_sound_notifications: bool | None = None
|
||||
git_user_name: str | None = None
|
||||
git_user_email: EmailStr | None = None
|
||||
126
enterprise/server/services/user_app_settings_service.py
Normal file
126
enterprise/server/services/user_app_settings_service.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Service class for managing user app settings.
|
||||
|
||||
Separates business logic from route handlers.
|
||||
Uses dependency injection for db_session and user_context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import Request
|
||||
from server.routes.user_app_settings_models import (
|
||||
UserAppSettingsResponse,
|
||||
UserAppSettingsUpdate,
|
||||
UserNotFoundError,
|
||||
)
|
||||
from storage.user_app_settings_store import UserAppSettingsStore
|
||||
|
||||
from openhands.app_server.services.injector import Injector, InjectorState
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserAppSettingsService:
|
||||
"""Service for user app settings with injected dependencies."""
|
||||
|
||||
store: UserAppSettingsStore
|
||||
user_context: UserContext
|
||||
|
||||
async def get_user_app_settings(self) -> UserAppSettingsResponse:
|
||||
"""Get user app settings.
|
||||
|
||||
User ID is obtained from the injected user_context.
|
||||
|
||||
Returns:
|
||||
UserAppSettingsResponse: The user's app settings
|
||||
|
||||
Raises:
|
||||
ValueError: If user is not authenticated
|
||||
UserNotFoundError: If user is not found
|
||||
"""
|
||||
user_id = await self.user_context.get_user_id()
|
||||
if not user_id:
|
||||
raise ValueError('User is not authenticated')
|
||||
|
||||
logger.info(
|
||||
'Getting user app settings',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
user = await self.store.get_user_by_id(user_id)
|
||||
|
||||
if not user:
|
||||
raise UserNotFoundError(user_id)
|
||||
|
||||
return UserAppSettingsResponse.from_user(user)
|
||||
|
||||
async def update_user_app_settings(
|
||||
self,
|
||||
update_data: UserAppSettingsUpdate,
|
||||
) -> UserAppSettingsResponse:
|
||||
"""Update user app settings.
|
||||
|
||||
Only updates fields that are explicitly provided in update_data.
|
||||
User ID is obtained from the injected user_context.
|
||||
Session auto-commits at request end via DbSessionInjector.
|
||||
|
||||
Args:
|
||||
update_data: The update data from the request
|
||||
|
||||
Returns:
|
||||
UserAppSettingsResponse: The updated user's app settings
|
||||
|
||||
Raises:
|
||||
ValueError: If user is not authenticated
|
||||
UserNotFoundError: If user is not found
|
||||
"""
|
||||
user_id = await self.user_context.get_user_id()
|
||||
if not user_id:
|
||||
raise ValueError('User is not authenticated')
|
||||
|
||||
logger.info(
|
||||
'Updating user app settings',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
# Check if any fields are provided
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
if not update_dict:
|
||||
# No fields to update, just return current settings
|
||||
return await self.get_user_app_settings()
|
||||
|
||||
user = await self.store.update_user_app_settings(
|
||||
user_id=user_id,
|
||||
update_data=update_data,
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise UserNotFoundError(user_id)
|
||||
|
||||
logger.info(
|
||||
'User app settings updated successfully',
|
||||
extra={'user_id': user_id, 'updated_fields': list(update_dict.keys())},
|
||||
)
|
||||
|
||||
return UserAppSettingsResponse.from_user(user)
|
||||
|
||||
|
||||
class UserAppSettingsServiceInjector(Injector[UserAppSettingsService]):
|
||||
"""Injector that composes store and user_context for UserAppSettingsService."""
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[UserAppSettingsService, None]:
|
||||
# Local imports to avoid circular dependencies
|
||||
from openhands.app_server.config import get_db_session, get_user_context
|
||||
|
||||
async with (
|
||||
get_user_context(state, request) as user_context,
|
||||
get_db_session(state, request) as db_session,
|
||||
):
|
||||
store = UserAppSettingsStore(db_session=db_session)
|
||||
yield UserAppSettingsService(store=store, user_context=user_context)
|
||||
64
enterprise/storage/user_app_settings_store.py
Normal file
64
enterprise/storage/user_app_settings_store.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Store class for managing user app settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from server.routes.user_app_settings_models import UserAppSettingsUpdate
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserAppSettingsStore:
|
||||
"""Store for user app settings with injected db_session."""
|
||||
|
||||
db_session: AsyncSession
|
||||
|
||||
async def get_user_by_id(self, user_id: str) -> User | None:
|
||||
"""Get user by ID.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID (Keycloak user ID)
|
||||
|
||||
Returns:
|
||||
User: The user object, or None if not found
|
||||
"""
|
||||
result = await self.db_session.execute(
|
||||
select(User).filter(User.id == uuid.UUID(user_id))
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def update_user_app_settings(
|
||||
self, user_id: str, update_data: UserAppSettingsUpdate
|
||||
) -> User | None:
|
||||
"""Update user app settings.
|
||||
|
||||
Only updates fields that are explicitly provided in update_data.
|
||||
Uses flush() - commit happens at request end via DbSessionInjector.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID (Keycloak user ID)
|
||||
update_data: Pydantic model with fields to update
|
||||
|
||||
Returns:
|
||||
User: The updated user object, or None if user not found
|
||||
"""
|
||||
result = await self.db_session.execute(
|
||||
select(User).filter(User.id == uuid.UUID(user_id)).with_for_update()
|
||||
)
|
||||
user = result.scalars().first()
|
||||
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# Update only explicitly provided fields
|
||||
for field, value in update_data.model_dump(exclude_unset=True).items():
|
||||
setattr(user, field, value)
|
||||
|
||||
# flush instead of commit - DbSessionInjector auto-commits at request end
|
||||
await self.db_session.flush()
|
||||
await self.db_session.refresh(user)
|
||||
return user
|
||||
207
enterprise/tests/unit/server/routes/test_user_app_settings.py
Normal file
207
enterprise/tests/unit/server/routes/test_user_app_settings.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
Unit tests for user app settings API routes.
|
||||
|
||||
Tests the GET and POST /api/users/app endpoints.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, status
|
||||
from fastapi.testclient import TestClient
|
||||
from server.routes.user_app_settings import user_app_settings_router
|
||||
from server.routes.user_app_settings_models import (
|
||||
UserAppSettingsResponse,
|
||||
UserNotFoundError,
|
||||
)
|
||||
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
TEST_USER_ID = str(uuid.uuid4())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app():
|
||||
"""Create a test FastAPI app with user app settings routes and mocked auth."""
|
||||
app = FastAPI()
|
||||
app.include_router(user_app_settings_router)
|
||||
|
||||
def mock_get_user_id():
|
||||
return TEST_USER_ID
|
||||
|
||||
app.dependency_overrides[get_user_id] = mock_get_user_id
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app_unauthenticated():
|
||||
"""Create a test FastAPI app with no authenticated user."""
|
||||
app = FastAPI()
|
||||
app.include_router(user_app_settings_router)
|
||||
|
||||
def mock_get_user_id():
|
||||
return None
|
||||
|
||||
app.dependency_overrides[get_user_id] = mock_get_user_id
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings_response():
|
||||
"""Create a mock user app settings response."""
|
||||
return UserAppSettingsResponse(
|
||||
language='en',
|
||||
user_consents_to_analytics=True,
|
||||
enable_sound_notifications=False,
|
||||
git_user_name='testuser',
|
||||
git_user_email='test@example.com',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_app_settings_success(mock_app, mock_settings_response):
|
||||
"""
|
||||
GIVEN: An authenticated user with app settings
|
||||
WHEN: GET /api/users/app is called
|
||||
THEN: User's app settings are returned with 200 status
|
||||
"""
|
||||
# Arrange
|
||||
with patch(
|
||||
'server.routes.user_app_settings.UserAppSettingsService.get_user_app_settings',
|
||||
AsyncMock(return_value=mock_settings_response),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
|
||||
# Act
|
||||
response = client.get('/api/users/app')
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data['language'] == 'en'
|
||||
assert data['user_consents_to_analytics'] is True
|
||||
assert data['enable_sound_notifications'] is False
|
||||
assert data['git_user_name'] == 'testuser'
|
||||
assert data['git_user_email'] == 'test@example.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_app_settings_not_authenticated(mock_app_unauthenticated):
|
||||
"""
|
||||
GIVEN: An unauthenticated request
|
||||
WHEN: GET /api/users/app is called
|
||||
THEN: 401 Unauthorized is returned
|
||||
"""
|
||||
# Arrange
|
||||
client = TestClient(mock_app_unauthenticated)
|
||||
|
||||
# Act
|
||||
response = client.get('/api/users/app')
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert 'not authenticated' in response.json()['detail'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_app_settings_user_not_found(mock_app):
|
||||
"""
|
||||
GIVEN: An authenticated user that doesn't exist in the database
|
||||
WHEN: GET /api/users/app is called
|
||||
THEN: 404 Not Found is returned
|
||||
"""
|
||||
# Arrange
|
||||
with patch(
|
||||
'server.routes.user_app_settings.UserAppSettingsService.get_user_app_settings',
|
||||
AsyncMock(side_effect=UserNotFoundError(TEST_USER_ID)),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
|
||||
# Act
|
||||
response = client.get('/api/users/app')
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert 'not found' in response.json()['detail'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_app_settings_success(mock_app):
|
||||
"""
|
||||
GIVEN: An authenticated user
|
||||
WHEN: POST /api/users/app is called with update data
|
||||
THEN: Updated settings are returned with 200 status
|
||||
"""
|
||||
# Arrange
|
||||
updated_response = UserAppSettingsResponse(
|
||||
language='es',
|
||||
user_consents_to_analytics=False,
|
||||
enable_sound_notifications=True,
|
||||
git_user_name='newuser',
|
||||
git_user_email='new@example.com',
|
||||
)
|
||||
request_data = {
|
||||
'language': 'es',
|
||||
'user_consents_to_analytics': False,
|
||||
}
|
||||
|
||||
with patch(
|
||||
'server.routes.user_app_settings.UserAppSettingsService.update_user_app_settings',
|
||||
AsyncMock(return_value=updated_response),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
|
||||
# Act
|
||||
response = client.post('/api/users/app', json=request_data)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data['language'] == 'es'
|
||||
assert data['user_consents_to_analytics'] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_app_settings_not_authenticated(mock_app_unauthenticated):
|
||||
"""
|
||||
GIVEN: An unauthenticated request
|
||||
WHEN: POST /api/users/app is called
|
||||
THEN: 401 Unauthorized is returned
|
||||
"""
|
||||
# Arrange
|
||||
request_data = {'language': 'en'}
|
||||
client = TestClient(mock_app_unauthenticated)
|
||||
|
||||
# Act
|
||||
response = client.post('/api/users/app', json=request_data)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert 'not authenticated' in response.json()['detail'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_app_settings_user_not_found(mock_app):
|
||||
"""
|
||||
GIVEN: An authenticated user that doesn't exist in the database
|
||||
WHEN: POST /api/users/app is called
|
||||
THEN: 404 Not Found is returned
|
||||
"""
|
||||
# Arrange
|
||||
request_data = {'language': 'en'}
|
||||
|
||||
with patch(
|
||||
'server.routes.user_app_settings.UserAppSettingsService.update_user_app_settings',
|
||||
AsyncMock(side_effect=UserNotFoundError(TEST_USER_ID)),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
|
||||
# Act
|
||||
response = client.post('/api/users/app', json=request_data)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert 'not found' in response.json()['detail'].lower()
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Unit tests for UserAppSettingsService.
|
||||
|
||||
Tests the service layer for user app settings operations.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from server.routes.user_app_settings_models import (
|
||||
UserAppSettingsResponse,
|
||||
UserAppSettingsUpdate,
|
||||
UserNotFoundError,
|
||||
)
|
||||
from server.services.user_app_settings_service import UserAppSettingsService
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_id():
|
||||
"""Create a test user ID."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user(user_id):
|
||||
"""Create a mock user with app settings."""
|
||||
user = MagicMock(spec=User)
|
||||
user.id = uuid.UUID(user_id)
|
||||
user.language = 'en'
|
||||
user.user_consents_to_analytics = True
|
||||
user.enable_sound_notifications = False
|
||||
user.git_user_name = 'testuser'
|
||||
user.git_user_email = 'test@example.com'
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_store():
|
||||
"""Create a mock UserAppSettingsStore."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_context(user_id):
|
||||
"""Create a mock UserContext that returns the user_id."""
|
||||
context = MagicMock()
|
||||
context.get_user_id = AsyncMock(return_value=user_id)
|
||||
return context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_app_settings_success(
|
||||
user_id, mock_user, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user exists in the database
|
||||
WHEN: get_user_app_settings is called
|
||||
THEN: UserAppSettingsResponse is returned with correct data
|
||||
"""
|
||||
# Arrange
|
||||
mock_store.get_user_by_id = AsyncMock(return_value=mock_user)
|
||||
service = UserAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act
|
||||
result = await service.get_user_app_settings()
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, UserAppSettingsResponse)
|
||||
assert result.language == 'en'
|
||||
assert result.user_consents_to_analytics is True
|
||||
assert result.enable_sound_notifications is False
|
||||
assert result.git_user_name == 'testuser'
|
||||
assert result.git_user_email == 'test@example.com'
|
||||
mock_store.get_user_by_id.assert_called_once_with(user_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_app_settings_user_not_found(
|
||||
user_id, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user does not exist in the database
|
||||
WHEN: get_user_app_settings is called
|
||||
THEN: UserNotFoundError is raised
|
||||
"""
|
||||
# Arrange
|
||||
mock_store.get_user_by_id = AsyncMock(return_value=None)
|
||||
service = UserAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(UserNotFoundError) as exc_info:
|
||||
await service.get_user_app_settings()
|
||||
|
||||
assert user_id in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_app_settings_success(
|
||||
user_id, mock_user, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user exists in the database
|
||||
WHEN: update_user_app_settings is called with new values
|
||||
THEN: UserAppSettingsResponse is returned with updated data
|
||||
"""
|
||||
# Arrange
|
||||
mock_user.language = 'es'
|
||||
mock_user.user_consents_to_analytics = False
|
||||
|
||||
update_data = UserAppSettingsUpdate(
|
||||
language='es',
|
||||
user_consents_to_analytics=False,
|
||||
)
|
||||
|
||||
mock_store.update_user_app_settings = AsyncMock(return_value=mock_user)
|
||||
service = UserAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act
|
||||
result = await service.update_user_app_settings(update_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, UserAppSettingsResponse)
|
||||
assert result.language == 'es'
|
||||
assert result.user_consents_to_analytics is False
|
||||
mock_store.update_user_app_settings.assert_called_once_with(
|
||||
user_id=user_id, update_data=update_data
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_app_settings_no_changes(
|
||||
user_id, mock_user, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user exists in the database
|
||||
WHEN: update_user_app_settings is called with no fields
|
||||
THEN: Current settings are returned without calling update
|
||||
"""
|
||||
# Arrange
|
||||
update_data = UserAppSettingsUpdate() # No fields set
|
||||
|
||||
mock_store.get_user_by_id = AsyncMock(return_value=mock_user)
|
||||
mock_store.update_user_app_settings = AsyncMock()
|
||||
service = UserAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act
|
||||
result = await service.update_user_app_settings(update_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, UserAppSettingsResponse)
|
||||
mock_store.get_user_by_id.assert_called_once_with(user_id)
|
||||
mock_store.update_user_app_settings.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_app_settings_user_not_found(
|
||||
user_id, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user does not exist in the database
|
||||
WHEN: update_user_app_settings is called
|
||||
THEN: UserNotFoundError is raised
|
||||
"""
|
||||
# Arrange
|
||||
update_data = UserAppSettingsUpdate(language='en')
|
||||
|
||||
mock_store.update_user_app_settings = AsyncMock(return_value=None)
|
||||
service = UserAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(UserNotFoundError) as exc_info:
|
||||
await service.update_user_app_settings(update_data)
|
||||
|
||||
assert user_id in str(exc_info.value)
|
||||
204
enterprise/tests/unit/storage/test_user_app_settings_store.py
Normal file
204
enterprise/tests/unit/storage/test_user_app_settings_store.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Unit tests for UserAppSettingsStore.
|
||||
|
||||
Tests the async database operations for user app settings.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
# Mock the database module before importing
|
||||
with patch('storage.database.engine', create=True), patch(
|
||||
'storage.database.a_engine', create=True
|
||||
):
|
||||
from server.routes.user_app_settings_models import UserAppSettingsUpdate
|
||||
from storage.base import Base
|
||||
from storage.org import Org
|
||||
from storage.user import User
|
||||
from storage.user_app_settings_store import UserAppSettingsStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_by_id_success(async_session_maker):
|
||||
"""
|
||||
GIVEN: A user exists in the database
|
||||
WHEN: get_user_by_id is called with the user's ID
|
||||
THEN: The user is returned with correct data
|
||||
"""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
current_org_id=org.id,
|
||||
language='en',
|
||||
user_consents_to_analytics=True,
|
||||
enable_sound_notifications=False,
|
||||
git_user_name='testuser',
|
||||
git_user_email='test@example.com',
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
user_id = str(user.id)
|
||||
|
||||
# Act - create store with the session
|
||||
store = UserAppSettingsStore(db_session=session)
|
||||
result = await store.get_user_by_id(user_id)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert str(result.id) == user_id
|
||||
assert result.language == 'en'
|
||||
assert result.user_consents_to_analytics is True
|
||||
assert result.enable_sound_notifications is False
|
||||
assert result.git_user_name == 'testuser'
|
||||
assert result.git_user_email == 'test@example.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_by_id_not_found(async_session_maker):
|
||||
"""
|
||||
GIVEN: A user does not exist in the database
|
||||
WHEN: get_user_by_id is called with a non-existent ID
|
||||
THEN: None is returned
|
||||
"""
|
||||
# Arrange
|
||||
non_existent_id = str(uuid.uuid4())
|
||||
|
||||
# Act
|
||||
async with async_session_maker() as session:
|
||||
store = UserAppSettingsStore(db_session=session)
|
||||
result = await store.get_user_by_id(non_existent_id)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_app_settings_success(async_session_maker):
|
||||
"""
|
||||
GIVEN: A user exists in the database
|
||||
WHEN: update_user_app_settings is called with new values
|
||||
THEN: The user's settings are updated and returned
|
||||
"""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
current_org_id=org.id,
|
||||
language='en',
|
||||
user_consents_to_analytics=False,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
user_id = str(user.id)
|
||||
|
||||
update_data = UserAppSettingsUpdate(
|
||||
language='es',
|
||||
user_consents_to_analytics=True,
|
||||
enable_sound_notifications=True,
|
||||
git_user_name='newuser',
|
||||
git_user_email='new@example.com',
|
||||
)
|
||||
|
||||
# Act - create store with the session
|
||||
store = UserAppSettingsStore(db_session=session)
|
||||
result = await store.update_user_app_settings(user_id, update_data)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result.language == 'es'
|
||||
assert result.user_consents_to_analytics is True
|
||||
assert result.enable_sound_notifications is True
|
||||
assert result.git_user_name == 'newuser'
|
||||
assert result.git_user_email == 'new@example.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_app_settings_partial(async_session_maker):
|
||||
"""
|
||||
GIVEN: A user exists with existing settings
|
||||
WHEN: update_user_app_settings is called with only some fields
|
||||
THEN: Only the provided fields are updated, others remain unchanged
|
||||
"""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
current_org_id=org.id,
|
||||
language='en',
|
||||
user_consents_to_analytics=True,
|
||||
git_user_name='original',
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
user_id = str(user.id)
|
||||
|
||||
# Only update language
|
||||
update_data = UserAppSettingsUpdate(language='fr')
|
||||
|
||||
# Act - create store with the session
|
||||
store = UserAppSettingsStore(db_session=session)
|
||||
result = await store.update_user_app_settings(user_id, update_data)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result.language == 'fr'
|
||||
assert result.user_consents_to_analytics is True # Unchanged
|
||||
assert result.git_user_name == 'original' # Unchanged
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_app_settings_user_not_found(async_session_maker):
|
||||
"""
|
||||
GIVEN: A user does not exist in the database
|
||||
WHEN: update_user_app_settings is called
|
||||
THEN: None is returned
|
||||
"""
|
||||
# Arrange
|
||||
non_existent_id = str(uuid.uuid4())
|
||||
update_data = UserAppSettingsUpdate(language='en')
|
||||
|
||||
# Act
|
||||
async with async_session_maker() as session:
|
||||
store = UserAppSettingsStore(db_session=session)
|
||||
result = await store.update_user_app_settings(non_existent_id, update_data)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
Reference in New Issue
Block a user