feat(backend): saas – organizations app settings api (#13022)

This commit is contained in:
Hiep Le
2026-03-01 23:26:39 +07:00
committed by GitHub
parent f9d553d0bb
commit b4a3e5db2f
9 changed files with 1574 additions and 1 deletions

View File

@@ -1,6 +1,13 @@
from typing import Annotated
from pydantic import BaseModel, EmailStr, Field, SecretStr, StringConstraints
from pydantic import (
BaseModel,
EmailStr,
Field,
SecretStr,
StringConstraints,
field_validator,
)
from storage.org import Org
from storage.org_member import OrgMember
from storage.role import Role
@@ -327,3 +334,44 @@ class MeResponse(BaseModel):
llm_base_url=member.llm_base_url,
status=member.status,
)
class OrgAppSettingsResponse(BaseModel):
"""Response model for organization app settings."""
enable_proactive_conversation_starters: bool = True
enable_solvability_analysis: bool | None = None
max_budget_per_task: float | None = None
@classmethod
def from_org(cls, org: Org) -> 'OrgAppSettingsResponse':
"""Create an OrgAppSettingsResponse from an Org entity.
Args:
org: The organization entity
Returns:
OrgAppSettingsResponse with app settings
"""
return cls(
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters
if org.enable_proactive_conversation_starters is not None
else True,
enable_solvability_analysis=org.enable_solvability_analysis,
max_budget_per_task=org.max_budget_per_task,
)
class OrgAppSettingsUpdate(BaseModel):
"""Request model for updating organization app settings."""
enable_proactive_conversation_starters: bool | None = None
enable_solvability_analysis: bool | None = None
max_budget_per_task: float | None = None
@field_validator('max_budget_per_task')
@classmethod
def validate_max_budget_per_task(cls, v: float | None) -> float | None:
if v is not None and v <= 0:
raise ValueError('max_budget_per_task must be greater than 0')
return v

View File

@@ -15,6 +15,8 @@ from server.routes.org_models import (
LiteLLMIntegrationError,
MemberUpdateError,
MeResponse,
OrgAppSettingsResponse,
OrgAppSettingsUpdate,
OrgAuthorizationError,
OrgCreate,
OrgDatabaseError,
@@ -30,6 +32,10 @@ from server.routes.org_models import (
OrphanedUserError,
RoleNotFoundError,
)
from server.services.org_app_settings_service import (
OrgAppSettingsService,
OrgAppSettingsServiceInjector,
)
from server.services.org_member_service import OrgMemberService
from storage.org_service import OrgService
from storage.user_store import UserStore
@@ -40,6 +46,10 @@ from openhands.server.user_auth import get_user_id
# Initialize API router
org_router = APIRouter(prefix='/api/organizations', tags=['Orgs'])
# Create injector instance and dependency at module level
_org_app_settings_injector = OrgAppSettingsServiceInjector()
org_app_settings_service_dependency = Depends(_org_app_settings_injector.depends)
@org_router.get('', response_model=OrgPage)
async def list_user_orgs(
@@ -201,6 +211,96 @@ async def create_org(
)
@org_router.get(
'/app',
response_model=OrgAppSettingsResponse,
dependencies=[Depends(require_permission(Permission.MANAGE_APPLICATION_SETTINGS))],
)
async def get_org_app_settings(
service: OrgAppSettingsService = org_app_settings_service_dependency,
) -> OrgAppSettingsResponse:
"""Get organization app settings for the user's current organization.
This endpoint retrieves application settings for the authenticated user's
current organization. Access requires the MANAGE_APPLICATION_SETTINGS permission,
which is granted to all organization members (member, admin, and owner roles).
Args:
service: OrgAppSettingsService (injected by dependency)
Returns:
OrgAppSettingsResponse: The organization app settings
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user lacks MANAGE_APPLICATION_SETTINGS permission
HTTPException: 404 if current organization not found
"""
try:
return await service.get_org_app_settings()
except OrgNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Current organization not found',
)
except Exception as e:
logger.exception(
'Unexpected error retrieving organization app settings',
extra={'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@org_router.post(
'/app',
response_model=OrgAppSettingsResponse,
dependencies=[Depends(require_permission(Permission.MANAGE_APPLICATION_SETTINGS))],
)
async def update_org_app_settings(
update_data: OrgAppSettingsUpdate,
service: OrgAppSettingsService = org_app_settings_service_dependency,
) -> OrgAppSettingsResponse:
"""Update organization app settings for the user's current organization.
This endpoint updates application settings for the authenticated user's
current organization. Access requires the MANAGE_APPLICATION_SETTINGS permission,
which is granted to all organization members (member, admin, and owner roles).
Args:
update_data: App settings update data
service: OrgAppSettingsService (injected by dependency)
Returns:
OrgAppSettingsResponse: The updated organization app settings
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user lacks MANAGE_APPLICATION_SETTINGS permission
HTTPException: 404 if current organization not found
HTTPException: 422 if validation errors occur (handled by FastAPI)
HTTPException: 500 if update fails
"""
try:
return await service.update_org_app_settings(update_data)
except OrgNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Current organization not found',
)
except Exception as e:
logger.exception(
'Unexpected error updating organization app settings',
extra={'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@org_router.get('/{org_id}', response_model=OrgResponse, status_code=status.HTTP_200_OK)
async def get_org(
org_id: UUID,

View File

@@ -0,0 +1,130 @@
"""Service class for managing organization 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.org_models import (
OrgAppSettingsResponse,
OrgAppSettingsUpdate,
OrgNotFoundError,
)
from storage.org_app_settings_store import OrgAppSettingsStore
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 OrgAppSettingsService:
"""Service for organization app settings with injected dependencies."""
store: OrgAppSettingsStore
user_context: UserContext
async def get_org_app_settings(self) -> OrgAppSettingsResponse:
"""Get organization app settings.
User ID is obtained from the injected user_context.
Returns:
OrgAppSettingsResponse: The organization's app settings
Raises:
OrgNotFoundError: If current organization is not found
"""
user_id = await self.user_context.get_user_id()
logger.info(
'Getting organization app settings',
extra={'user_id': user_id},
)
org = await self.store.get_current_org_by_user_id(user_id)
if not org:
raise OrgNotFoundError('current')
return OrgAppSettingsResponse.from_org(org)
async def update_org_app_settings(
self,
update_data: OrgAppSettingsUpdate,
) -> OrgAppSettingsResponse:
"""Update organization 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:
OrgAppSettingsResponse: The updated organization's app settings
Raises:
OrgNotFoundError: If current organization is not found
"""
user_id = await self.user_context.get_user_id()
logger.info(
'Updating organization app settings',
extra={'user_id': user_id},
)
# Get current org first
org = await self.store.get_current_org_by_user_id(user_id)
if not org:
raise OrgNotFoundError('current')
# 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
logger.info(
'No fields to update in app settings',
extra={'user_id': user_id, 'org_id': str(org.id)},
)
return OrgAppSettingsResponse.from_org(org)
updated_org = await self.store.update_org_app_settings(
org_id=org.id,
update_data=update_data,
)
if not updated_org:
raise OrgNotFoundError('current')
logger.info(
'Organization app settings updated successfully',
extra={'user_id': user_id, 'updated_fields': list(update_dict.keys())},
)
return OrgAppSettingsResponse.from_org(updated_org)
class OrgAppSettingsServiceInjector(Injector[OrgAppSettingsService]):
"""Injector that composes store and user_context for OrgAppSettingsService."""
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[OrgAppSettingsService, 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 = OrgAppSettingsStore(db_session=db_session)
yield OrgAppSettingsService(store=store, user_context=user_context)

View File

@@ -0,0 +1,105 @@
"""Store class for managing organization app settings."""
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from server.constants import (
LITE_LLM_API_URL,
ORG_SETTINGS_VERSION,
get_default_litellm_model,
)
from server.routes.org_models import OrgAppSettingsUpdate
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from storage.org import Org
from storage.user import User
@dataclass
class OrgAppSettingsStore:
"""Store for organization app settings with injected db_session."""
db_session: AsyncSession
async def get_current_org_by_user_id(self, user_id: str) -> Org | None:
"""Get the current organization for a user.
Args:
user_id: The user's ID (Keycloak user ID)
Returns:
Org: The organization object, or None if not found
"""
# Get user with their current_org_id
result = await self.db_session.execute(
select(User).filter(User.id == UUID(user_id))
)
user = result.scalars().first()
if not user:
return None
org_id = user.current_org_id
if not org_id:
return None
# Get the organization
result = await self.db_session.execute(select(Org).filter(Org.id == org_id))
org = result.scalars().first()
if not org:
return None
return await self._validate_org_version(org)
async def _validate_org_version(self, org: Org) -> Org:
"""Check if we need to update org version.
Args:
org: The organization to validate
Returns:
Org: The validated (and potentially updated) organization
"""
if org.org_version < ORG_SETTINGS_VERSION:
org.org_version = ORG_SETTINGS_VERSION
org.default_llm_model = get_default_litellm_model()
org.llm_base_url = LITE_LLM_API_URL
await self.db_session.flush()
await self.db_session.refresh(org)
return org
async def update_org_app_settings(
self, org_id: UUID, update_data: OrgAppSettingsUpdate
) -> Org | None:
"""Update organization app settings.
Only updates fields that are explicitly provided in update_data.
Uses flush() - commit happens at request end via DbSessionInjector.
Args:
org_id: The organization's ID
update_data: Pydantic model with fields to update
Returns:
Org: The updated organization object, or None if not found
"""
result = await self.db_session.execute(
select(Org).filter(Org.id == org_id).with_for_update()
)
org = result.scalars().first()
if not org:
return None
# Update only explicitly provided fields
for field, value in update_data.model_dump(exclude_unset=True).items():
setattr(org, field, value)
# flush instead of commit - DbSessionInjector auto-commits at request end
await self.db_session.flush()
await self.db_session.refresh(org)
return org

View File

@@ -9,6 +9,7 @@ from uuid import UUID as parse_uuid
from server.constants import ORG_SETTINGS_VERSION, get_default_litellm_model
from server.routes.org_models import (
LiteLLMIntegrationError,
OrgAppSettingsUpdate,
OrgAuthorizationError,
OrgDatabaseError,
OrgNameExistsError,
@@ -951,3 +952,96 @@ class OrgService:
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise OrgDatabaseError(f'Failed to switch organization: {str(e)}')
@staticmethod
def get_current_org_app_settings(user_id: str) -> Org:
"""
Get organization app settings for the user's current organization.
Args:
user_id: User ID
Returns:
Org: The user's current organization
Raises:
OrgNotFoundError: If user has no current organization
"""
logger.info(
'Retrieving organization app settings',
extra={'user_id': user_id},
)
org = OrgStore.get_current_org_from_keycloak_user_id(user_id)
if not org:
logger.warning(
'Current organization not found for user',
extra={'user_id': user_id},
)
raise OrgNotFoundError('current')
logger.info(
'Successfully retrieved organization app settings',
extra={'user_id': user_id, 'org_id': str(org.id)},
)
return org
@staticmethod
def update_current_org_app_settings(
user_id: str,
update_data: OrgAppSettingsUpdate,
) -> Org:
"""
Update organization app settings for the user's current organization.
Args:
user_id: User ID
update_data: App settings update data
Returns:
Org: The updated organization
Raises:
OrgNotFoundError: If user has no current organization
OrgDatabaseError: If update fails
"""
logger.info(
'Updating organization app settings',
extra={'user_id': user_id},
)
org = OrgStore.get_current_org_from_keycloak_user_id(user_id)
if not org:
logger.warning(
'Current organization not found for user',
extra={'user_id': user_id},
)
raise OrgNotFoundError('current')
update_dict = update_data.model_dump(exclude_unset=True)
if not update_dict:
logger.info(
'No fields to update in app settings',
extra={'user_id': user_id, 'org_id': str(org.id)},
)
return org
updated_org = OrgStore.update_org(org.id, update_dict)
if not updated_org:
logger.error(
'Failed to update organization app settings',
extra={'user_id': user_id, 'org_id': str(org.id)},
)
raise OrgDatabaseError('Failed to update organization app settings')
logger.info(
'Successfully updated organization app settings',
extra={
'user_id': user_id,
'org_id': str(org.id),
'updated_fields': list(update_dict.keys()),
},
)
return updated_org

View File

@@ -24,6 +24,8 @@ with patch('storage.database.engine', create=True), patch(
LastOwnerError,
LiteLLMIntegrationError,
MeResponse,
OrgAppSettingsResponse,
OrgAppSettingsUpdate,
OrgAuthorizationError,
OrgDatabaseError,
OrgMemberNotFoundError,
@@ -3424,3 +3426,421 @@ async def test_switch_org_database_error(mock_app_with_get_user_id):
# Assert
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert 'Failed to switch organization' in response.json()['detail']
# =============================================================================
# Tests for App Settings Endpoints
# =============================================================================
@pytest.fixture
def mock_member_role():
"""Create a mock member role for authorization tests."""
mock_role = MagicMock()
mock_role.name = 'member'
return mock_role
@pytest.mark.asyncio
async def test_get_org_app_settings_success(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: Authenticated user with MANAGE_APPLICATION_SETTINGS permission
WHEN: GET /api/organizations/app is called
THEN: App settings are returned with 200 status
"""
# Arrange
mock_response = OrgAppSettingsResponse(
enable_proactive_conversation_starters=True,
enable_solvability_analysis=False,
max_budget_per_task=10.0,
)
with (
patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
),
patch(
'server.routes.orgs.OrgAppSettingsService.get_org_app_settings',
AsyncMock(return_value=mock_response),
),
):
client = TestClient(mock_app_with_get_user_id)
# Act
response = client.get('/api/organizations/app')
# Assert
assert response.status_code == status.HTTP_200_OK
response_data = response.json()
assert response_data['enable_proactive_conversation_starters'] is True
assert response_data['enable_solvability_analysis'] is False
assert response_data['max_budget_per_task'] == 10.0
@pytest.mark.asyncio
async def test_get_org_app_settings_with_null_values(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: Organization has null app settings values
WHEN: GET /api/organizations/app is called
THEN: Default values are returned where applicable
"""
# Arrange
# OrgAppSettingsResponse.from_org() handles defaults, so we test the response model
mock_response = OrgAppSettingsResponse(
enable_proactive_conversation_starters=True, # Default when None in Org
enable_solvability_analysis=None,
max_budget_per_task=None,
)
with (
patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
),
patch(
'server.routes.orgs.OrgAppSettingsService.get_org_app_settings',
AsyncMock(return_value=mock_response),
),
):
client = TestClient(mock_app_with_get_user_id)
# Act
response = client.get('/api/organizations/app')
# Assert
assert response.status_code == status.HTTP_200_OK
response_data = response.json()
# enable_proactive_conversation_starters defaults to True when None
assert response_data['enable_proactive_conversation_starters'] is True
assert response_data['enable_solvability_analysis'] is None
assert response_data['max_budget_per_task'] is None
@pytest.mark.asyncio
async def test_get_org_app_settings_not_found(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: User has no current organization
WHEN: GET /api/organizations/app is called
THEN: 404 Not Found error is returned
"""
# Arrange
with (
patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
),
patch(
'server.routes.orgs.OrgAppSettingsService.get_org_app_settings',
AsyncMock(side_effect=OrgNotFoundError('current')),
),
):
client = TestClient(mock_app_with_get_user_id)
# Act
response = client.get('/api/organizations/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_get_org_app_settings_user_not_member(mock_app_with_get_user_id):
"""
GIVEN: User is not a member of any organization
WHEN: GET /api/organizations/app is called
THEN: 403 Forbidden error is returned
"""
# Arrange - user has no role (not a member)
with patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=None),
):
client = TestClient(mock_app_with_get_user_id)
# Act
response = client.get('/api/organizations/app')
# Assert
assert response.status_code == status.HTTP_403_FORBIDDEN
assert 'not a member' in response.json()['detail'].lower()
@pytest.mark.asyncio
async def test_update_org_app_settings_success(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: Valid update data and authenticated user
WHEN: POST /api/organizations/app is called
THEN: Updated app settings are returned with 200 status
"""
# Arrange
mock_response = OrgAppSettingsResponse(
enable_proactive_conversation_starters=False,
enable_solvability_analysis=True,
max_budget_per_task=25.0,
)
with (
patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
),
patch(
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
AsyncMock(return_value=mock_response),
) as mock_update,
):
client = TestClient(mock_app_with_get_user_id)
# Act
response = client.post(
'/api/organizations/app',
json={
'enable_proactive_conversation_starters': False,
'enable_solvability_analysis': True,
'max_budget_per_task': 25.0,
},
)
# Assert
assert response.status_code == status.HTTP_200_OK
response_data = response.json()
assert response_data['enable_proactive_conversation_starters'] is False
assert response_data['enable_solvability_analysis'] is True
assert response_data['max_budget_per_task'] == 25.0
mock_update.assert_called_once()
@pytest.mark.asyncio
async def test_update_org_app_settings_partial_update(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: Partial update data (only some fields)
WHEN: POST /api/organizations/app is called
THEN: Only specified fields are updated
"""
# Arrange
mock_response = OrgAppSettingsResponse(
enable_proactive_conversation_starters=False,
enable_solvability_analysis=True,
max_budget_per_task=10.0, # Unchanged
)
with (
patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
),
patch(
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
AsyncMock(return_value=mock_response),
) as mock_update,
):
client = TestClient(mock_app_with_get_user_id)
# Act - only updating one field
response = client.post(
'/api/organizations/app',
json={'enable_proactive_conversation_starters': False},
)
# Assert
assert response.status_code == status.HTTP_200_OK
mock_update.assert_called_once()
# Verify the update data only contains the specified field
call_args = mock_update.call_args
update_data = call_args[0][0] # First positional argument (update_data)
assert isinstance(update_data, OrgAppSettingsUpdate)
@pytest.mark.asyncio
async def test_update_org_app_settings_set_null(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: Request to set max_budget_per_task to null
WHEN: POST /api/organizations/app is called
THEN: The field is set to null successfully
"""
# Arrange
mock_response = OrgAppSettingsResponse(
enable_proactive_conversation_starters=True,
enable_solvability_analysis=True,
max_budget_per_task=None,
)
with (
patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
),
patch(
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
AsyncMock(return_value=mock_response),
),
):
client = TestClient(mock_app_with_get_user_id)
# Act - explicitly setting max_budget_per_task to null
response = client.post(
'/api/organizations/app',
json={'max_budget_per_task': None},
)
# Assert
assert response.status_code == status.HTTP_200_OK
response_data = response.json()
assert response_data['max_budget_per_task'] is None
@pytest.mark.asyncio
async def test_update_org_app_settings_invalid_max_budget(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: Invalid max_budget_per_task value (zero or negative)
WHEN: POST /api/organizations/app is called
THEN: 422 Validation error is returned
"""
# Arrange
with patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
):
client = TestClient(mock_app_with_get_user_id)
# Act - negative value
response = client.post(
'/api/organizations/app',
json={'max_budget_per_task': -5.0},
)
# Assert
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_update_org_app_settings_zero_max_budget(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: max_budget_per_task is set to zero
WHEN: POST /api/organizations/app is called
THEN: 422 Validation error is returned (must be greater than 0)
"""
# Arrange
with patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
):
client = TestClient(mock_app_with_get_user_id)
# Act - zero value
response = client.post(
'/api/organizations/app',
json={'max_budget_per_task': 0},
)
# Assert
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_update_org_app_settings_not_found(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: User has no current organization
WHEN: POST /api/organizations/app is called
THEN: 404 Not Found error is returned
"""
# Arrange
with (
patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
),
patch(
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
AsyncMock(side_effect=OrgNotFoundError('current')),
),
):
client = TestClient(mock_app_with_get_user_id)
# Act
response = client.post(
'/api/organizations/app',
json={'enable_proactive_conversation_starters': False},
)
# 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_org_app_settings_database_error(
mock_app_with_get_user_id, mock_member_role
):
"""
GIVEN: Database update fails
WHEN: POST /api/organizations/app is called
THEN: 500 Internal Server Error is returned
"""
# Arrange
with (
patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=mock_member_role),
),
patch(
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
AsyncMock(side_effect=Exception('Database connection failed')),
),
):
client = TestClient(mock_app_with_get_user_id)
# Act
response = client.post(
'/api/organizations/app',
json={'enable_proactive_conversation_starters': False},
)
# Assert
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert 'unexpected error' in response.json()['detail'].lower()
@pytest.mark.asyncio
async def test_update_org_app_settings_user_not_member(mock_app_with_get_user_id):
"""
GIVEN: User is not a member of any organization
WHEN: POST /api/organizations/app is called
THEN: 403 Forbidden error is returned
"""
# Arrange - user has no role (not a member)
with patch(
'server.auth.authorization.get_user_org_role_async',
AsyncMock(return_value=None),
):
client = TestClient(mock_app_with_get_user_id)
# Act
response = client.post(
'/api/organizations/app',
json={'enable_proactive_conversation_starters': False},
)
# Assert
assert response.status_code == status.HTTP_403_FORBIDDEN
assert 'not a member' in response.json()['detail'].lower()

View File

@@ -0,0 +1,173 @@
"""
Unit tests for OrgAppSettingsService.
Tests the service layer for organization app settings operations.
"""
import uuid
from unittest.mock import AsyncMock, MagicMock
import pytest
from server.routes.org_models import (
OrgAppSettingsResponse,
OrgAppSettingsUpdate,
OrgNotFoundError,
)
from server.services.org_app_settings_service import OrgAppSettingsService
from storage.org import Org
@pytest.fixture
def user_id():
"""Create a test user ID."""
return str(uuid.uuid4())
@pytest.fixture
def mock_org():
"""Create a mock organization with app settings."""
org = MagicMock(spec=Org)
org.id = uuid.uuid4()
org.enable_proactive_conversation_starters = True
org.enable_solvability_analysis = False
org.max_budget_per_task = 25.0
return org
@pytest.fixture
def mock_store():
"""Create a mock OrgAppSettingsStore."""
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_org_app_settings_success(
user_id, mock_org, mock_store, mock_user_context
):
"""
GIVEN: A user's current organization exists
WHEN: get_org_app_settings is called
THEN: OrgAppSettingsResponse is returned with correct data
"""
# Arrange
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
# Act
result = await service.get_org_app_settings()
# Assert
assert isinstance(result, OrgAppSettingsResponse)
assert result.enable_proactive_conversation_starters is True
assert result.enable_solvability_analysis is False
assert result.max_budget_per_task == 25.0
mock_store.get_current_org_by_user_id.assert_called_once_with(user_id)
@pytest.mark.asyncio
async def test_get_org_app_settings_org_not_found(
user_id, mock_store, mock_user_context
):
"""
GIVEN: A user has no current organization
WHEN: get_org_app_settings is called
THEN: OrgNotFoundError is raised
"""
# Arrange
mock_store.get_current_org_by_user_id = AsyncMock(return_value=None)
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
# Act & Assert
with pytest.raises(OrgNotFoundError) as exc_info:
await service.get_org_app_settings()
assert 'current' in str(exc_info.value)
@pytest.mark.asyncio
async def test_update_org_app_settings_success(
user_id, mock_org, mock_store, mock_user_context
):
"""
GIVEN: A user's current organization exists
WHEN: update_org_app_settings is called with new values
THEN: OrgAppSettingsResponse is returned with updated data
"""
# Arrange
mock_org.enable_proactive_conversation_starters = False
mock_org.max_budget_per_task = 50.0
update_data = OrgAppSettingsUpdate(
enable_proactive_conversation_starters=False,
max_budget_per_task=50.0,
)
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
mock_store.update_org_app_settings = AsyncMock(return_value=mock_org)
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
# Act
result = await service.update_org_app_settings(update_data)
# Assert
assert isinstance(result, OrgAppSettingsResponse)
assert result.enable_proactive_conversation_starters is False
assert result.max_budget_per_task == 50.0
mock_store.update_org_app_settings.assert_called_once_with(
org_id=mock_org.id, update_data=update_data
)
@pytest.mark.asyncio
async def test_update_org_app_settings_no_changes(
user_id, mock_org, mock_store, mock_user_context
):
"""
GIVEN: A user's current organization exists
WHEN: update_org_app_settings is called with no fields
THEN: Current settings are returned without calling update
"""
# Arrange
update_data = OrgAppSettingsUpdate() # No fields set
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
mock_store.update_org_app_settings = AsyncMock()
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
# Act
result = await service.update_org_app_settings(update_data)
# Assert
assert isinstance(result, OrgAppSettingsResponse)
mock_store.get_current_org_by_user_id.assert_called_once_with(user_id)
mock_store.update_org_app_settings.assert_not_called()
@pytest.mark.asyncio
async def test_update_org_app_settings_org_not_found(
user_id, mock_store, mock_user_context
):
"""
GIVEN: A user has no current organization
WHEN: update_org_app_settings is called
THEN: OrgNotFoundError is raised
"""
# Arrange
update_data = OrgAppSettingsUpdate(enable_proactive_conversation_starters=False)
mock_store.get_current_org_by_user_id = AsyncMock(return_value=None)
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
# Act & Assert
with pytest.raises(OrgNotFoundError) as exc_info:
await service.update_org_app_settings(update_data)
assert 'current' in str(exc_info.value)

View File

@@ -0,0 +1,189 @@
"""
Unit tests for OrgAppSettingsStore.
Tests the async database operations for organization 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.org_models import OrgAppSettingsUpdate
from storage.base import Base
from storage.org import Org
from storage.org_app_settings_store import OrgAppSettingsStore
from storage.user import User
@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_current_org_by_user_id_success(async_session_maker):
"""
GIVEN: A user exists with a current organization
WHEN: get_current_org_by_user_id is called with the user's ID
THEN: The organization is returned with correct data
"""
# Arrange
async with async_session_maker() as session:
org = Org(
name='test-org',
enable_proactive_conversation_starters=True,
enable_solvability_analysis=False,
max_budget_per_task=25.0,
)
session.add(org)
await session.flush()
user = User(
id=uuid.uuid4(),
current_org_id=org.id,
)
session.add(user)
await session.commit()
user_id = str(user.id)
# Act
store = OrgAppSettingsStore(db_session=session)
result = await store.get_current_org_by_user_id(user_id)
# Assert
assert result is not None
assert result.name == 'test-org'
assert result.enable_proactive_conversation_starters is True
assert result.enable_solvability_analysis is False
assert result.max_budget_per_task == 25.0
@pytest.mark.asyncio
async def test_get_current_org_by_user_id_user_not_found(async_session_maker):
"""
GIVEN: A user does not exist in the database
WHEN: get_current_org_by_user_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 = OrgAppSettingsStore(db_session=session)
result = await store.get_current_org_by_user_id(non_existent_id)
# Assert
assert result is None
@pytest.mark.asyncio
async def test_update_org_app_settings_success(async_session_maker):
"""
GIVEN: An organization exists in the database
WHEN: update_org_app_settings is called with new values
THEN: The organization's settings are updated and returned
"""
# Arrange
async with async_session_maker() as session:
org = Org(
name='test-org',
enable_proactive_conversation_starters=True,
enable_solvability_analysis=False,
max_budget_per_task=10.0,
)
session.add(org)
await session.commit()
org_id = org.id
update_data = OrgAppSettingsUpdate(
enable_proactive_conversation_starters=False,
enable_solvability_analysis=True,
max_budget_per_task=50.0,
)
# Act
store = OrgAppSettingsStore(db_session=session)
result = await store.update_org_app_settings(org_id, update_data)
# Assert
assert result is not None
assert result.enable_proactive_conversation_starters is False
assert result.enable_solvability_analysis is True
assert result.max_budget_per_task == 50.0
@pytest.mark.asyncio
async def test_update_org_app_settings_partial(async_session_maker):
"""
GIVEN: An organization exists with existing settings
WHEN: update_org_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',
enable_proactive_conversation_starters=True,
enable_solvability_analysis=False,
max_budget_per_task=10.0,
)
session.add(org)
await session.commit()
org_id = org.id
# Only update max_budget_per_task
update_data = OrgAppSettingsUpdate(max_budget_per_task=100.0)
# Act
store = OrgAppSettingsStore(db_session=session)
result = await store.update_org_app_settings(org_id, update_data)
# Assert
assert result is not None
assert result.max_budget_per_task == 100.0
assert result.enable_proactive_conversation_starters is True # Unchanged
assert result.enable_solvability_analysis is False # Unchanged
@pytest.mark.asyncio
async def test_update_org_app_settings_org_not_found(async_session_maker):
"""
GIVEN: An organization does not exist in the database
WHEN: update_org_app_settings is called
THEN: None is returned
"""
# Arrange
non_existent_id = uuid.uuid4()
update_data = OrgAppSettingsUpdate(enable_proactive_conversation_starters=False)
# Act
async with async_session_maker() as session:
store = OrgAppSettingsStore(db_session=session)
result = await store.update_org_app_settings(non_existent_id, update_data)
# Assert
assert result is None

View File

@@ -2024,3 +2024,317 @@ async def test_switch_org_user_not_found():
await OrgService.switch_org(user_id, org_id)
assert 'User not found' in str(exc_info.value)
# =============================================================================
# Tests for App Settings Methods
# =============================================================================
def test_get_current_org_app_settings_success():
"""
GIVEN: User has a current organization
WHEN: get_current_org_app_settings is called
THEN: Organization is returned with app settings
"""
# Arrange
user_id = str(uuid.uuid4())
org_id = uuid.uuid4()
mock_org = Org(
id=org_id,
name='Test Organization',
enable_proactive_conversation_starters=True,
enable_solvability_analysis=False,
max_budget_per_task=10.0,
)
with patch(
'storage.org_service.OrgStore.get_current_org_from_keycloak_user_id',
return_value=mock_org,
):
# Act
result = OrgService.get_current_org_app_settings(user_id)
# Assert
assert result is not None
assert result.id == org_id
assert result.enable_proactive_conversation_starters is True
assert result.enable_solvability_analysis is False
assert result.max_budget_per_task == 10.0
def test_get_current_org_app_settings_no_current_org():
"""
GIVEN: User has no current organization
WHEN: get_current_org_app_settings is called
THEN: OrgNotFoundError is raised
"""
# Arrange
user_id = str(uuid.uuid4())
with patch(
'storage.org_service.OrgStore.get_current_org_from_keycloak_user_id',
return_value=None,
):
# Act & Assert
with pytest.raises(OrgNotFoundError) as exc_info:
OrgService.get_current_org_app_settings(user_id)
assert 'current' in str(exc_info.value)
def test_update_current_org_app_settings_success():
"""
GIVEN: User has a current organization and valid update data
WHEN: update_current_org_app_settings is called
THEN: Organization is updated and returned
"""
# Import here to avoid circular dependency in test setup
from server.routes.org_models import OrgAppSettingsUpdate
# Arrange
user_id = str(uuid.uuid4())
org_id = uuid.uuid4()
mock_org = Org(
id=org_id,
name='Test Organization',
enable_proactive_conversation_starters=True,
enable_solvability_analysis=None,
max_budget_per_task=None,
)
updated_org = Org(
id=org_id,
name='Test Organization',
enable_proactive_conversation_starters=False,
enable_solvability_analysis=True,
max_budget_per_task=5.0,
)
update_data = OrgAppSettingsUpdate(
enable_proactive_conversation_starters=False,
enable_solvability_analysis=True,
max_budget_per_task=5.0,
)
with (
patch(
'storage.org_service.OrgStore.get_current_org_from_keycloak_user_id',
return_value=mock_org,
),
patch(
'storage.org_service.OrgStore.update_org',
return_value=updated_org,
) as mock_update,
):
# Act
result = OrgService.update_current_org_app_settings(user_id, update_data)
# Assert
assert result is not None
assert result.enable_proactive_conversation_starters is False
assert result.enable_solvability_analysis is True
assert result.max_budget_per_task == 5.0
mock_update.assert_called_once_with(
org_id,
{
'enable_proactive_conversation_starters': False,
'enable_solvability_analysis': True,
'max_budget_per_task': 5.0,
},
)
def test_update_current_org_app_settings_partial_update():
"""
GIVEN: User updates only some app settings
WHEN: update_current_org_app_settings is called
THEN: Only specified fields are updated
"""
from server.routes.org_models import OrgAppSettingsUpdate
# Arrange
user_id = str(uuid.uuid4())
org_id = uuid.uuid4()
mock_org = Org(
id=org_id,
name='Test Organization',
enable_proactive_conversation_starters=True,
enable_solvability_analysis=True,
max_budget_per_task=10.0,
)
updated_org = Org(
id=org_id,
name='Test Organization',
enable_proactive_conversation_starters=False,
enable_solvability_analysis=True,
max_budget_per_task=10.0,
)
# Only update one field
update_data = OrgAppSettingsUpdate(enable_proactive_conversation_starters=False)
with (
patch(
'storage.org_service.OrgStore.get_current_org_from_keycloak_user_id',
return_value=mock_org,
),
patch(
'storage.org_service.OrgStore.update_org',
return_value=updated_org,
) as mock_update,
):
# Act
result = OrgService.update_current_org_app_settings(user_id, update_data)
# Assert
assert result is not None
assert result.enable_proactive_conversation_starters is False
# Only the specified field should be in the update dict
mock_update.assert_called_once_with(
org_id,
{'enable_proactive_conversation_starters': False},
)
def test_update_current_org_app_settings_empty_update():
"""
GIVEN: No fields are specified in update
WHEN: update_current_org_app_settings is called
THEN: Original org is returned without database update
"""
from server.routes.org_models import OrgAppSettingsUpdate
# Arrange
user_id = str(uuid.uuid4())
org_id = uuid.uuid4()
mock_org = Org(
id=org_id,
name='Test Organization',
enable_proactive_conversation_starters=True,
enable_solvability_analysis=None,
max_budget_per_task=5.0,
)
update_data = OrgAppSettingsUpdate() # No fields set
with (
patch(
'storage.org_service.OrgStore.get_current_org_from_keycloak_user_id',
return_value=mock_org,
),
patch('storage.org_service.OrgStore.update_org') as mock_update,
):
# Act
result = OrgService.update_current_org_app_settings(user_id, update_data)
# Assert
assert result is mock_org
mock_update.assert_not_called()
def test_update_current_org_app_settings_set_to_null():
"""
GIVEN: User explicitly sets max_budget_per_task to null
WHEN: update_current_org_app_settings is called
THEN: The field is set to null in the update
"""
from server.routes.org_models import OrgAppSettingsUpdate
# Arrange
user_id = str(uuid.uuid4())
org_id = uuid.uuid4()
mock_org = Org(
id=org_id,
name='Test Organization',
enable_proactive_conversation_starters=True,
enable_solvability_analysis=True,
max_budget_per_task=5.0,
)
updated_org = Org(
id=org_id,
name='Test Organization',
enable_proactive_conversation_starters=True,
enable_solvability_analysis=True,
max_budget_per_task=None,
)
# Explicitly set max_budget_per_task to None
update_data = OrgAppSettingsUpdate.model_validate({'max_budget_per_task': None})
with (
patch(
'storage.org_service.OrgStore.get_current_org_from_keycloak_user_id',
return_value=mock_org,
),
patch(
'storage.org_service.OrgStore.update_org',
return_value=updated_org,
) as mock_update,
):
# Act
result = OrgService.update_current_org_app_settings(user_id, update_data)
# Assert
assert result.max_budget_per_task is None
mock_update.assert_called_once_with(
org_id,
{'max_budget_per_task': None},
)
def test_update_current_org_app_settings_no_current_org():
"""
GIVEN: User has no current organization
WHEN: update_current_org_app_settings is called
THEN: OrgNotFoundError is raised
"""
from server.routes.org_models import OrgAppSettingsUpdate
# Arrange
user_id = str(uuid.uuid4())
update_data = OrgAppSettingsUpdate(enable_proactive_conversation_starters=False)
with patch(
'storage.org_service.OrgStore.get_current_org_from_keycloak_user_id',
return_value=None,
):
# Act & Assert
with pytest.raises(OrgNotFoundError) as exc_info:
OrgService.update_current_org_app_settings(user_id, update_data)
assert 'current' in str(exc_info.value)
def test_update_current_org_app_settings_database_failure():
"""
GIVEN: Database update fails
WHEN: update_current_org_app_settings is called
THEN: OrgDatabaseError is raised
"""
from server.routes.org_models import OrgAppSettingsUpdate
# Arrange
user_id = str(uuid.uuid4())
org_id = uuid.uuid4()
mock_org = Org(
id=org_id,
name='Test Organization',
enable_proactive_conversation_starters=True,
)
update_data = OrgAppSettingsUpdate(enable_proactive_conversation_starters=False)
with (
patch(
'storage.org_service.OrgStore.get_current_org_from_keycloak_user_id',
return_value=mock_org,
),
patch(
'storage.org_service.OrgStore.update_org',
return_value=None,
),
):
# Act & Assert
with pytest.raises(OrgDatabaseError) as exc_info:
OrgService.update_current_org_app_settings(user_id, update_data)
assert 'Failed to update organization app settings' in str(exc_info.value)