mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
feat(backend): saas – organizations app settings api (#13022)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
130
enterprise/server/services/org_app_settings_service.py
Normal file
130
enterprise/server/services/org_app_settings_service.py
Normal 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)
|
||||
105
enterprise/storage/org_app_settings_store.py
Normal file
105
enterprise/storage/org_app_settings_store.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
189
enterprise/tests/unit/storage/test_org_app_settings_store.py
Normal file
189
enterprise/tests/unit/storage/test_org_app_settings_store.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user