From ddd544f8d658eae253f72fc91066d3ec3f6d5b3e Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:01:03 +0700 Subject: [PATCH] feat(backend): saas users app settings api (#13021) --- enterprise/saas_server.py | 2 + enterprise/server/routes/user_app_settings.py | 115 ++++++++++ .../server/routes/user_app_settings_models.py | 57 +++++ .../services/user_app_settings_service.py | 126 +++++++++++ enterprise/storage/user_app_settings_store.py | 64 ++++++ .../server/routes/test_user_app_settings.py | 207 ++++++++++++++++++ .../test_user_app_settings_service.py | 176 +++++++++++++++ .../storage/test_user_app_settings_store.py | 204 +++++++++++++++++ 8 files changed, 951 insertions(+) create mode 100644 enterprise/server/routes/user_app_settings.py create mode 100644 enterprise/server/routes/user_app_settings_models.py create mode 100644 enterprise/server/services/user_app_settings_service.py create mode 100644 enterprise/storage/user_app_settings_store.py create mode 100644 enterprise/tests/unit/server/routes/test_user_app_settings.py create mode 100644 enterprise/tests/unit/server/services/test_user_app_settings_service.py create mode 100644 enterprise/tests/unit/storage/test_user_app_settings_store.py diff --git a/enterprise/saas_server.py b/enterprise/saas_server.py index ad0b7f66dd..1d6e53010f 100644 --- a/enterprise/saas_server.py +++ b/enterprise/saas_server.py @@ -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 diff --git a/enterprise/server/routes/user_app_settings.py b/enterprise/server/routes/user_app_settings.py new file mode 100644 index 0000000000..260fd4ea39 --- /dev/null +++ b/enterprise/server/routes/user_app_settings.py @@ -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', + ) diff --git a/enterprise/server/routes/user_app_settings_models.py b/enterprise/server/routes/user_app_settings_models.py new file mode 100644 index 0000000000..0a50887451 --- /dev/null +++ b/enterprise/server/routes/user_app_settings_models.py @@ -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 diff --git a/enterprise/server/services/user_app_settings_service.py b/enterprise/server/services/user_app_settings_service.py new file mode 100644 index 0000000000..32dcb0a22b --- /dev/null +++ b/enterprise/server/services/user_app_settings_service.py @@ -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) diff --git a/enterprise/storage/user_app_settings_store.py b/enterprise/storage/user_app_settings_store.py new file mode 100644 index 0000000000..e75e39cce7 --- /dev/null +++ b/enterprise/storage/user_app_settings_store.py @@ -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 diff --git a/enterprise/tests/unit/server/routes/test_user_app_settings.py b/enterprise/tests/unit/server/routes/test_user_app_settings.py new file mode 100644 index 0000000000..110e54633f --- /dev/null +++ b/enterprise/tests/unit/server/routes/test_user_app_settings.py @@ -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() diff --git a/enterprise/tests/unit/server/services/test_user_app_settings_service.py b/enterprise/tests/unit/server/services/test_user_app_settings_service.py new file mode 100644 index 0000000000..bbf5293ade --- /dev/null +++ b/enterprise/tests/unit/server/services/test_user_app_settings_service.py @@ -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) diff --git a/enterprise/tests/unit/storage/test_user_app_settings_store.py b/enterprise/tests/unit/storage/test_user_app_settings_store.py new file mode 100644 index 0000000000..a3aaf3c385 --- /dev/null +++ b/enterprise/tests/unit/storage/test_user_app_settings_store.py @@ -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